mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 03:41:12 -04:00
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:
1 parent
28ee04a480
commit
be057bf337
19 files changed
+1711
-48
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
|
||||
});
|
||||
|
||||
app.MapDrawingLibrary();
|
||||
app.MapDrawingSets();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user