From be057bf3375f1c31a332514d2f3411f2c3416a92 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 12:51:56 -0400 Subject: [PATCH] feat(server): store versioned drawing sets A drawing set is a named list of library drawings with quantities (a kit, an assembly, a recurring order) that will later be released into one nest per material. This is the server half: sets get the same revision, If-Match and archive contract as /api/drawings so the client and tests follow a known shape. Schema v7 adds drawing_sets, drawing_set_revisions and drawing_set_members. Members reference drawings by id and mean each drawing's current revision, so reads join the current drawing record: the client can group members by material without one request per drawing, and the joined record is ignored on save so a read record can be sent back unchanged. Every change keeps its own member list, and /api/drawings/{id}/sets answers "used in" from current revisions only. The drawing query's enum, integer and history-paging parsing is now shared so both APIs refuse bad input identically. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/DrawingSetMember.cs | 15 + OpenNest.Data/DrawingSetPage.cs | 15 + OpenNest.Data/DrawingSetQuery.cs | 48 +++ OpenNest.Data/DrawingSetRecord.cs | 39 ++ OpenNest.Data/DrawingSetRules.cs | 51 +++ OpenNest.Data/DrawingSetSortField.cs | 10 + .../DrawingLibraryDatabaseTests.cs | 13 +- OpenNest.Server.Tests/DrawingSetApiTests.cs | 337 +++++++++++++++ .../DrawingSetDatabaseTests.cs | 365 ++++++++++++++++ OpenNest.Server.Tests/DrawingSetFixtures.cs | 53 +++ OpenNest.Server/DrawingLibraryRequest.cs | 43 +- OpenNest.Server/DrawingLibraryRoutes.cs | 18 +- OpenNest.Server/DrawingSetRequest.cs | 140 +++++++ OpenNest.Server/DrawingSetRoutes.cs | 111 +++++ OpenNest.Server/NestDatabase.Drawings.cs | 47 +-- OpenNest.Server/NestDatabase.Sets.cs | 391 ++++++++++++++++++ OpenNest.Server/NestDatabase.cs | 7 +- OpenNest.Server/Program.cs | 1 + docs/nest-storage.md | 55 +++ 19 files changed, 1711 insertions(+), 48 deletions(-) create mode 100644 OpenNest.Data/DrawingSetMember.cs create mode 100644 OpenNest.Data/DrawingSetPage.cs create mode 100644 OpenNest.Data/DrawingSetQuery.cs create mode 100644 OpenNest.Data/DrawingSetRecord.cs create mode 100644 OpenNest.Data/DrawingSetRules.cs create mode 100644 OpenNest.Data/DrawingSetSortField.cs create mode 100644 OpenNest.Server.Tests/DrawingSetApiTests.cs create mode 100644 OpenNest.Server.Tests/DrawingSetDatabaseTests.cs create mode 100644 OpenNest.Server.Tests/DrawingSetFixtures.cs create mode 100644 OpenNest.Server/DrawingSetRequest.cs create mode 100644 OpenNest.Server/DrawingSetRoutes.cs create mode 100644 OpenNest.Server/NestDatabase.Sets.cs diff --git a/OpenNest.Data/DrawingSetMember.cs b/OpenNest.Data/DrawingSetMember.cs new file mode 100644 index 00000000..1f37d833 --- /dev/null +++ b/OpenNest.Data/DrawingSetMember.cs @@ -0,0 +1,15 @@ +namespace OpenNest.Data; + +/// One drawing of a set and how many of it the set needs. +public sealed class DrawingSetMember +{ + public Guid DrawingId { get; set; } + + public int Quantity { get; set; } = 1; + + /// + /// The drawing's current record, archived or not, joined by the server when it reads + /// a set. Ignored on save. + /// + public DrawingLibraryRecord? Drawing { get; set; } +} diff --git a/OpenNest.Data/DrawingSetPage.cs b/OpenNest.Data/DrawingSetPage.cs new file mode 100644 index 00000000..038c3abb --- /dev/null +++ b/OpenNest.Data/DrawingSetPage.cs @@ -0,0 +1,15 @@ +namespace OpenNest.Data; + +/// One bounded page of drawing set records, without their members. +public sealed class DrawingSetPage +{ + /// At most records, in the requested order. + public IReadOnlyList Items { get; init; } = Array.Empty(); + + /// Number of records matching the request across all pages. + public int Total { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } +} diff --git a/OpenNest.Data/DrawingSetQuery.cs b/OpenNest.Data/DrawingSetQuery.cs new file mode 100644 index 00000000..a9162e10 --- /dev/null +++ b/OpenNest.Data/DrawingSetQuery.cs @@ -0,0 +1,48 @@ +namespace OpenNest.Data; + +/// +/// One bounded page request for browsing drawing sets (GET /api/sets/query). +/// The server filters and pages in SQL; the client and server share these bounds. +/// +public sealed class DrawingSetQuery +{ + public const int DefaultLimit = 100; + public const int MaxLimit = 500; + public const int MaxSearchLength = 200; + + /// + /// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter. + /// + public string Search { get; init; } = ""; + + /// Exact customer, compared by . Blank means any. + public string Customer { get; init; } = ""; + + public bool IncludeArchived { get; init; } + + /// Column to order by; ties are broken by id in the same direction. + public DrawingSetSortField Sort { get; init; } = DrawingSetSortField.SavedAt; + + /// Largest/newest/Z first when true (the default, newest saved first). + public bool Descending { get; init; } = true; + + public int Offset { get; init; } + + public int Limit { get; init; } = DefaultLimit; + + /// The search text as matched: trimmed, never null. + public string NormalizedSearch => (Search ?? "").Trim(); + + /// 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) + return $"limit must be between 1 and {MaxLimit}."; + return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength) + ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength); + } +} diff --git a/OpenNest.Data/DrawingSetRecord.cs b/OpenNest.Data/DrawingSetRecord.cs new file mode 100644 index 00000000..fca2c7cc --- /dev/null +++ b/OpenNest.Data/DrawingSetRecord.cs @@ -0,0 +1,39 @@ +namespace OpenNest.Data; + +/// +/// One revision of a drawing set: a named list of library drawings with per-member +/// quantities (a kit, an assembly, a recurring order). Members reference drawings by id; +/// the set always means each drawing's current revision. +/// +public sealed class DrawingSetRecord +{ + /// Allocated by the client before the first save; kept by every revision. + public Guid Id { get; set; } + + /// Immutable revision number, starting at 1. Ignored on save. + public int Revision { get; set; } + + public string Name { get; set; } = ""; + + public string Customer { get; set; } = ""; + + public string Notes { get; set; } = ""; + + /// Hidden from browsing by default; still readable by id. + public bool Archived { get; set; } + + /// When the server stored this revision. Server-computed. + public DateTime SavedAt { get; set; } + + /// Who saved this revision, as reported by the client. + public string SavedBy { get; set; } = ""; + + /// Number of members in this revision. Server-computed. + public int MemberCount { get; set; } + + /// + /// The members in order, one per drawing. Empty on query and history pages, which + /// carry only. + /// + public IReadOnlyList Members { get; set; } = Array.Empty(); +} diff --git a/OpenNest.Data/DrawingSetRules.cs b/OpenNest.Data/DrawingSetRules.cs new file mode 100644 index 00000000..88e38bf8 --- /dev/null +++ b/OpenNest.Data/DrawingSetRules.cs @@ -0,0 +1,51 @@ +namespace OpenNest.Data; + +/// +/// Bounds shared by the drawing set client and server. The client checks them before +/// sending; the server refuses anything outside them with no state change. Whether each +/// member's drawing exists is checked by the server. +/// +public static class DrawingSetRules +{ + public const int MaxMembers = 500; + + public const int MaxQuantity = 100_000; + + /// Largest accepted set JSON body (1 MiB): room for members echoed with their drawings. + public const int MaxBodyBytes = 1024 * 1024; + + /// The first rule breaks, or null when it may be saved. + public static string? GetError(DrawingSetRecord? record) + { + if (record is null) + return "Missing set."; + if (string.IsNullOrWhiteSpace(record.Name)) + return "name is required."; + var error = DrawingLibraryRules.TextError("name", record.Name, DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("customer", record.Customer, DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("savedBy", record.SavedBy, DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("notes", record.Notes, DrawingLibraryRules.MaxNotesLength); + if (error is not null) + return error; + if (record.Members is null) + return "members is required."; + if (record.Members.Count > MaxMembers) + return $"A set may have at most {MaxMembers} members."; + + var seen = new HashSet(); + for (var i = 0; i < record.Members.Count; i++) + { + var member = record.Members[i]; + if (member is null) + return $"members[{i}] is required."; + if (member.DrawingId == Guid.Empty) + return $"members[{i}].drawingId is required."; + if (member.Quantity < 1 || member.Quantity > MaxQuantity) + return $"members[{i}].quantity must be between 1 and {MaxQuantity}."; + if (!seen.Add(member.DrawingId)) + return $"members[{i}] repeats drawing {member.DrawingId}: use one member with a larger quantity."; + } + + return null; + } +} diff --git a/OpenNest.Data/DrawingSetSortField.cs b/OpenNest.Data/DrawingSetSortField.cs new file mode 100644 index 00000000..7a894239 --- /dev/null +++ b/OpenNest.Data/DrawingSetSortField.cs @@ -0,0 +1,10 @@ +namespace OpenNest.Data; + +/// Columns a drawing set query may order by (camelCase on the wire). +public enum DrawingSetSortField +{ + SavedAt, + Name, + Customer, + Revision, +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 944dd18b..4a9e9139 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -42,7 +42,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -79,7 +79,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=7"); + Execute(factory.DatabasePath, "PRAGMA user_version=8"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } @@ -220,7 +220,7 @@ public sealed class DrawingLibraryDatabaseTests } } - /// A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly. + /// A real schema-v3 file: the drawing and set tables are additive, so dropping them restores v3 exactly. private static void CreateVersion3Database(string path) { using (var database = new NestDatabase(path, pooling: false)) @@ -235,7 +235,10 @@ public sealed class DrawingLibraryDatabaseTests database.SetDeleted(a.Id, false, 3); } - Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;"); + Execute(path, """ + DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; + DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3; + """); } private static readonly byte[] V4PackageA = Package("v4 original"); diff --git a/OpenNest.Server.Tests/DrawingSetApiTests.cs b/OpenNest.Server.Tests/DrawingSetApiTests.cs new file mode 100644 index 00000000..bedb4954 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingSetApiTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using static OpenNest.Server.Tests.DrawingSetFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class DrawingSetApiTests +{ + [Fact] + public async Task Create_StoresRevisionOneWithMembersJoinedToTheirDrawings() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var a = await AddDrawing(client, "Synthetic A"); + var b = await AddDrawing(client, "Synthetic B", r => r.CutType = CutType.Waterjet); + var set = Set(members: [(a, 4), (b, 1)]); + set.Revision = 9; + set.MemberCount = 99; + + var saved = await Create(client, set); + + Assert.Equal((set.Id, 1, 2, false), (saved.Id, saved.Revision, saved.MemberCount, saved.Archived)); + Assert.Equal(("Synthetic kit", "Synthetic customer", "Synthetic set notes", "Synthetic operator"), + (saved.Name, saved.Customer, saved.Notes, saved.SavedBy)); + Assert.NotEqual(default, saved.SavedAt); + Assert.Equal(new[] { (a, 4), (b, 1) }, MembersOf(saved)); + Assert.Equal(new[] { "Synthetic A", "Synthetic B" }, saved.Members.Select(m => m.Drawing!.Name)); + var fetched = await client.GetFromJsonAsync($"/api/sets/{set.Id}", Options); + Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(fetched)); + + var json = JsonNode.Parse(await client.GetStringAsync($"/api/sets/{set.Id}"))!; + var joined = json["members"]![1]!["drawing"]!; + Assert.Equal(b.ToString(), json["members"]![1]!["drawingId"]!.GetValue()); + Assert.Equal("waterjet", joined["cutType"]!.GetValue()); + Assert.Equal("inches", joined["units"]!.GetValue()); + } + + [Fact] + public async Task Create_ExistingId_ConflictsWithTheCurrentRecord() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = Set(); + await Create(client, set); + + using var response = await client.PostAsync("/api/sets", Json(Set("Second writer", set.Id))); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var current = await Read(response); + Assert.Equal(("Synthetic kit", 1), (current.Name, current.Revision)); + } + + public static TheoryData RejectedSets() + { + var drawing = Guid.Parse("00000000-0000-0000-0000-0000000000d1"); + var valid = JsonSerializer.Serialize(Set(members: (drawing, 2)), Options); + string With(string property, JsonNode? value) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node[property] = value; + return node.ToJsonString(); + } + string Without(string property) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node.Remove(property); + return node.ToJsonString(); + } + JsonArray Members(params (Guid Drawing, object? Quantity)[] members) => + new(members.Select(m => (JsonNode)new JsonObject { ["drawingId"] = m.Drawing.ToString(), ["quantity"] = JsonValue.Create(m.Quantity) }).ToArray()); + + return new TheoryData + { + { "empty id", With("id", Guid.Empty.ToString()) }, + { "blank name", With("name", " ") }, + { "null name", With("name", null) }, + { "long name", With("name", new string('n', 201)) }, + { "NUL customer", With("customer", "a\0b") }, + { "long notes", With("notes", new string('x', 4001)) }, + { "missing members", Without("members") }, + { "null members", With("members", null) }, + { "members not an array", With("members", "a") }, + { "null member", With("members", new JsonArray((JsonNode?)null)) }, + { "empty drawing id", With("members", Members((Guid.Empty, 1))) }, + { "zero quantity", With("members", Members((drawing, 0))) }, + { "quantity over the limit", With("members", Members((drawing, 100_001))) }, + { "fractional quantity", With("members", Members((drawing, 1.5))) }, + { "repeated drawing", With("members", Members((drawing, 1), (drawing, 2))) }, + { "unknown drawing", With("members", Members((Guid.NewGuid(), 1))) }, + { "too many members", With("members", Members(Enumerable.Range(0, 501).Select(_ => (drawing, (object?)1)).ToArray())) }, + { "not an object", "[1]" }, + { "malformed", "{" }, + { "empty", "" }, + }; + } + + [Theory] + [MemberData(nameof(RejectedSets))] + public async Task Create_InvalidSet_IsRejectedWithNoState(string row, string json) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + // The one drawing the valid members name. + await AddDrawing(client, "Synthetic A", r => r.Id = Guid.Parse("00000000-0000-0000-0000-0000000000d1")); + + using var response = await client.PostAsync("/api/sets", Json(json)); + + Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row); + await AssertNoSets(client); + } + + [Fact] + public async Task Create_WrongContentTypeOrOversizedBody_IsRejected() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var text = await client.PostAsync("/api/sets", new StringContent(JsonSerializer.Serialize(Set(), Options))); + var padded = Set(); + padded.Notes = new string(' ', DrawingSetRules.MaxBodyBytes); + using var oversized = await client.PostAsync("/api/sets", Json(padded)); + + Assert.Equal(HttpStatusCode.BadRequest, text.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, oversized.StatusCode); + Assert.Contains("too large", await oversized.Content.ReadAsStringAsync()); + await AssertNoSets(client); + } + + [Fact] + public async Task Update_FollowsTheIfMatchContract() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var drawing = await AddDrawing(client); + var set = await Create(client, Set(members: (drawing, 1))); + var url = $"/api/sets/{set.Id}"; + + async Task Put(string? ifMatch, DrawingSetRecord body, string target) + { + using var response = await client.SendAsync(Guarded(HttpMethod.Put, target, ifMatch, Json(body))); + return response.StatusCode; + } + + Assert.Equal((HttpStatusCode)428, await Put(null, Set("Edit", set.Id), url)); + Assert.Equal(HttpStatusCode.BadRequest, await Put("1", Set("Edit", set.Id), url)); + Assert.Equal(HttpStatusCode.BadRequest, await Put("\"1\"", Set("Edit", Guid.NewGuid()), url)); + Assert.Equal(HttpStatusCode.NotFound, await Put("\"1\"", Set("Edit", Guid.Empty), $"/api/sets/{Guid.NewGuid()}")); + using (var stale = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"2\"", Json(Set("Edit", set.Id))))) + { + Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode); + var current = await Read(stale); + Assert.Equal((1, "Synthetic kit"), (current.Revision, current.Name)); + Assert.Single(current.Members); + } + + // An id-less body updates the route's set. + var edit = Set("Edited kit", Guid.Empty, (drawing, 7)); + using var saved = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"1\"", Json(edit))); + Assert.Equal(HttpStatusCode.OK, saved.StatusCode); + var updated = await Read(saved); + Assert.Equal((2, "Edited kit"), (updated.Revision, updated.Name)); + Assert.Equal(new[] { (drawing, 7) }, MembersOf(updated)); + } + + [Fact] + public async Task Update_EchoingAReadRecord_IsANoOp() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var drawing = await AddDrawing(client); + var created = await Create(client, Set(members: (drawing, 3))); + var set = (await client.GetFromJsonAsync($"/api/sets/{created.Id}", Options))!; + Assert.NotNull(set.Members[0].Drawing); + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{set.Id}", "\"1\"", Json(set))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, (await Read(response)).Revision); + var history = await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions", Options); + Assert.Equal(1, history!.Total); + } + + [Fact] + public async Task ArchiveAndRestore_HideFromBrowsingAndGuardWrites() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + var url = $"/api/sets/{set.Id}"; + + using (var missing = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", null))) + Assert.Equal((HttpStatusCode)428, missing.StatusCode); + using (var archived = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", "\"1\""))) + Assert.True((await Read(archived)).Archived); + using (var again = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", "\"2\""))) + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + using (var write = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"2\"", Json(Set("Edit", set.Id))))) + Assert.Equal(HttpStatusCode.Conflict, write.StatusCode); + + Assert.Empty((await client.GetFromJsonAsync("/api/sets/query", Options))!.Items); + Assert.Single((await client.GetFromJsonAsync("/api/sets/query?includeArchived=true", Options))!.Items); + Assert.True((await client.GetFromJsonAsync(url, Options))!.Archived); + + using (var restored = await client.SendAsync(Guarded(HttpMethod.Post, url + "/restore", "\"2\""))) + { + var record = await Read(restored); + Assert.Equal((3, false), (record.Revision, record.Archived)); + } + using (var unknown = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/sets/{Guid.NewGuid()}/restore", "\"1\""))) + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + } + + [Fact] + public async Task Query_FiltersSortsAndPagesInSql() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var drawing = await AddDrawing(client); + await Create(client, Set("Gamma frame", members: (drawing, 1))); + var alpha = Set("alpha kit"); + alpha.Customer = "Other Customer"; + await Create(client, alpha); + var beta = Set("Beta kit"); + beta.Notes = "Contains 100% literal_text"; + await Create(client, beta); + + async Task Names(string query) => + (await client.GetFromJsonAsync("/api/sets/query" + query, Options))!.Items.Select(s => s.Name).ToArray(); + + Assert.Equal(new[] { "alpha kit", "Beta kit", "Gamma frame" }, await Names("?sort=name&order=asc")); + Assert.Equal(new[] { "alpha kit", "Beta kit" }, await Names("?search=KIT&sort=name&order=asc")); + Assert.Equal(new[] { "Beta kit" }, await Names("?search=100%25")); + Assert.Equal(new[] { "Beta kit" }, await Names("?search=l_t")); + Assert.Equal(new[] { "alpha kit" }, await Names("?customer=%20other%20customer")); + Assert.Equal(new[] { "Beta kit" }, await Names("?sort=name&order=asc&offset=1&limit=1")); + var page = await client.GetFromJsonAsync("/api/sets/query?limit=1", Options); + Assert.Equal((3, 1), (page!.Total, page.Items.Count)); + Assert.Empty(page.Items[0].Members); + var gamma = (await client.GetFromJsonAsync("/api/sets/query?search=gamma", Options))!.Items.Single(); + Assert.Equal(1, gamma.MemberCount); + } + + [Theory] + [InlineData("?bogus=1")] + [InlineData("?search=a&search=b")] + [InlineData("?sort=memberCount")] + [InlineData("?sort=1")] + [InlineData("?order=up")] + [InlineData("?includeArchived=yes")] + [InlineData("?offset=-1")] + [InlineData("?limit=0")] + [InlineData("?limit=501")] + [InlineData("?materialName=Steel")] + public async Task Query_RefusesUnknownRepeatedOrOutOfRangeParameters(string query) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/sets/query" + query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Revisions_PageWithoutMembersAndReadOneWithItsMembers() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var a = await AddDrawing(client, "Synthetic A"); + var b = await AddDrawing(client, "Synthetic B"); + var set = await Create(client, Set(members: [(a, 1), (b, 2)])); + using (await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{set.Id}", "\"1\"", Json(Set("Edited", set.Id, (b, 9)))))) { } + + var history = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions?limit=1&offset=1", Options))!; + Assert.Equal((2, 1, 2), (history.Total, history.Items.Single().Revision, history.Items.Single().MemberCount)); + Assert.Empty(history.Items.Single().Members); + + var first = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions/1", Options))!; + Assert.Equal("Synthetic kit", first.Name); + Assert.Equal(new[] { (a, 1), (b, 2) }, MembersOf(first)); + Assert.Equal("Synthetic B", first.Members[1].Drawing!.Name); + + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{set.Id}/revisions/3")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions/0")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions?limit=201")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions?page=1")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/revisions")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}")).StatusCode); + } + + [Fact] + public async Task UsedIn_ListsSetsWhoseCurrentRevisionContainsTheDrawing() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var a = await AddDrawing(client, "Synthetic A"); + var b = await AddDrawing(client, "Synthetic B"); + await Create(client, Set("Kit one", members: (a, 1))); + var two = await Create(client, Set("Kit two", members: [(a, 1), (b, 1)])); + using (await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{two.Id}", "\"1\"", Json(Set("Kit two", two.Id, (b, 1)))))) { } + + var usedA = (await client.GetFromJsonAsync($"/api/drawings/{a}/sets", Options))!; + var usedB = (await client.GetFromJsonAsync($"/api/drawings/{b}/sets", Options))!; + + Assert.Equal(new[] { "Kit one" }, usedA.Items.Select(s => s.Name)); + Assert.Equal(new[] { "Kit two" }, usedB.Items.Select(s => s.Name)); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/drawings/{Guid.NewGuid()}/sets")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/drawings/{a}/sets?limit=0")).StatusCode); + } + + [Fact] + public async Task ArchivedDrawing_StaysListedWithItsFlagAndLatestRevision() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var drawing = await AddDrawing(client); + var set = await Create(client, Set(members: (drawing, 2))); + using (await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{drawing}/archive", "\"1\""))) { } + + var read = (await client.GetFromJsonAsync($"/api/sets/{set.Id}", Options))!; + + var member = read.Members.Single(); + Assert.Equal((drawing, 2, true, 2), (member.DrawingId, member.Quantity, member.Drawing!.Archived, member.Drawing.Revision)); + // An archived drawing may still be added to a set. + Assert.Equal(1, (await Create(client, Set("Another", members: (drawing, 1)))).MemberCount); + } + + private static async Task AssertNoSets(HttpClient client) + { + var page = await client.GetFromJsonAsync("/api/sets/query?includeArchived=true", Options); + Assert.Equal(0, page!.Total); + } +} diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs new file mode 100644 index 00000000..b7d24261 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -0,0 +1,365 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using static OpenNest.Server.Tests.DrawingSetFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class DrawingSetDatabaseTests +{ + [Fact] + public void V6Upgrade_AddsSetTablesAndKeepsEveryOtherRowAndIsIdempotent() + { + using var factory = new ServerFactory(); + var drawing = CreateVersion6Database(factory.DatabasePath); + var before = Digest(factory.DatabasePath); + + for (var open = 0; open < 2; open++) + { + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); + Assert.Equal(before, Digest(factory.DatabasePath)); + Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + } + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(members: (drawing, 2)); + Assert.Equal(1, upgraded.InsertDrawingSet(set.Id, set).Revision); + Assert.Equal(drawing, upgraded.GetDrawingSet(set.Id)!.Members.Single().Drawing!.Id); + } + + [Fact] + public void FailedSetMigration_RollsBackAndCanRestart() + { + using var factory = new ServerFactory(); + CreateVersion6Database(factory.DatabasePath); + var before = Digest(factory.DatabasePath); + // A table holding an index's name makes the set schema fail part-way through. + Execute(factory.DatabasePath, "CREATE TABLE ix_drawing_set_members_drawing (fixture TEXT)"); + + Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); + + Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'drawing_set%'")); + Assert.Equal(before, Digest(factory.DatabasePath)); + Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + } + + [Fact] + public void EveryChange_AppendsAnImmutableRevisionWithItsOwnMembers() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var a = AddDrawing(database, "Synthetic A"); + var b = AddDrawing(database, "Synthetic B"); + var set = Set(members: [(a, 2), (b, 1)]); + + database.InsertDrawingSet(set.Id, set); + var edit = Set("Renamed kit", set.Id, (b, 5)); + Assert.Equal(2, database.UpdateDrawingSet(set.Id, edit, 1)!.Revision); + Assert.True(database.SetDrawingSetArchived(set.Id, true, 2)!.Archived); + var restored = database.SetDrawingSetArchived(set.Id, false, 3)!; + + Assert.Equal((4, "Renamed kit", false), (restored.Revision, restored.Name, restored.Archived)); + Assert.Equal(new[] { (b, 5) }, MembersOf(restored)); + var history = database.DrawingSetHistory(set.Id, 0, 100)!; + Assert.Equal(new[] { 4, 3, 2, 1 }, history.Items.Select(r => r.Revision)); + Assert.Equal(new[] { false, true, false, false }, history.Items.Select(r => r.Archived)); + Assert.Equal(new[] { 1, 1, 1, 2 }, history.Items.Select(r => r.MemberCount)); + Assert.All(history.Items, r => Assert.Empty(r.Members)); + var first = database.GetDrawingSetRevision(set.Id, 1)!; + Assert.Equal(("Synthetic kit", 2), (first.Name, first.MemberCount)); + Assert.Equal(new[] { (a, 2), (b, 1) }, MembersOf(first)); + Assert.Null(database.GetDrawingSetRevision(set.Id, 5)); + } + + [Fact] + public void IdenticalSave_IsANoOpButMemberOrderIsAChange() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var a = AddDrawing(database, "Synthetic A"); + var b = AddDrawing(database, "Synthetic B"); + var set = Set(members: [(a, 2), (b, 1)]); + database.InsertDrawingSet(set.Id, set); + + set.SavedBy = "Another operator"; + Assert.Equal(1, database.UpdateDrawingSet(set.Id, set, 1)!.Revision); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions")); + + var reordered = Set(members: [(b, 1), (a, 2)]); + var saved = database.UpdateDrawingSet(set.Id, reordered, 1)!; + Assert.Equal(2, saved.Revision); + Assert.Equal(new[] { (b, 1), (a, 2) }, MembersOf(saved)); + } + + [Fact] + public void Members_JoinTheDrawingsCurrentRevisionIncludingArchived() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var set = Set(members: (drawing, 3)); + database.InsertDrawingSet(set.Id, set); + + var edit = database.GetDrawing(drawing)!; + edit.MaterialName = "Synthetic Aluminum"; + database.UpdateDrawing(drawing, edit, Package("revised"), 1); + database.SetDrawingArchived(drawing, true, 2); + + foreach (var read in new[] { database.GetDrawingSet(set.Id)!, database.GetDrawingSetRevision(set.Id, 1)! }) + { + var joined = read.Members.Single().Drawing!; + Assert.Equal((3, "Synthetic Aluminum", true), (joined.Revision, joined.MaterialName, joined.Archived)); + Assert.Equal(database.GetDrawing(drawing)!.PackageSha256, joined.PackageSha256); + } + Assert.Equal(1, database.GetDrawingSet(set.Id)!.Revision); + } + + [Fact] + public void SetsContaining_ReadsCurrentRevisionsOnlyAndIncludesArchivedSets() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var a = AddDrawing(database, "Synthetic A"); + var b = AddDrawing(database, "Synthetic B"); + var kept = Set("b kit", members: (a, 1)); + var moved = Set("a kit", members: (a, 1)); + var archived = Set("c kit", members: [(a, 1), (b, 1)]); + foreach (var set in new[] { kept, moved, archived }) + database.InsertDrawingSet(set.Id, set); + database.UpdateDrawingSet(moved.Id, Set("a kit", moved.Id, (b, 1)), 1); + database.SetDrawingSetArchived(archived.Id, true, 1); + + var usedA = database.DrawingSetsContaining(a, 0, 100)!; + Assert.Equal(new[] { "b kit", "c kit" }, usedA.Items.Select(s => s.Name)); + Assert.Equal(2, usedA.Total); + Assert.Equal(new[] { "a kit", "c kit" }, database.DrawingSetsContaining(b, 0, 100)!.Items.Select(s => s.Name)); + Assert.Equal(new[] { "c kit" }, database.DrawingSetsContaining(a, 1, 1)!.Items.Select(s => s.Name)); + Assert.Null(database.DrawingSetsContaining(Guid.NewGuid(), 0, 100)); + } + + [Fact] + public void Writes_AreGuardedByRevisionAndArchivedState() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var set = Set(members: (drawing, 1)); + database.InsertDrawingSet(set.Id, set); + + Assert.Equal(1, Assert.Throws(() => database.InsertDrawingSet(set.Id, Set("Other", set.Id))).Current.Revision); + Assert.Throws(() => database.UpdateDrawingSet(set.Id, Set("Stale", set.Id), 2)); + Assert.Throws(() => database.SetDrawingSetArchived(set.Id, false, 1)); + database.SetDrawingSetArchived(set.Id, true, 1); + var refused = Assert.Throws(() => database.UpdateDrawingSet(set.Id, Set("Archived", set.Id), 2)); + Assert.True(refused.Current.Archived); + Assert.Single(refused.Current.Members); + Assert.Null(database.UpdateDrawingSet(Guid.NewGuid(), set, 1)); + Assert.Null(database.SetDrawingSetArchived(Guid.NewGuid(), true, 1)); + Assert.Null(database.DrawingSetHistory(Guid.NewGuid(), 0, 100)); + Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions")); + } + + [Fact] + public void DirectWrites_EnforceTheSharedRulesWithNoState() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var tooMany = Enumerable.Range(0, DrawingSetRules.MaxMembers + 1).Select(_ => (Guid.NewGuid(), 1)).ToArray(); + + Assert.Throws(() => database.InsertDrawingSet(Guid.Empty, Set())); + Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(" "))); + Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, 0)))); + Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, DrawingSetRules.MaxQuantity + 1)))); + Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (drawing, 2)]))); + Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: tooMany))); + var unknown = Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (Guid.NewGuid(), 1)]))); + Assert.Contains("is not in the library", unknown.Message); + Assert.Throws(() => database.QueryDrawingSets(new DrawingSetQuery { Limit = 501 })); + Assert.Throws(() => database.DrawingSetHistory(Guid.NewGuid(), 0, 201)); + Assert.Throws(() => database.DrawingSetsContaining(drawing, -1, 10)); + + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_members")); + + // The boundaries themselves are accepted, including an empty set. + Assert.Equal(0, database.InsertDrawingSet(Guid.NewGuid(), Set()).MemberCount); + Assert.Equal(DrawingSetRules.MaxQuantity, + database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, DrawingSetRules.MaxQuantity))).Members.Single().Quantity); + } + + // Code-level ownership gate, as for the drawing operations. + [Theory] + [InlineData("GetDrawingSet")] + [InlineData("GetDrawingSetRevision")] + [InlineData("QueryDrawingSets")] + [InlineData("InsertDrawingSet")] + [InlineData("UpdateDrawingSet")] + [InlineData("SetDrawingSetArchived")] + [InlineData("DrawingSetHistory")] + [InlineData("DrawingSetsContaining")] + public async Task Concurrency_EverySetOperationWaitsForTheSameMonitor(string operation) + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var set = Set(members: (drawing, 1)); + database.InsertDrawingSet(set.Id, set); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() => + { + switch (operation) + { + case "GetDrawingSet": database.GetDrawingSet(set.Id); break; + case "GetDrawingSetRevision": database.GetDrawingSetRevision(set.Id, 1); break; + case "QueryDrawingSets": database.QueryDrawingSets(new DrawingSetQuery { Search = "kit" }); break; + case "InsertDrawingSet": database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, 1))); break; + case "UpdateDrawingSet": database.UpdateDrawingSet(set.Id, Set("Renamed", set.Id, (drawing, 2)), 1); break; + case "SetDrawingSetArchived": database.SetDrawingSetArchived(set.Id, true, 1); break; + case "DrawingSetHistory": database.DrawingSetHistory(set.Id, 0, 100); break; + case "DrawingSetsContaining": database.DrawingSetsContaining(drawing, 0, 100); break; + default: throw new InvalidOperationException($"Unknown test operation: {operation}"); + } + }))) + { IsBackground = true }; + + bool observedWaiting; + bool completedWhileHeld; + lock (sync) + { + worker.Start(); + observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted || + (worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10)); + completedWhileHeld = completed.Task.IsCompleted; + } + + Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor."); + Assert.True(observedWaiting, "Caller never reached the operation within the watchdog."); + Assert.False(completedWhileHeld, operation + " bypassed the shared monitor."); + Assert.Null(await completed.Task); + } + + [Fact] + public void Concurrency_AllSetSqlStepsOwnTheMonitor() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var connection = (SqliteConnection)typeof(NestDatabase) + .GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var steps = 0; + var unownedSteps = 0; + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ => + { + steps++; + if (!Monitor.IsEntered(sync)) + unownedSteps++; + return 0; + }, null); + try + { + var set = Set(members: (drawing, 1)); + Action[] operations = + [ + () => database.InsertDrawingSet(set.Id, set), + () => database.GetDrawingSet(set.Id), + () => database.GetDrawingSetRevision(set.Id, 1), + () => database.QueryDrawingSets(new DrawingSetQuery { Search = "Synthetic", Customer = "synthetic customer" }), + () => database.UpdateDrawingSet(set.Id, Set("Renamed", set.Id, (drawing, 2)), 1), + () => database.SetDrawingSetArchived(set.Id, true, 2), + () => database.SetDrawingSetArchived(set.Id, false, 3), + () => database.DrawingSetHistory(set.Id, 0, 100), + () => database.DrawingSetsContaining(drawing, 0, 100), + ]; + foreach (var operation in operations) + { + var before = steps; + operation(); + Assert.True(steps > before, "Operation did not execute the observed SQLite VM."); + Assert.Equal(0, unownedSteps); + } + } + finally + { + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null); + } + } + + /// + /// A real schema-v6 file with nests and a library drawing: the set tables are additive, + /// so dropping them restores v6 exactly. Returns the drawing's id. + /// + private static Guid CreateVersion6Database(string path) + { + Guid drawing; + using (var database = new NestDatabase(path, pooling: false)) + { + var nest = new NestRecord { Name = "Synthetic nest", Customer = "Synthetic", Material = "Alloy", Thickness = 0.25 }; + var stored = database.Insert(Guid.NewGuid(), nest, new byte[] { 0, 1, 255 }); + nest.Comments = "Edited"; + database.Update(stored.Id, nest, new byte[] { 2, 3 }, 1); + database.AllocateNestNumber(2026); + drawing = AddDrawing(database); + var edit = database.GetDrawing(drawing)!; + edit.Notes = "Edited"; + database.UpdateDrawing(drawing, edit, null, 1); + } + + Execute(path, "DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6;"); + return drawing; + } + + // Every pre-v7 table, row by row, so the upgrade is shown to leave them byte-identical. + private static string Digest(string path) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + var text = new StringBuilder(); + foreach (var table in new[] { "nests", "nest_revisions", "nest_sequences", "drawing_packages", "drawing_revisions", "drawings" }) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT * FROM {table} ORDER BY 1, 2"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + text.Append(table).Append(':'); + for (var i = 0; i < reader.FieldCount; i++) + { + var value = reader.GetValue(i); + text.Append(reader.GetName(i)).Append('=') + .Append(value is byte[] bytes ? Convert.ToHexString(SHA256.HashData(bytes)) : Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)) + .Append(';'); + } + text.Append('\n'); + } + } + return text.ToString(); + } + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs new file mode 100644 index 00000000..26059aa3 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs @@ -0,0 +1,53 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; + +namespace OpenNest.Server.Tests; + +/// Neutral, code-built drawing set fixtures for the server tests. +internal static class DrawingSetFixtures +{ + public static DrawingSetRecord Set(string name = "Synthetic kit", Guid? id = null, params (Guid Drawing, int Quantity)[] members) => new() + { + Id = id ?? Guid.NewGuid(), + Name = name, + Customer = "Synthetic customer", + Notes = "Synthetic set notes", + SavedBy = "Synthetic operator", + Members = members.Select(m => new DrawingSetMember { DrawingId = m.Drawing, Quantity = m.Quantity }).ToList(), + }; + + /// Stores a library drawing directly and returns its id. + public static Guid AddDrawing(NestDatabase database, string name = "Synthetic bracket") + { + var record = Record(name); + database.InsertDrawing(record.Id, record, Package(name)); + return record.Id; + } + + public static async Task AddDrawing(HttpClient client, string name = "Synthetic bracket", Action? edit = null) + { + var record = Record(name); + edit?.Invoke(record); + return (await Upload(client, record, Package(name))).Id; + } + + public static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); + + public static StringContent Json(DrawingSetRecord record) => Json(JsonSerializer.Serialize(record, Options)); + + public static async Task Create(HttpClient client, DrawingSetRecord record) + { + using var response = await client.PostAsync("/api/sets", Json(record)); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(Options))!; + } + + public static async Task Read(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync(Options))!; + + public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) => + record.Members.Select(m => (m.DrawingId, m.Quantity)); +} diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs index 9c1c476c..559b8fdb 100644 --- a/OpenNest.Server/DrawingLibraryRequest.cs +++ b/OpenNest.Server/DrawingLibraryRequest.cs @@ -184,8 +184,45 @@ internal static class DrawingLibraryRequest return error.Length == 0; } + /// + /// Reads history-style paging: only offset (zero or greater) and limit + /// (1 to ), each at most once. + /// + public static bool TryReadPaging(IQueryCollection values, out int offset, out int limit, out string error) + { + offset = 0; + limit = DrawingLibraryRules.DefaultRevisionLimit; + error = ""; + foreach (var parameter in values) + { + if (parameter.Value.Count != 1 + || !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value)) + { + error = "Invalid history pagination."; + return false; + } + + switch (parameter.Key) + { + case "offset": offset = value; break; + case "limit": limit = value; break; + default: + error = "Unknown history parameter."; + return false; + } + } + + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + { + error = "Invalid history pagination."; + return false; + } + + return true; + } + // Names only: Enum.TryParse would also accept numbers, combinations and undefined values. - private static bool TryEnumName(string text, out TEnum value) + internal static bool TryEnumName(string text, out TEnum value) where TEnum : struct, Enum { value = default; @@ -195,9 +232,9 @@ internal static class DrawingLibraryRequest && Enum.IsDefined(value); } - private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + internal 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) + internal static bool TryInteger(IQueryCollection values, string key, ref int value, out string error) { error = ""; if (!values.TryGetValue(key, out var text)) diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs index de7c2564..b1e547fc 100644 --- a/OpenNest.Server/DrawingLibraryRoutes.cs +++ b/OpenNest.Server/DrawingLibraryRoutes.cs @@ -108,22 +108,8 @@ internal static class DrawingLibraryRoutes private static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db) { - var offset = 0; - var limit = DrawingLibraryRules.DefaultRevisionLimit; - foreach (var parameter in request.Query) - { - if (parameter.Value.Count != 1 - || !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value)) - return Results.BadRequest("Invalid history pagination."); - switch (parameter.Key) - { - case "offset": offset = value; break; - case "limit": limit = value; break; - default: return Results.BadRequest("Unknown history parameter."); - } - } - if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) - return Results.BadRequest("Invalid history pagination."); + if (!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)) + return Results.BadRequest(error); return db.DrawingHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound(); } diff --git a/OpenNest.Server/DrawingSetRequest.cs b/OpenNest.Server/DrawingSetRequest.cs new file mode 100644 index 00000000..9ee2997c --- /dev/null +++ b/OpenNest.Server/DrawingSetRequest.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Parses drawing set requests with the drawing library's strictness: unknown, repeated +/// or out-of-range values are rejected rather than ignored or clamped, and sort accepts +/// names only. +/// +internal static class DrawingSetRequest +{ + private static readonly string[] QueryKeys = + { + "search", "customer", "includeArchived", "sort", "order", "offset", "limit", + }; + + private static readonly JsonSerializerOptions SetOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + public static bool TryParseQuery(IQueryCollection values, out DrawingSetQuery query, out string error) + { + query = new DrawingSetQuery(); + foreach (var key in values.Keys) + { + if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + error = $"Unknown query parameter '{key}'."; + return false; + } + + if (values[key].Count > 1) + { + error = $"Query parameter '{key}' was given more than once."; + return false; + } + } + + var sort = DrawingSetSortField.SavedAt; + if (values.TryGetValue("sort", out var sortText) && !DrawingLibraryRequest.TryEnumName(sortText.ToString(), out sort)) + { + error = $"sort must be one of: {string.Join(", ", Enum.GetNames().Select(DrawingLibraryRequest.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 includeArchived = false; + if (values.TryGetValue("includeArchived", out var archivedText) && !bool.TryParse(archivedText.ToString(), out includeArchived)) + { + error = "includeArchived must be true or false."; + return false; + } + + var offset = 0; + var limit = DrawingSetQuery.DefaultLimit; + if (!DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error) + || !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error)) + return false; + + query = new DrawingSetQuery + { + Search = values["search"].ToString(), + Customer = values["customer"].ToString(), + IncludeArchived = includeArchived, + Sort = sort, + Descending = descending, + Offset = offset, + Limit = limit, + }; + error = query.GetValidationError() ?? ""; + return error.Length == 0; + } + + /// + /// Reads and validates one set JSON object. members must be present; each + /// member's joined drawing is ignored, so a record read from the server may be + /// sent back as it is. + /// + public static bool TryReadSet(string? json, out DrawingSetRecord record, out string error) + { + record = new DrawingSetRecord(); + if (string.IsNullOrWhiteSpace(json)) + { + error = "Missing set."; + return false; + } + + try + { + if (JsonNode.Parse(json) is not JsonObject root) + { + error = "The set must be a JSON object."; + return false; + } + + var members = root.FirstOrDefault(p => string.Equals(p.Key, "members", StringComparison.OrdinalIgnoreCase)); + if (members.Key is null) + { + error = "members is required."; + return false; + } + + if (members.Value is JsonArray array) + { + foreach (var member in array.OfType()) + { + foreach (var key in member.Select(p => p.Key) + .Where(k => string.Equals(k, "drawing", StringComparison.OrdinalIgnoreCase)).ToList()) + member.Remove(key); + } + } + + record = root.Deserialize(SetOptions) ?? record; + } + catch (JsonException) + { + error = "Invalid set JSON."; + return false; + } + + error = DrawingSetRules.GetError(record) ?? ""; + return error.Length == 0; + } +} diff --git a/OpenNest.Server/DrawingSetRoutes.cs b/OpenNest.Server/DrawingSetRoutes.cs new file mode 100644 index 00000000..08b5b5b3 --- /dev/null +++ b/OpenNest.Server/DrawingSetRoutes.cs @@ -0,0 +1,111 @@ +using System.Text; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// /api/sets: named, versioned lists of library drawings with quantities. Sets +/// travel as JSON; existing-set writes follow the drawing If-Match contract (428 missing, +/// 400 malformed, 409 stale or wrong state with the current record). +/// +internal static class DrawingSetRoutes +{ + public static void MapDrawingSets(this IEndpointRouteBuilder app) + { + app.MapGet("/api/sets/query", (HttpRequest request, NestDatabase db) => + DrawingSetRequest.TryParseQuery(request.Query, out var query, out var error) + ? Results.Ok(db.QueryDrawingSets(query)) + : Results.BadRequest(error)); + + app.MapGet("/api/sets/{id:guid}", (Guid id, NestDatabase db) => + db.GetDrawingSet(id) is { } record ? Results.Ok(record) : Results.NotFound()); + + app.MapGet("/api/sets/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) => + !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error) + ? Results.BadRequest(error) + : db.DrawingSetHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound()); + + app.MapGet("/api/sets/{id:guid}/revisions/{revision:int}", (Guid id, int revision, NestDatabase db) => + revision < 1 ? Results.BadRequest("Invalid revision.") : + db.GetDrawingSetRevision(id, revision) is { } record ? Results.Ok(record) : Results.NotFound()); + + app.MapGet("/api/drawings/{id:guid}/sets", (Guid id, HttpRequest request, NestDatabase db) => + !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error) + ? Results.BadRequest(error) + : db.DrawingSetsContaining(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound()); + + // JSON set with members; the client allocates the id. + app.MapPost("/api/sets", async (HttpRequest request, NestDatabase db) => + { + var (record, error) = await ReadSet(request); + if (error is not null) return error; + if (record!.Id == Guid.Empty) + return Results.BadRequest("id is required: allocate it before the first save."); + try { return Results.Ok(db.InsertDrawingSet(record.Id, record)); } + catch (DrawingSetConflictException conflict) { return Conflict(conflict); } + catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); } + }); + + app.MapPut("/api/sets/{id:guid}", async (Guid id, HttpRequest request, NestDatabase db) => + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + var (record, error) = await ReadSet(request); + if (error is not null) return error; + if (record!.Id != Guid.Empty && record.Id != id) + return Results.BadRequest("Set id does not match the route."); + try + { + var updated = db.UpdateDrawingSet(id, record, expected); + return updated is null ? Results.NotFound() : Results.Ok(updated); + } + catch (DrawingSetConflictException conflict) { return Conflict(conflict); } + catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); } + }); + + app.MapPost("/api/sets/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) => + SetArchived(id, true, request, db)); + + app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) => + SetArchived(id, false, request, db)); + } + + private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db) + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + try + { + var changed = db.SetDrawingSetArchived(id, archived, expected); + return changed is null ? Results.NotFound() : Results.Ok(changed); + } + catch (DrawingSetConflictException conflict) { return Conflict(conflict); } + } + + private static IResult Conflict(DrawingSetConflictException conflict) => + Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); + + private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request) + { + if (!request.HasJsonContentType()) + return (null, Results.BadRequest("Expected an application/json set.")); + if (request.ContentLength > DrawingSetRules.MaxBodyBytes) + return (null, Results.BadRequest("The set is too large.")); + + // Bounded read: a body without a length, or longer than it claims, stops at the limit. + using var body = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0) + { + if (body.Length + read > DrawingSetRules.MaxBodyBytes) + return (null, Results.BadRequest("The set is too large.")); + body.Write(chunk, 0, read); + } + + var json = Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length); + return DrawingSetRequest.TryReadSet(json, out var record, out var error) + ? (record, null) + : (null, Results.BadRequest(error)); + } +} diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index 47ac7770..9583cacd 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -389,32 +389,33 @@ public sealed partial class NestDatabase _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), }; - private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader) => new() + // Reads DrawingColumns in order from column `first` on; a join may put other columns before them. + private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader, int first = 0) => new() { - Id = Guid.Parse(reader.GetString(0)), - Revision = reader.GetInt32(1), - Name = reader.GetString(2), - Customer = reader.GetString(3), - MaterialName = reader.GetString(4), - MaterialGrade = reader.GetString(5), - Thickness = reader.GetDouble(6), - Units = Enum.Parse(reader.GetString(7)), - CutType = Enum.Parse(reader.GetString(8)), - Notes = reader.GetString(9), - Archived = reader.GetInt32(10) != 0, - PackageSha256 = reader.GetString(11), - PackageSize = reader.GetInt64(12), - SavedAt = DateTime.Parse(reader.GetString(13)), - SavedBy = reader.GetString(14), + Id = Guid.Parse(reader.GetString(first)), + Revision = reader.GetInt32(first + 1), + Name = reader.GetString(first + 2), + Customer = reader.GetString(first + 3), + MaterialName = reader.GetString(first + 4), + MaterialGrade = reader.GetString(first + 5), + Thickness = reader.GetDouble(first + 6), + Units = Enum.Parse(reader.GetString(first + 7)), + CutType = Enum.Parse(reader.GetString(first + 8)), + Notes = reader.GetString(first + 9), + Archived = reader.GetInt32(first + 10) != 0, + PackageSha256 = reader.GetString(first + 11), + PackageSize = reader.GetInt64(first + 12), + SavedAt = DateTime.Parse(reader.GetString(first + 13)), + SavedBy = reader.GetString(first + 14), Settings = new DrawingNestingSettings { - CutFirst = reader.GetInt32(15) != 0, - Priority = reader.GetInt32(16), - StepAngle = reader.GetDouble(17), - StartAngle = reader.GetDouble(18), - EndAngle = reader.GetDouble(19), - Allow180Equivalent = reader.GetInt32(20) != 0, - ColorArgb = reader.GetInt32(21), + CutFirst = reader.GetInt32(first + 15) != 0, + Priority = reader.GetInt32(first + 16), + StepAngle = reader.GetDouble(first + 17), + StartAngle = reader.GetDouble(first + 18), + EndAngle = reader.GetDouble(first + 19), + Allow180Equivalent = reader.GetInt32(first + 20) != 0, + ColorArgb = reader.GetInt32(first + 21), }, }; } diff --git a/OpenNest.Server/NestDatabase.Sets.cs b/OpenNest.Server/NestDatabase.Sets.cs new file mode 100644 index 00000000..a30553ad --- /dev/null +++ b/OpenNest.Server/NestDatabase.Sets.cs @@ -0,0 +1,391 @@ +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Drawing set storage. drawing_set_revisions holds every immutable revision's details +/// and archived flag, drawing_set_members every revision's ordered members, and +/// drawing_sets each set's current revision for browsing. Members reference library +/// drawings by id and are read joined to each drawing's current record. Every change appends +/// a revision, with its members, in one transaction. +/// +public sealed partial class NestDatabase +{ + private const string SetColumnDefinitions = """ + id TEXT NOT NULL, + revision INTEGER NOT NULL, + name TEXT NOT NULL, + nameKey TEXT NOT NULL, + customer TEXT NOT NULL DEFAULT '', + customerKey TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, + savedAt TEXT NOT NULL, + savedBy TEXT NOT NULL DEFAULT '' + """; + + private const string DrawingSetSchema = $""" + CREATE TABLE drawing_set_revisions ({SetColumnDefinitions}, PRIMARY KEY (id, revision)); + CREATE TABLE drawing_sets ({SetColumnDefinitions}, PRIMARY KEY (id)); + CREATE TABLE drawing_set_members ( + setId TEXT NOT NULL, + revision INTEGER NOT NULL, + position INTEGER NOT NULL, + drawingId TEXT NOT NULL REFERENCES drawings(id), + quantity INTEGER NOT NULL, + PRIMARY KEY (setId, revision, position), + UNIQUE (setId, revision, drawingId)); + CREATE INDEX ix_drawing_sets_name ON drawing_sets(customerKey, nameKey); + CREATE INDEX ix_drawing_sets_savedAt ON drawing_sets(savedAt DESC); + CREATE INDEX ix_drawing_set_members_drawing ON drawing_set_members(drawingId); + PRAGMA user_version=7; + """; + + private const string SetColumns = "s.id, s.revision, s.name, s.customer, s.notes, s.archived, s.savedAt, s.savedBy"; + + private const string SetStoredColumns = "id, revision, name, customer, notes, archived, savedAt, savedBy, nameKey, customerKey"; + + // Read after SetColumns: the member count of the row's own revision. + private const string SetMemberCount = + "(SELECT COUNT(*) FROM drawing_set_members m WHERE m.setId = s.id AND m.revision = s.revision)"; + + private const string SetSearchPredicate = """ + s.name LIKE $pattern ESCAPE '\' OR s.customer LIKE $pattern ESCAPE '\' OR s.notes LIKE $pattern ESCAPE '\' + """; + + private static readonly string JoinedDrawingColumns = + string.Join(", ", DrawingColumns.Split(',').Select(column => "d." + column.Trim())); + + /// The current revision with its members, or null for an unknown id. + public DrawingSetRecord? GetDrawingSet(Guid id) + { + lock (_sync) + { + var record = ReadSet($"SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s WHERE s.id=$id", id, null); + return record is null ? null : WithMembers(record); + } + } + + /// One immutable revision with its members, or null when the set or revision is unknown. + public DrawingSetRecord? GetDrawingSetRevision(Guid id, int revision) + { + lock (_sync) + { + var record = ReadSet( + $"SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id AND s.revision=$revision", + id, revision); + return record is null ? null : WithMembers(record); + } + } + + /// One bounded page of matching sets (without members) plus the total, read in one monitor hold. + public DrawingSetPage QueryDrawingSets(DrawingSetQuery query) + { + ArgumentNullException.ThrowIfNull(query); + var error = query.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(query)); + + var conditions = new List(); + if (!query.IncludeArchived) conditions.Add("s.archived=0"); + if (query.NormalizedSearch.Length > 0) conditions.Add("(" + SetSearchPredicate + ")"); + if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("s.customerKey=$customer"); + var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions); + + lock (_sync) + { + using var count = _connection.CreateCommand(); + count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}"; + AddSetFilterParameters(count, query); + var total = checked((int)(long)count.ExecuteScalar()!); + + using var command = _connection.CreateCommand(); + var direction = query.Descending ? "DESC" : "ASC"; + command.CommandText = $""" + SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where} + ORDER BY {SetSortColumn(query.Sort)} {direction}, s.id {direction} + LIMIT $limit OFFSET $offset + """; + AddSetFilterParameters(command, query); + command.Parameters.AddWithValue("$limit", query.Limit); + command.Parameters.AddWithValue("$offset", query.Offset); + return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = query.Offset, Limit = query.Limit }; + } + } + + /// Stores revision 1 under a client-allocated id; an existing id is a conflict. + public DrawingSetRecord InsertDrawingSet(Guid id, DrawingSetRecord record) + { + if (id == Guid.Empty) throw new ArgumentException("A set id is required.", nameof(id)); + ValidateSet(record); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + if (GetDrawingSet(id) is { } existing) + throw new DrawingSetConflictException(existing, "A drawing set with this id already exists."); + RequireMemberDrawings(record, transaction); + WriteSetRevision(id, record, record.Members, 1, archived: false, transaction); + transaction.Commit(); + return GetDrawingSet(id) ?? throw new InvalidOperationException("Insert did not persist."); + } + } + + /// + /// Appends a revision with new details and members. Returns null for an unknown id; + /// an identical save (details and members in order) is a no-op. + /// + public DrawingSetRecord? UpdateDrawingSet(Guid id, DrawingSetRecord record, int expectedRevision) + { + ValidateSet(record); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var current = GetDrawingSet(id); + if (current is null) return null; + if (current.Revision != expectedRevision) + throw new DrawingSetConflictException(current, "The drawing set has changed. Reload before saving."); + if (current.Archived) + throw new DrawingSetConflictException(current, "The drawing set is archived. Restore it before saving."); + if (SameSet(current, record)) + return current; + + RequireMemberDrawings(record, transaction); + WriteSetRevision(id, record, record.Members, checked(current.Revision + 1), archived: false, transaction); + transaction.Commit(); + return GetDrawingSet(id); + } + } + + /// Archives or restores a set as a new revision with the same members. Returns null for an unknown id. + public DrawingSetRecord? SetDrawingSetArchived(Guid id, bool archived, int expectedRevision) + { + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var current = GetDrawingSet(id); + if (current is null) return null; + if (current.Revision != expectedRevision) + throw new DrawingSetConflictException(current, "The drawing set has changed. Reload and try again."); + if (current.Archived == archived) + throw new DrawingSetConflictException(current, + archived ? "The drawing set is already archived." : "The drawing set is not archived."); + + WriteSetRevision(id, current, current.Members, checked(current.Revision + 1), archived, transaction); + transaction.Commit(); + return GetDrawingSet(id); + } + } + + /// One page of revisions (without members), newest first, or null for an unknown id. + public DrawingSetPage? DrawingSetHistory(Guid id, int offset, int limit) + { + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + throw new ArgumentOutOfRangeException(nameof(limit)); + lock (_sync) + { + if (GetDrawingSet(id) is null) return null; + using var count = _connection.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM drawing_set_revisions WHERE id=$id"; + count.Parameters.AddWithValue("$id", id.ToString()); + var total = checked((int)(long)count.ExecuteScalar()!); + using var command = _connection.CreateCommand(); + command.CommandText = $""" + SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id + ORDER BY s.revision DESC LIMIT $limit OFFSET $offset + """; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$limit", limit); + command.Parameters.AddWithValue("$offset", offset); + return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit }; + } + } + + /// + /// The sets, archived ones included, whose current revision contains the drawing, by + /// name; null for an unknown drawing. + /// + public DrawingSetPage? DrawingSetsContaining(Guid drawingId, int offset, int limit) + { + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + throw new ArgumentOutOfRangeException(nameof(limit)); + const string where = """ + WHERE EXISTS (SELECT 1 FROM drawing_set_members m + WHERE m.setId = s.id AND m.revision = s.revision AND m.drawingId = $drawing) + """; + lock (_sync) + { + if (GetDrawing(drawingId) is null) return null; + using var count = _connection.CreateCommand(); + count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}"; + count.Parameters.AddWithValue("$drawing", drawingId.ToString()); + var total = checked((int)(long)count.ExecuteScalar()!); + using var command = _connection.CreateCommand(); + command.CommandText = $""" + SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where} + ORDER BY s.name COLLATE NOCASE, s.id LIMIT $limit OFFSET $offset + """; + command.Parameters.AddWithValue("$drawing", drawingId.ToString()); + command.Parameters.AddWithValue("$limit", limit); + command.Parameters.AddWithValue("$offset", offset); + return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit }; + } + } + + private static void ValidateSet(DrawingSetRecord record) + { + var error = DrawingSetRules.GetError(record); + if (error is not null) + throw new ArgumentException(error); + } + + // Drawings are never deleted, so a member checked here stays valid; archived drawings are allowed. + private void RequireMemberDrawings(DrawingSetRecord record, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "SELECT 1 FROM drawings WHERE id=$id"; + var parameter = command.Parameters.Add("$id", SqliteType.Text); + foreach (var member in record.Members) + { + parameter.Value = member.DrawingId.ToString(); + if (command.ExecuteScalar() is null) + throw new ArgumentException($"Drawing {member.DrawingId} is not in the library."); + } + } + + // Members compare by drawing and quantity in order; the joined drawings are not part of the set. + private static bool SameSet(DrawingSetRecord current, DrawingSetRecord record) => + current.Name == record.Name && current.Customer == record.Customer && current.Notes == record.Notes && + current.Members.Select(m => (m.DrawingId, m.Quantity)) + .SequenceEqual(record.Members.Select(m => (m.DrawingId, m.Quantity))); + + // Called only under _sync with the caller's transaction; the current row, its revision + // snapshot and the revision's members are one write. + private void WriteSetRevision(Guid id, DrawingSetRecord record, IReadOnlyList members, + int revision, bool archived, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $""" + INSERT OR REPLACE INTO drawing_sets ({SetStoredColumns}) + VALUES ($id, $revision, $name, $customer, $notes, $archived, $savedAt, $savedBy, $nameKey, $customerKey) + """; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", revision); + command.Parameters.AddWithValue("$name", record.Name); + command.Parameters.AddWithValue("$customer", record.Customer); + command.Parameters.AddWithValue("$notes", record.Notes); + command.Parameters.AddWithValue("$archived", archived ? 1 : 0); + command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o")); + command.Parameters.AddWithValue("$savedBy", record.SavedBy); + command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name)); + command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer)); + command.ExecuteNonQuery(); + + command.Parameters.Clear(); + command.CommandText = $"INSERT INTO drawing_set_revisions ({SetStoredColumns}) SELECT {SetStoredColumns} FROM drawing_sets WHERE id=$id"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.ExecuteNonQuery(); + + command.Parameters.Clear(); + command.CommandText = """ + INSERT INTO drawing_set_members (setId, revision, position, drawingId, quantity) + VALUES ($id, $revision, $position, $drawing, $quantity) + """; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", revision); + var position = command.Parameters.Add("$position", SqliteType.Integer); + var drawing = command.Parameters.Add("$drawing", SqliteType.Text); + var quantity = command.Parameters.Add("$quantity", SqliteType.Integer); + for (var i = 0; i < members.Count; i++) + { + position.Value = i; + drawing.Value = members[i].DrawingId.ToString(); + quantity.Value = members[i].Quantity; + command.ExecuteNonQuery(); + } + } + + // Called only under _sync. Reads one record row; the revision parameter is bound when given. + private DrawingSetRecord? ReadSet(string sql, Guid id, int? revision) + { + using var command = _connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$id", id.ToString()); + if (revision is { } value) + command.Parameters.AddWithValue("$revision", value); + return ReadSets(command).FirstOrDefault(); + } + + private static List ReadSets(SqliteCommand command) + { + var records = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + records.Add(new DrawingSetRecord + { + Id = Guid.Parse(reader.GetString(0)), + Revision = reader.GetInt32(1), + Name = reader.GetString(2), + Customer = reader.GetString(3), + Notes = reader.GetString(4), + Archived = reader.GetInt32(5) != 0, + SavedAt = DateTime.Parse(reader.GetString(6)), + SavedBy = reader.GetString(7), + MemberCount = reader.GetInt32(8), + }); + } + return records; + } + + // Called only under _sync. Members of the record's own revision, each joined to its drawing's current record. + private DrawingSetRecord WithMembers(DrawingSetRecord record) + { + using var command = _connection.CreateCommand(); + command.CommandText = $""" + SELECT m.drawingId, m.quantity, {JoinedDrawingColumns} + FROM drawing_set_members m LEFT JOIN drawings d ON d.id = m.drawingId + WHERE m.setId=$id AND m.revision=$revision ORDER BY m.position + """; + command.Parameters.AddWithValue("$id", record.Id.ToString()); + command.Parameters.AddWithValue("$revision", record.Revision); + var members = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + members.Add(new DrawingSetMember + { + DrawingId = Guid.Parse(reader.GetString(0)), + Quantity = reader.GetInt32(1), + Drawing = reader.IsDBNull(2) ? null : ReadDrawing(reader, first: 2), + }); + } + record.Members = members; + return record; + } + + private static void AddSetFilterParameters(SqliteCommand command, DrawingSetQuery query) + { + AddSearchParameter(command, query.NormalizedSearch); + if (SharedListNames.Key(query.Customer).Length > 0) + command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer)); + } + + // Fixed allowlist: request text never reaches the ORDER BY clause. + private static string SetSortColumn(DrawingSetSortField field) => field switch + { + DrawingSetSortField.SavedAt => "s.savedAt", + DrawingSetSortField.Name => "s.name COLLATE NOCASE", + DrawingSetSortField.Customer => "s.customer COLLATE NOCASE", + DrawingSetSortField.Revision => "s.revision", + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), + }; +} + +/// A guarded drawing set write found a different current state; nothing was changed. +public sealed class DrawingSetConflictException(DrawingSetRecord current, string message) : Exception(message) +{ + public DrawingSetRecord Current { get; } = current; +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 3a238d54..5a81a726 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -319,7 +319,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 6) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 7) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -366,6 +366,11 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = NestSequenceSchema; command.ExecuteNonQuery(); } + if (version < 7) + { + command.CommandText = DrawingSetSchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index d4151a98..5aeb2292 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -140,6 +140,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i }); app.MapDrawingLibrary(); +app.MapDrawingSets(); app.Run(); diff --git a/docs/nest-storage.md b/docs/nest-storage.md index d56e34c7..b729637e 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -327,6 +327,61 @@ its client-allocated id. Saving identical content at the current revision is a no-op. On success only the nest drawing's library link changes, to the saved revision; save the nest to keep it. +## Drawing set endpoints + +A **drawing set** (schema v7) is a named, versioned list of library drawings +with a quantity each: a kit, an assembly or a recurring order. Members name +drawings by id and always mean each drawing's **current** revision; there is no +per-member revision pin. Sets travel as JSON (`application/json`, at most 1 MiB). +The desktop does not use these routes yet. + +`OpenNest.Data.DrawingSetRecord` fields: `id` (client-allocated before the +first save), `revision`, `name`, `customer`, `notes`, `archived`, `savedBy`, +the server-computed `savedAt` and `memberCount`, and `members`, in order, each +`{ drawingId, quantity, drawing }`. `drawing` is the member drawing's current +library record (archived or not), joined by the server on every read so a client +can group members by material without one request per drawing; it is ignored on +save, so a record read from the server may be sent back as it is. + +| Method | Path | Body | Response | +|---|---|---|---| +| POST | `/api/sets` | JSON set with a nonempty `id` and `members` | Revision 1; 409 with the current record when the id exists | +| GET | `/api/sets/query?search=&customer=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }`; items have `memberCount` and no `members` | +| GET | `/api/sets/{id}` | — | Current record with joined members (archived sets included), or 404 | +| GET | `/api/sets/{id}/revisions?offset=0&limit=100` | — | Revision page without members, newest first; limit 1–200 | +| GET | `/api/sets/{id}/revisions/{revision}` | — | That revision's details and members, each joined to the drawing's current record | +| PUT | `/api/sets/{id}` | JSON set (`id` empty or matching the route) | New revision | +| POST | `/api/sets/{id}/archive` / `/restore` | — | New revision with `archived` changed and the same members | +| GET | `/api/drawings/{id}/sets?offset=0&limit=100` | — | Sets, archived included, whose current revision contains the drawing, by name; 404 for an unknown drawing | + +- Existing-set writes follow the drawing contract: `If-Match` 428 missing, 400 + malformed, 409 stale with the current record; writing to an archived set, + archiving an archived one or restoring an active one is also 409. There is no + delete. +- Every change appends an immutable revision, with its own copy of the members, + in the transaction that updates the current row. An identical save (name, + customer, notes and members in order) at the current revision is a no-op; + reordering members is a change. +- Validation (400, no state change): `name` nonblank; name, customer and savedBy + at most 200 characters, notes at most 4000, no NUL characters; `members` + present and not null, at most 500, each with a nonempty `drawingId` that is in + the library (archived drawings allowed), an integer `quantity` from 1 to + 100000, and no drawing listed twice (one member carries the whole quantity). An + empty set is allowed. +- Query: `search` matches name, customer or notes and `customer` matches by + `SharedListNames.Key`, exactly as for drawings. `sort` is one of `savedAt` + (default), `name`, `customer`, `revision`; ties break by `id`. Paging, `order` + and the refusal of unknown, repeated or out-of-range parameters follow the + drawing query. + +Schema v7 adds `drawing_set_revisions` (every revision's details and archived +flag), `drawing_set_members` (each revision's members by position, unique per +drawing) and `drawing_sets` (each set's current revision). Upgrading from v6 +only creates these tables; every other table is untouched. A server older than +v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup. +The stopped-volume `tar` backup includes the set tables; `nest_manifest.py` and +`restore_check` do not verify them yet. + ## Storage `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with