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`,