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.