diff --git a/OpenNest.Data/NestQuery.cs b/OpenNest.Data/NestQuery.cs index 2d59c6f8..74a8372a 100644 --- a/OpenNest.Data/NestQuery.cs +++ b/OpenNest.Data/NestQuery.cs @@ -16,6 +16,24 @@ public sealed class NestQuery /// public string Search { get; init; } = ""; + /// Only nests in this status; null means any. + public NestStatus? Status { get; init; } + + /// + /// Only nests of this material name, compared by + /// (case and spacing ignored); null means any. + /// + public string? Material { get; init; } + + /// + /// Only nests of this grade, compared like ; null means any and + /// blank means nests with no grade recorded. + /// + public string? MaterialGrade { get; init; } + + /// Only nests of this thickness, compared at four decimals; null means any. + public double? Thickness { get; init; } + /// Column to order by; ties are broken by id in the same direction. public NestSortField Sort { get; init; } = NestSortField.SavedAt; @@ -45,6 +63,11 @@ public sealed class NestQuery // SQLite LIKE stops at an embedded NUL, which would turn the rest of the text into a wildcard. if (NormalizedSearch.Contains('\0')) return "search must not contain NUL characters."; - return null; + if (Status is { } status && !Enum.IsDefined(status)) + return "status must be a supported status."; + if (Thickness is { } thickness && (!double.IsFinite(thickness) || thickness < 0)) + return "thickness must be a finite number, zero or greater."; + return (Material is null ? null : DrawingLibraryRules.TextError("material", Material, DrawingLibraryRules.MaxTextLength)) + ?? (MaterialGrade is null ? null : DrawingLibraryRules.TextError("materialGrade", MaterialGrade, DrawingLibraryRules.MaxTextLength)); } } diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index d4aa3546..906cbaa7 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -85,12 +85,24 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable return page; } - private static string BuildQueryString(NestQuery query) => - "search=" + Uri.EscapeDataString(query.NormalizedSearch) - + "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString()) - + "&order=" + (query.Descending ? "desc" : "asc") - + "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture) - + "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture); + // Filters are sent only when set, so plain browsing still works against servers without them. + private static string BuildQueryString(NestQuery query) + { + var text = "search=" + Uri.EscapeDataString(query.NormalizedSearch) + + "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString()) + + "&order=" + (query.Descending ? "desc" : "asc") + + "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture) + + "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture); + if (query.Status is { } status) + text += "&status=" + JsonNamingPolicy.CamelCase.ConvertName(status.ToString()); + if (query.Material is not null) + text += "&material=" + Uri.EscapeDataString(query.Material); + if (query.MaterialGrade is not null) + text += "&materialGrade=" + Uri.EscapeDataString(query.MaterialGrade); + if (query.Thickness is { } thickness) + text += "&thickness=" + thickness.ToString("R", CultureInfo.InvariantCulture); + return text; + } public Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) => GetMetadataCoreAsync(id, false, cancellationToken); diff --git a/OpenNest.Server.Tests/NestQueryTests.cs b/OpenNest.Server.Tests/NestQueryTests.cs index f90d2602..81a38e05 100644 --- a/OpenNest.Server.Tests/NestQueryTests.cs +++ b/OpenNest.Server.Tests/NestQueryTests.cs @@ -177,6 +177,47 @@ public sealed class NestQueryTests : IDisposable Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id)); } + [Fact] + public async Task Query_FiltersByStatusMaterialGradeAndThickness_ForReleaseDraftLookup() + { + NestRecord Add(string name, NestStatus status, string material, string grade, double thickness) => + _database.Insert(Guid.NewGuid(), new NestRecord + { + Name = name, + Status = status, + Material = material, + MaterialGrade = grade, + Thickness = thickness, + }, new byte[] { 1 }); + var match = Add("Matching draft", NestStatus.Draft, "Synthetic Steel", "a36", 0.25); + var spaced = Add("Spaced draft", NestStatus.Draft, " SYNTHETIC STEEL ", " A36 ", 0.25004); + Add("Quote", NestStatus.Quote, "Synthetic Steel", "A36", 0.25); + Add("Other grade", NestStatus.Draft, "Synthetic Steel", "A1011", 0.25); + Add("Thicker", NestStatus.Draft, "Synthetic Steel", "A36", 0.2501); + var ungraded = Add("No grade", NestStatus.Draft, "Synthetic Steel", "", 0.25); + _database.SetDeleted(Add("Deleted draft", NestStatus.Draft, "Synthetic Steel", "A36", 0.25).Id, true, 1); + + var drafts = await _repository.QueryAsync(new NestQuery + { + Status = NestStatus.Draft, + Material = "synthetic steel", + MaterialGrade = "A36", + Thickness = 0.25, + Sort = NestSortField.Name, + Descending = false, + }); + var blankGrade = await _repository.QueryAsync(new NestQuery { Status = NestStatus.Draft, MaterialGrade = "" }); + var anyGrade = await _repository.QueryAsync(new NestQuery { Material = "Synthetic Steel", Thickness = 0.25 }); + + Assert.Equal(new[] { match.Id, spaced.Id }, drafts.Items.Select(r => r.Id)); + Assert.Equal(2, drafts.Total); + Assert.Equal(ungraded.Id, Assert.Single(blankGrade.Items).Id); + Assert.Equal(5, anyGrade.Total); + var body = await _client.GetStringAsync("/api/nests/query?status=DRAFT&thickness=2.5e-1&material=synthetic%20steel&materialGrade=a36"); + Assert.Contains("Matching draft", body); + Assert.DoesNotContain("Quote", body); + } + [Fact] public async Task Query_DoesNotMatchDatesOrNumbers() { @@ -200,7 +241,14 @@ public sealed class NestQueryTests : IDisposable [InlineData("offset=99999999999")] [InlineData("limit=1&limit=2")] [InlineData("serach=beta")] - [InlineData("status=quote")] + [InlineData("status=1")] + [InlineData("status=cut")] + [InlineData("status=quote,draft")] + [InlineData("thickness=-1")] + [InlineData("thickness=abc")] + [InlineData("thickness=NaN")] + [InlineData("material=a&material=b")] + [InlineData("materialGrade=%00")] [InlineData("search=Alpha%00not-present")] [InlineData("search=%00")] public async Task Query_InvalidParameters_Return400WithoutItems(string queryString) diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 59879f50..e716fcd0 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -29,6 +29,8 @@ public sealed partial class NestDatabase : IDisposable Pooling = pooling, }.ToString()); _connection.Open(); + // Nest material and grade filters compare as the shared material lists do (case and spacing ignored). + _connection.CreateFunction("shared_key", (string? name) => SharedListNames.Key(name), isDeterministic: true); try { Execute(""" @@ -97,12 +99,14 @@ public sealed partial class NestDatabase : IDisposable throw new ArgumentException(error, nameof(query)); var search = query.NormalizedSearch; - var where = "WHERE deleted=" + (deleted ? "1" : "0") + (search.Length == 0 ? "" : " AND (" + SearchPredicate + ")"); + var where = "WHERE deleted=" + (deleted ? "1" : "0") + (search.Length == 0 ? "" : " AND (" + SearchPredicate + ")") + + FilterPredicate(query); lock (_sync) { using var count = _connection.CreateCommand(); count.CommandText = $"SELECT COUNT(*) FROM nests {where}"; AddSearchParameter(count, search); + AddFilterParameters(count, query); var total = checked((int)(long)count.ExecuteScalar()!); using var command = _connection.CreateCommand(); @@ -113,6 +117,7 @@ public sealed partial class NestDatabase : IDisposable LIMIT $limit OFFSET $offset """; AddSearchParameter(command, search); + AddFilterParameters(command, query); command.Parameters.AddWithValue("$limit", query.Limit); command.Parameters.AddWithValue("$offset", query.Offset); var records = new List(); @@ -489,6 +494,25 @@ public sealed partial class NestDatabase : IDisposable _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), }; + // Thickness matches when both round to the same four decimals, as drawing thicknesses are stored. + private static string FilterPredicate(NestQuery query) => + (query.Status is null ? "" : " AND status=$status") + + (query.Material is null ? "" : " AND shared_key(material)=$materialKey") + + (query.MaterialGrade is null ? "" : " AND shared_key(materialGrade)=$gradeKey") + + (query.Thickness is null ? "" : " AND ABS(ROUND(thickness, 4) - $thickness) < 0.000001"); + + private static void AddFilterParameters(SqliteCommand command, NestQuery query) + { + if (query.Status is { } status) + command.Parameters.AddWithValue("$status", status.ToString()); + if (query.Material is not null) + command.Parameters.AddWithValue("$materialKey", SharedListNames.Key(query.Material)); + if (query.MaterialGrade is not null) + command.Parameters.AddWithValue("$gradeKey", SharedListNames.Key(query.MaterialGrade)); + if (query.Thickness is { } thickness) + command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(thickness)); + } + private static void AddSearchParameter(SqliteCommand command, string search) { if (search.Length == 0) diff --git a/OpenNest.Server/NestQueryRequest.cs b/OpenNest.Server/NestQueryRequest.cs index 779a0578..ed01e87d 100644 --- a/OpenNest.Server/NestQueryRequest.cs +++ b/OpenNest.Server/NestQueryRequest.cs @@ -10,7 +10,10 @@ namespace OpenNest.Server; /// internal static class NestQueryRequest { - private static readonly string[] Keys = { "search", "sort", "order", "offset", "limit" }; + private static readonly string[] Keys = + { + "search", "sort", "order", "offset", "limit", "status", "material", "materialGrade", "thickness", + }; public static bool TryParse(IQueryCollection values, out NestQuery query, out string error) { @@ -52,6 +55,29 @@ internal static class NestQueryRequest } } + NestStatus? status = null; + if (values.TryGetValue("status", out var statusText)) + { + if (!DrawingLibraryRequest.TryEnumName(statusText.ToString(), out var parsed)) + { + error = "status must be draft, quote, toBeCut or hasBeenCut."; + return false; + } + status = parsed; + } + + double? thickness = null; + if (values.TryGetValue("thickness", out var thicknessText)) + { + if (!double.TryParse(thicknessText.ToString(), NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture, out var parsed)) + { + error = "thickness must be a number, zero or greater."; + return false; + } + thickness = parsed; + } + var offset = 0; var limit = NestQuery.DefaultLimit; if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) @@ -60,6 +86,11 @@ internal static class NestQueryRequest query = new NestQuery { Search = values["search"].ToString(), + Status = status, + // Present but blank is a filter too: a blank grade asks for nests without one. + Material = values.TryGetValue("material", out var material) ? material.ToString() : null, + MaterialGrade = values.TryGetValue("materialGrade", out var grade) ? grade.ToString() : null, + Thickness = thickness, Sort = sort, Descending = descending, Offset = offset, diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs index a5cc5ee3..67a67409 100644 --- a/OpenNest.Tests/Data/NestStorageTests.cs +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -268,8 +268,33 @@ public class RemoteNestRepositoryTests new NestQuery { Search = new string('x', NestQuery.MaxSearchLength + 1) }, new NestQuery { Sort = (NestSortField)99 }, new NestQuery { Search = "Alpha\0not-present" }, + new NestQuery { Status = (NestStatus)99 }, + new NestQuery { Thickness = -0.1 }, + new NestQuery { Thickness = double.NaN }, + new NestQuery { Material = new string('m', 201) }, + new NestQuery { MaterialGrade = "A\0" }, }; + [Fact] + public async Task QueryAsync_SendsDraftFilters_IncludingABlankGrade() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty() })); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); + + await repo.QueryAsync(new NestQuery + { + Status = NestStatus.Draft, + Material = "Carbon Steel", + MaterialGrade = "", + Thickness = 0.1875, + }); + + Assert.EndsWith( + "/api/nests/query?search=&sort=savedAt&order=desc&offset=0&limit=100" + + "&status=draft&material=Carbon%20Steel&materialGrade=&thickness=0.1875", + handler.Requests[0].RequestUri!.AbsoluteUri); + } + [Fact] public async Task QueryAsync_SendsSortColumnAndDirection() { diff --git a/docs/nest-storage.md b/docs/nest-storage.md index cf288c67..5526f62a 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -99,7 +99,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr |---|---|---|---| | GET | `/healthz` | — | 200 `{ "status": "ok" }` after a live database query; 503 `{ "status": "unavailable" }` on storage failure (no internal details) | | GET | `/api/nests` | — | `NestRecord[]`, newest `savedAt` first. Full, unbounded enumeration for backup manifests, restore checks and the container smoke; browsing uses `/api/nests/query` | -| GET | `/api/nests/query?search=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter | +| GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter | | GET | `/api/nests/{id}` | — | `NestRecord` or 404 | | GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 | | POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing | @@ -170,6 +170,13 @@ only the requested page of metadata (never archive bytes): `materialGrade`, `madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`). `%`, `_` and `\` are literal. Dates and numbers are not matched as text. Case folding is ASCII-only (SQLite `LIKE`). +- Exact filters (optional, all must match; used to find draft nests a release + can merge into): `status` (`draft`, `quote`, `toBeCut` or `hasBeenCut`), + `material` and `materialGrade` (compared by `SharedListNames.Key`, so case and + spacing are ignored; a present but blank `materialGrade` asks for nests with no + grade) and `thickness` (a number, zero or greater, matched at four decimals). + The client sends them only when set, so plain browsing still works against + servers without them. - `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500). - `sort` (default `savedAt`): one of `savedAt`, `name`, `customer`, `status`, `material`, `dateCreated`, `dateModified`, `thickness`, `plateCount`,