diff --git a/OpenNest.Data/NestQuery.cs b/OpenNest.Data/NestQuery.cs index f656bf6..40f207a 100644 --- a/OpenNest.Data/NestQuery.cs +++ b/OpenNest.Data/NestQuery.cs @@ -16,6 +16,12 @@ public sealed class NestQuery /// public string Search { get; init; } = ""; + /// Column to order by; ties are broken by id in the same direction. + public NestSortField Sort { get; init; } = NestSortField.SavedAt; + + /// Largest/newest/Z first when true (the default, newest saved first). + public bool Descending { get; init; } = true; + /// Number of matching records to skip, zero or greater. public int Offset { get; init; } @@ -28,6 +34,8 @@ public sealed class NestQuery /// The first violated bound, or null when the query may be sent. public string? GetValidationError() { + if (!Enum.IsDefined(Sort)) + return "sort must be a supported column."; if (Offset < 0) return "offset must be zero or greater."; if (Limit < 1 || Limit > MaxLimit) diff --git a/OpenNest.Data/NestSortField.cs b/OpenNest.Data/NestSortField.cs new file mode 100644 index 0000000..c0fdd94 --- /dev/null +++ b/OpenNest.Data/NestSortField.cs @@ -0,0 +1,19 @@ +namespace OpenNest.Data; + +/// Columns a saved-nest browse query may order by (camelCase on the wire). +public enum NestSortField +{ + SavedAt, + Name, + Customer, + Status, + Material, + DateCreated, + DateModified, + Thickness, + PlateCount, + PartCount, + MadeBy, + Comments, + FileSize, +} diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index 3a32cbe..aa8b7e4 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -88,6 +88,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable 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); diff --git a/OpenNest.Server.Tests/NestQueryTests.cs b/OpenNest.Server.Tests/NestQueryTests.cs index 4bada79..9c4e7e5 100644 --- a/OpenNest.Server.Tests/NestQueryTests.cs +++ b/OpenNest.Server.Tests/NestQueryTests.cs @@ -224,6 +224,68 @@ public sealed class NestQueryTests : IDisposable Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + public static TheoryData SortCases() + { + var data = new TheoryData(); + foreach (var field in Enum.GetValues()) + { + data.Add(field, false); + data.Add(field, true); + } + + return data; + } + + [Theory] + [MemberData(nameof(SortCases))] + public async Task Query_SortsEachAllowlistedColumnAcrossPages(NestSortField field, bool descending) + { + var expected = Sorted(Seed(25), field, descending).Select(r => r.Id).ToArray(); + var collected = new List(); + + for (var offset = 0; offset < expected.Length; offset += 6) + { + var page = await _repository.QueryAsync( + new NestQuery { Sort = field, Descending = descending, Offset = offset, Limit = 6 }); + collected.AddRange(page.Items.Select(r => r.Id)); + } + + Assert.Equal(expected, collected); + } + + [Fact] + public async Task Query_SortAndOrderNamesAreCaseInsensitive() + { + var expected = Sorted(Seed(12), NestSortField.PlateCount, descending: false).Select(r => r.Id); + + var body = await _client.GetStringAsync("/api/nests/query?sort=PLATECOUNT&order=ASC"); + var ids = JsonDocument.Parse(body).RootElement.GetProperty("items").EnumerateArray() + .Select(item => item.GetProperty("id").GetGuid()); + + Assert.Equal(expected, ids); + } + + [Theory] + [InlineData("sort=unknown")] + [InlineData("sort=1")] + [InlineData("sort=-1")] + [InlineData("sort=")] + [InlineData("sort=saved_at")] + [InlineData("sort=savedAt&sort=name")] + [InlineData("order=up")] + [InlineData("order=")] + [InlineData("order=asc&order=desc")] + public async Task Query_InvalidSortOrOrder_Returns400WithoutItems(string queryString) + { + Seed(3); + + using var response = await _client.GetAsync("/api/nests/query?" + queryString); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.DoesNotContain("items", body); + } + [Fact] public async Task List_RemainsTheUnboundedFullEnumeration() { @@ -251,19 +313,20 @@ public sealed class NestQueryTests : IDisposable { var record = new NestRecord { - Name = $"Synthetic browse nest {index:D2}", + // Alternate case so text sorts must fold case rather than order by code point. + Name = (index % 2 == 0 ? "Synthetic" : "synthetic") + $" browse nest {index:D2}", Customer = customers[index % customers.Length], DateCreated = new DateTime(2026, 1, 1 + index % 28, 8, 0, 0, DateTimeKind.Unspecified), DateModified = new DateTime(2026, 1, 2 + index % 27, 9, 0, 0, DateTimeKind.Unspecified), Material = index % 3 == 0 ? "Synthetic steel" : "Synthetic alloy", - Thickness = 0.25, + Thickness = 0.25 + index % 3 * 0.125, Status = statuses[index % statuses.Length], PlateCount = 1 + index % 4, PartCount = 3 + index, Comments = index % 5 == 0 ? "rush" : index % 7 == 1 ? @"Path C:\jobs" : "", MadeBy = index % 2 == 0 ? "Synthetic operator A" : "Synthetic operator B", }; - stored.Add(_database.Insert(Guid.NewGuid(), record, new byte[] { (byte)index, 1, 2 })); + stored.Add(_database.Insert(Guid.NewGuid(), record, new byte[3 + index % 4])); } return stored; @@ -276,6 +339,40 @@ public sealed class NestQueryTests : IDisposable .ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal) .ToArray(); + /// Independent oracle for allowlisted sorts: ASCII case-folded text, then id. + private static IEnumerable Sorted(IEnumerable records, NestSortField field, bool descending) + { + static Comparison Text(Func value) => + (a, b) => string.CompareOrdinal(value(a).ToLowerInvariant(), value(b).ToLowerInvariant()); + + Comparison compare = field switch + { + NestSortField.SavedAt => (a, b) => a.SavedAt.CompareTo(b.SavedAt), + NestSortField.Name => Text(r => r.Name), + NestSortField.Customer => Text(r => r.Customer), + NestSortField.Status => Text(r => r.Status.ToString()), + NestSortField.Material => Text(r => r.Material), + NestSortField.DateCreated => (a, b) => a.DateCreated.CompareTo(b.DateCreated), + NestSortField.DateModified => (a, b) => a.DateModified.CompareTo(b.DateModified), + NestSortField.Thickness => (a, b) => a.Thickness.CompareTo(b.Thickness), + NestSortField.PlateCount => (a, b) => a.PlateCount.CompareTo(b.PlateCount), + NestSortField.PartCount => (a, b) => a.PartCount.CompareTo(b.PartCount), + NestSortField.MadeBy => Text(r => r.MadeBy), + NestSortField.Comments => Text(r => r.Comments), + NestSortField.FileSize => (a, b) => a.FileSize.CompareTo(b.FileSize), + _ => throw new ArgumentOutOfRangeException(nameof(field)), + }; + var sorted = records.ToList(); + sorted.Sort((a, b) => + { + var result = compare(a, b); + if (result == 0) + result = string.CompareOrdinal(a.Id.ToString(), b.Id.ToString()); + return descending ? -result : result; + }); + return sorted; + } + private static void AssertSameMetadata(NestRecord expected, NestRecord actual) => Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual)); } diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 270962e..a7e67a0 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -95,9 +95,10 @@ public sealed class NestDatabase : IDisposable var total = checked((int)(long)count.ExecuteScalar()!); using var command = _connection.CreateCommand(); + var direction = query.Descending ? "DESC" : "ASC"; command.CommandText = $""" SELECT {RecordColumns} FROM nests {where} - ORDER BY savedAt DESC, id DESC + ORDER BY {SortColumn(query.Sort)} {direction}, id {direction} LIMIT $limit OFFSET $offset """; AddSearchParameter(command, search); @@ -227,6 +228,25 @@ public sealed class NestDatabase : IDisposable ELSE status END) LIKE $pattern ESCAPE '\' """; + // Fixed allowlist: request text never reaches the ORDER BY clause. + private static string SortColumn(NestSortField field) => field switch + { + NestSortField.SavedAt => "savedAt", + NestSortField.Name => "name COLLATE NOCASE", + NestSortField.Customer => "customer COLLATE NOCASE", + NestSortField.Status => "status COLLATE NOCASE", + NestSortField.Material => "material COLLATE NOCASE", + NestSortField.DateCreated => "dateCreated", + NestSortField.DateModified => "dateModified", + NestSortField.Thickness => "thickness", + NestSortField.PlateCount => "plateCount", + NestSortField.PartCount => "partCount", + NestSortField.MadeBy => "madeBy COLLATE NOCASE", + NestSortField.Comments => "comments COLLATE NOCASE", + NestSortField.FileSize => "fileSize", + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), + }; + 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 fc06de1..779a057 100644 --- a/OpenNest.Server/NestQueryRequest.cs +++ b/OpenNest.Server/NestQueryRequest.cs @@ -10,7 +10,7 @@ namespace OpenNest.Server; /// internal static class NestQueryRequest { - private static readonly string[] Keys = { "search", "offset", "limit" }; + private static readonly string[] Keys = { "search", "sort", "order", "offset", "limit" }; public static bool TryParse(IQueryCollection values, out NestQuery query, out string error) { @@ -30,16 +30,57 @@ internal static class NestQueryRequest } } + var sort = NestSortField.SavedAt; + if (values.TryGetValue("sort", out var sortText) + && !TrySortField(sortText.ToString(), out sort)) + { + error = $"sort must be one of: {string.Join(", ", Enum.GetNames().Select(CamelCase))}."; + return false; + } + + var descending = true; + if (values.TryGetValue("order", out var orderText)) + { + if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase)) + { + descending = false; + } + else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase)) + { + error = "order must be asc or desc."; + return false; + } + } + var offset = 0; var limit = NestQuery.DefaultLimit; if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) return false; - query = new NestQuery { Search = values["search"].ToString(), Offset = offset, Limit = limit }; + query = new NestQuery + { + Search = values["search"].ToString(), + Sort = sort, + Descending = descending, + Offset = offset, + Limit = limit, + }; error = query.GetValidationError() ?? ""; return error.Length == 0; } + // Names only: Enum.TryParse would also accept numbers and undefined values. + private static bool TrySortField(string text, out NestSortField field) + { + field = NestSortField.SavedAt; + return text.Length > 0 + && text.All(char.IsAsciiLetter) + && Enum.TryParse(text, ignoreCase: true, out field) + && Enum.IsDefined(field); + } + + private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error) { error = ""; diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs index 507a534..a3b44a3 100644 --- a/OpenNest.Tests/Data/NestStorageTests.cs +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -232,7 +232,7 @@ public class RemoteNestRepositoryTests Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); Assert.Equal( - "http://server:8090/base/api/nests/query?search=a%26b%3Dc%20%25_%5C&offset=20&limit=20", + "http://server:8090/base/api/nests/query?search=a%26b%3Dc%20%25_%5C&sort=savedAt&order=desc&offset=20&limit=20", handler.Requests[0].RequestUri!.AbsoluteUri); Assert.Equal(record.Id, Assert.Single(page.Items).Id); Assert.Equal(41, page.Total); @@ -246,8 +246,22 @@ public class RemoteNestRepositoryTests new NestQuery { Limit = 0 }, new NestQuery { Limit = NestQuery.MaxLimit + 1 }, new NestQuery { Search = new string('x', NestQuery.MaxSearchLength + 1) }, + new NestQuery { Sort = (NestSortField)99 }, }; + [Fact] + public async Task QueryAsync_SendsSortColumnAndDirection() + { + 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 { Sort = NestSortField.PlateCount, Descending = false }); + + Assert.EndsWith( + "/api/nests/query?search=&sort=plateCount&order=asc&offset=0&limit=100", + handler.Requests[0].RequestUri!.AbsoluteUri); + } + [Theory] [MemberData(nameof(OutOfBoundsQueries))] public async Task QueryAsync_OutOfBounds_ThrowsWithoutSending(NestQuery query) diff --git a/docs/nest-storage.md b/docs/nest-storage.md index e26e0bd..45fca69 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -47,7 +47,7 @@ with enums serialized as strings (`JsonSerializerDefaults.Web` + |---|---|---|---| | 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=&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=&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 | @@ -69,7 +69,12 @@ only the requested page of metadata (never archive bytes): `%`, `_` and `\` are literal. Dates and numbers are not matched as text. Case folding is ASCII-only (SQLite `LIKE`). - `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500). -- Order: newest `savedAt` first, then `id`, so pages partition the matches +- `sort` (default `savedAt`): one of `savedAt`, `name`, `customer`, `status`, + `material`, `dateCreated`, `dateModified`, `thickness`, `plateCount`, + `partCount`, `madeBy`, `comments`, `fileSize` (names, case-insensitive; no + numbers). Text columns sort case-insensitively (ASCII); dates sort as their + stored ISO text. `order` is `asc` or `desc` (default `desc`). +- Ties are broken by `id` in the same direction, so pages partition the matches deterministically. `total` counts every match and is read with the page in one database hold. A save between two page requests can move a row across a page boundary.