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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 12:51:56 -04:00
1 parent 28ee04a480
commit be057bf337
19 files changed
+1711 -48

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
namespace OpenNest.Data;
/// <summary>One drawing of a set and how many of it the set needs.</summary>
public sealed class DrawingSetMember
{
public Guid DrawingId { get; set; }
public int Quantity { get; set; } = 1;
/// <summary>
/// The drawing's current record, archived or not, joined by the server when it reads
/// a set. Ignored on save.
/// </summary>
public DrawingLibraryRecord? Drawing { get; set; }
}
+15
View File
@@ -0,0 +1,15 @@
namespace OpenNest.Data;
/// <summary>One bounded page of drawing set records, without their members.</summary>
public sealed class DrawingSetPage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingSetRecord> Items { get; init; } = Array.Empty<DrawingSetRecord>();
/// <summary>Number of records matching the request across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+48
View File
@@ -0,0 +1,48 @@
namespace OpenNest.Data;
/// <summary>
/// One bounded page request for browsing drawing sets (<c>GET /api/sets/query</c>).
/// The server filters and pages in SQL; the client and server share these bounds.
/// </summary>
public sealed class DrawingSetQuery
{
public const int DefaultLimit = 100;
public const int MaxLimit = 500;
public const int MaxSearchLength = 200;
/// <summary>
/// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter.
/// </summary>
public string Search { get; init; } = "";
/// <summary>Exact customer, compared by <see cref="SharedListNames.Key"/>. Blank means any.</summary>
public string Customer { get; init; } = "";
public bool IncludeArchived { get; init; }
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
public DrawingSetSortField Sort { get; init; } = DrawingSetSortField.SavedAt;
/// <summary>Largest/newest/Z first when true (the default, newest saved first).</summary>
public bool Descending { get; init; } = true;
public int Offset { get; init; }
public int Limit { get; init; } = DefaultLimit;
/// <summary>The search text as matched: trimmed, never null.</summary>
public string NormalizedSearch => (Search ?? "").Trim();
/// <summary>The first violated bound, or null when the query may be sent.</summary>
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);
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public sealed class DrawingSetRecord
{
/// <summary>Allocated by the client before the first save; kept by every revision.</summary>
public Guid Id { get; set; }
/// <summary>Immutable revision number, starting at 1. Ignored on save.</summary>
public int Revision { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public string Notes { get; set; } = "";
/// <summary>Hidden from browsing by default; still readable by id.</summary>
public bool Archived { get; set; }
/// <summary>When the server stored this revision. Server-computed.</summary>
public DateTime SavedAt { get; set; }
/// <summary>Who saved this revision, as reported by the client.</summary>
public string SavedBy { get; set; } = "";
/// <summary>Number of members in this revision. Server-computed.</summary>
public int MemberCount { get; set; }
/// <summary>
/// The members in order, one per drawing. Empty on query and history pages, which
/// carry <see cref="MemberCount"/> only.
/// </summary>
public IReadOnlyList<DrawingSetMember> Members { get; set; } = Array.Empty<DrawingSetMember>();
}
+51
View File
@@ -0,0 +1,51 @@
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public static class DrawingSetRules
{
public const int MaxMembers = 500;
public const int MaxQuantity = 100_000;
/// <summary>Largest accepted set JSON body (1 MiB): room for members echoed with their drawings.</summary>
public const int MaxBodyBytes = 1024 * 1024;
/// <summary>The first rule <paramref name="record"/> breaks, or null when it may be saved.</summary>
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<Guid>();
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;
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace OpenNest.Data;
/// <summary>Columns a drawing set query may order by (camelCase on the wire).</summary>
public enum DrawingSetSortField
{
SavedAt,
Name,
Customer,
Revision,
}
@@ -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<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -220,7 +220,7 @@ public sealed class DrawingLibraryDatabaseTests
}
}
/// <summary>A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly.</summary>
/// <summary>A real schema-v3 file: the drawing and set tables are additive, so dropping them restores v3 exactly.</summary>
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");
+337
View File
@@ -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<DrawingSetRecord>($"/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<string>());
Assert.Equal("waterjet", joined["cutType"]!.GetValue<string>());
Assert.Equal("inches", joined["units"]!.GetValue<string>());
}
[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<string, string> 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<string, string>
{
{ "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<HttpStatusCode> 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<DrawingSetRecord>($"/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<DrawingSetPage>($"/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<DrawingSetPage>("/api/sets/query", Options))!.Items);
Assert.Single((await client.GetFromJsonAsync<DrawingSetPage>("/api/sets/query?includeArchived=true", Options))!.Items);
Assert.True((await client.GetFromJsonAsync<DrawingSetRecord>(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<string[]> Names(string query) =>
(await client.GetFromJsonAsync<DrawingSetPage>("/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<DrawingSetPage>("/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<DrawingSetPage>("/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<DrawingSetPage>($"/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<DrawingSetRecord>($"/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<DrawingSetPage>($"/api/drawings/{a}/sets", Options))!;
var usedB = (await client.GetFromJsonAsync<DrawingSetPage>($"/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<DrawingSetRecord>($"/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<DrawingSetPage>("/api/sets/query?includeArchived=true", Options);
Assert.Equal(0, page!.Total);
}
}
@@ -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<SqliteException>(() => 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<DrawingSetConflictException>(() => database.InsertDrawingSet(set.Id, Set("Other", set.Id))).Current.Revision);
Assert.Throws<DrawingSetConflictException>(() => database.UpdateDrawingSet(set.Id, Set("Stale", set.Id), 2));
Assert.Throws<DrawingSetConflictException>(() => database.SetDrawingSetArchived(set.Id, false, 1));
database.SetDrawingSetArchived(set.Id, true, 1);
var refused = Assert.Throws<DrawingSetConflictException>(() => 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<ArgumentException>(() => database.InsertDrawingSet(Guid.Empty, Set()));
Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(" ")));
Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, 0))));
Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, DrawingSetRules.MaxQuantity + 1))));
Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (drawing, 2)])));
Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: tooMany)));
var unknown = Assert.Throws<ArgumentException>(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (Guid.NewGuid(), 1)])));
Assert.Contains("is not in the library", unknown.Message);
Assert.Throws<ArgumentException>(() => database.QueryDrawingSets(new DrawingSetQuery { Limit = 501 }));
Assert.Throws<ArgumentOutOfRangeException>(() => database.DrawingSetHistory(Guid.NewGuid(), 0, 201));
Assert.Throws<ArgumentOutOfRangeException>(() => 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<Exception?>(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);
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
@@ -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;
/// <summary>Neutral, code-built drawing set fixtures for the server tests.</summary>
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(),
};
/// <summary>Stores a library drawing directly and returns its id.</summary>
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<Guid> AddDrawing(HttpClient client, string name = "Synthetic bracket", Action<DrawingLibraryRecord>? 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<DrawingSetRecord> Create(HttpClient client, DrawingSetRecord record)
{
using var response = await client.PostAsync("/api/sets", Json(record));
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<DrawingSetRecord>(Options))!;
}
public static async Task<DrawingSetRecord> Read(HttpResponseMessage response) =>
(await response.Content.ReadFromJsonAsync<DrawingSetRecord>(Options))!;
public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) =>
record.Members.Select(m => (m.DrawingId, m.Quantity));
}
+40 -3
View File
@@ -184,8 +184,45 @@ internal static class DrawingLibraryRequest
return error.Length == 0;
}
/// <summary>
/// Reads history-style paging: only <c>offset</c> (zero or greater) and <c>limit</c>
/// (1 to <see cref="DrawingLibraryRules.MaxRevisionLimit"/>), each at most once.
/// </summary>
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<TEnum>(string text, out TEnum value)
internal static bool TryEnumName<TEnum>(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))
+2 -16
View File
@@ -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();
}
+140
View File
@@ -0,0 +1,140 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// 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.
/// </summary>
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<DrawingSetSortField>().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;
}
/// <summary>
/// Reads and validates one set JSON object. <c>members</c> must be present; each
/// member's joined <c>drawing</c> is ignored, so a record read from the server may be
/// sent back as it is.
/// </summary>
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<JsonObject>())
{
foreach (var key in member.Select(p => p.Key)
.Where(k => string.Equals(k, "drawing", StringComparison.OrdinalIgnoreCase)).ToList())
member.Remove(key);
}
}
record = root.Deserialize<DrawingSetRecord>(SetOptions) ?? record;
}
catch (JsonException)
{
error = "Invalid set JSON.";
return false;
}
error = DrawingSetRules.GetError(record) ?? "";
return error.Length == 0;
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Text;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/sets</c>: 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).
/// </summary>
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));
}
}
+24 -23
View File
@@ -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<Units>(reader.GetString(7)),
CutType = Enum.Parse<CutType>(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<Units>(reader.GetString(first + 7)),
CutType = Enum.Parse<CutType>(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),
},
};
}
+391
View File
@@ -0,0 +1,391 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Drawing set storage. <c>drawing_set_revisions</c> holds every immutable revision's details
/// and archived flag, <c>drawing_set_members</c> every revision's ordered members, and
/// <c>drawing_sets</c> 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.
/// </summary>
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()));
/// <summary>The current revision with its members, or null for an unknown id.</summary>
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);
}
}
/// <summary>One immutable revision with its members, or null when the set or revision is unknown.</summary>
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);
}
}
/// <summary>One bounded page of matching sets (without members) plus the total, read in one monitor hold.</summary>
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<string>();
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 };
}
}
/// <summary>Stores revision 1 under a client-allocated id; an existing id is a conflict.</summary>
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.");
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>Archives or restores a set as a new revision with the same members. Returns null for an unknown id.</summary>
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);
}
}
/// <summary>One page of revisions (without members), newest first, or null for an unknown id.</summary>
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 };
}
}
/// <summary>
/// The sets, archived ones included, whose current revision contains the drawing, by
/// name; null for an unknown drawing.
/// </summary>
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<DrawingSetMember> 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<DrawingSetRecord> ReadSets(SqliteCommand command)
{
var records = new List<DrawingSetRecord>();
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<DrawingSetMember>();
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."),
};
}
/// <summary>A guarded drawing set write found a different current state; nothing was changed.</summary>
public sealed class DrawingSetConflictException(DrawingSetRecord current, string message) : Exception(message)
{
public DrawingSetRecord Current { get; } = current;
}
+6 -1
View File
@@ -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();
}
}
+1
View File
@@ -140,6 +140,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
});
app.MapDrawingLibrary();
app.MapDrawingSets();
app.Run();
+55
View File
@@ -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