feat(server): filter nest queries by status, material, grade and thickness

A release that merges into an existing draft needs the drafts of the same
material group. The nest query can now filter exactly on status, material,
grade and thickness. Material and grade compare like the shared material
lists (case and spacing ignored) through a SQLite function, so no key columns
or backfill are needed; a blank grade asks for nests without one, and
thickness matches at the four decimals drawings are stored with.

The client sends the filters only when set, so plain browsing keeps working
against servers that do not know them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 10:25:52 -04:00
1 parent 28db6ae807
commit 4b604d3874
7 files changed
+181 -11

No files matched your search

+24 -1
View File
@@ -16,6 +16,24 @@ public sealed class NestQuery
/// </summary>
public string Search { get; init; } = "";
/// <summary>Only nests in this status; null means any.</summary>
public NestStatus? Status { get; init; }
/// <summary>
/// Only nests of this material name, compared by <see cref="SharedListNames.Key"/>
/// (case and spacing ignored); null means any.
/// </summary>
public string? Material { get; init; }
/// <summary>
/// Only nests of this grade, compared like <see cref="Material"/>; null means any and
/// blank means nests with no grade recorded.
/// </summary>
public string? MaterialGrade { get; init; }
/// <summary>Only nests of this thickness, compared at four decimals; null means any.</summary>
public double? Thickness { get; init; }
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
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));
}
}
+18 -6
View File
@@ -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<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, false, cancellationToken);
+49 -1
View File
@@ -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)
+25 -1
View File
@@ -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<NestRecord>();
@@ -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)
+32 -1
View File
@@ -10,7 +10,10 @@ namespace OpenNest.Server;
/// </summary>
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<NestStatus>(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,
+25
View File
@@ -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<NestRecord>() }));
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()
{
+8 -1
View File
@@ -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`,