From 5ecdc985810c4e2eb0da0ab3cb87570f3b23303a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 21 Oct 2026 11:32:41 -0400 Subject: [PATCH] feat(server): add a company material list with list/add endpoints Nests, drawings and releases store material and grade as free text, so there was no canonical list for clients to suggest from or to fix spelling against. Schema v10 adds materials and material_grades keyed by SharedListNames.Key, with GET/POST /api/materials. Adding is idempotent by key and keeps the first spelling. The tables start empty: the materials already stored did not import cleanly, so the list is filled through the API instead. Existing free-text columns are unchanged. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/MaterialList.cs | 40 +++++ .../DrawingLibraryDatabaseTests.cs | 6 +- .../DrawingSetDatabaseTests.cs | 4 +- OpenNest.Server.Tests/MaterialListTests.cs | 153 ++++++++++++++++++ .../NestMaterialGradeTests.cs | 2 +- OpenNest.Server.Tests/SetReleaseTests.cs | 2 +- OpenNest.Server/MaterialRoutes.cs | 39 +++++ OpenNest.Server/NestDatabase.Materials.cs | 110 +++++++++++++ OpenNest.Server/NestDatabase.cs | 8 +- OpenNest.Server/Program.cs | 1 + docs/nest-storage.md | 25 +++ 11 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 OpenNest.Data/MaterialList.cs create mode 100644 OpenNest.Server.Tests/MaterialListTests.cs create mode 100644 OpenNest.Server/MaterialRoutes.cs create mode 100644 OpenNest.Server/NestDatabase.Materials.cs diff --git a/OpenNest.Data/MaterialList.cs b/OpenNest.Data/MaterialList.cs new file mode 100644 index 00000000..e93fa728 --- /dev/null +++ b/OpenNest.Data/MaterialList.cs @@ -0,0 +1,40 @@ +namespace OpenNest.Data; + +/// +/// Wire shape of one material in the nest server's company material list, with its +/// grades. Names keep the spelling first added; matching uses . +/// +public sealed class MaterialListEntry +{ + public string Name { get; set; } = ""; + public List Grades { get; set; } = new(); +} + +/// +/// Body of an add to the company material list: a material and, optionally, one of its +/// grades. Adding a name or grade that is already listed (by key) changes nothing. +/// +public sealed class MaterialListAddition +{ + public string? Name { get; set; } + public string? Grade { get; set; } +} + +public static class MaterialListRules +{ + /// The first rule breaks, or null when it may be added. + public static string? GetError(MaterialListAddition? addition) + { + if (addition is null) + return "Missing material."; + if (SharedListNames.Key(addition.Name).Length == 0) + return "name is required."; + return DrawingLibraryRules.TextError("name", addition.Name, DrawingLibraryRules.MaxTextLength) + ?? (addition.Grade is null ? null + : DrawingLibraryRules.TextError("grade", addition.Grade, DrawingLibraryRules.MaxTextLength)); + } + + /// The name as listed: trimmed, with internal whitespace runs collapsed to one space. + public static string Spelling(string? name) => + name == null ? "" : string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 79361875..9f26da8e 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -22,7 +22,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(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, 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. @@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=10"); + Execute(factory.DatabasePath, "PRAGMA user_version=11"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 03e2628b..8f9c11f7 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests 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(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests 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(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs new file mode 100644 index 00000000..5dd5fe97 --- /dev/null +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http.Json; +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 MaterialListTests +{ + [Fact] + public void V9Upgrade_StartsEmptyAndLeavesStoredMaterialsAlone() + { + using var factory = new ServerFactory(); + var nest = Guid.NewGuid(); + var drawing = Record(); + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + database.Insert(nest, Nest("A", " Stainless ", "304"), [1]); + database.InsertDrawing(drawing.Id, drawing, Package()); + } + Execute(factory.DatabasePath, "DROP TABLE material_grades; DROP TABLE materials; PRAGMA user_version=9;"); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Empty(upgraded.ListMaterials()); + Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade)); + Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName); + } + + [Fact] + public void Add_IsIdempotentByKeyAndKeepsTheFirstSpelling() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + + var (created, createdAdded) = database.AddMaterial(new MaterialListAddition { Name = " Aluminum 5000 " }); + var (again, againAdded) = database.AddMaterial(new MaterialListAddition { Name = "ALUMINUM 5000" }); + var (graded, gradeAdded) = database.AddMaterial(new MaterialListAddition { Name = "aluminum 5000", Grade = "5052" }); + var (_, sameGradeAdded) = database.AddMaterial(new MaterialListAddition { Name = "Aluminum 5000", Grade = " 5052" }); + var (both, bothAdded) = database.AddMaterial(new MaterialListAddition { Name = "Copper", Grade = "C110" }); + + Assert.Equal((true, false, true, false, true), (createdAdded, againAdded, gradeAdded, sameGradeAdded, bothAdded)); + Assert.Equal("Aluminum 5000", created.Name); + Assert.Equal(("Aluminum 5000", 0), (again.Name, again.Grades.Count)); + Assert.Equal(("Aluminum 5000", "5052"), (graded.Name, Assert.Single(graded.Grades))); + Assert.Equal(("Copper", "C110"), (both.Name, Assert.Single(both.Grades))); + Assert.Equal(new[] { "Aluminum 5000", "Copper" }, database.ListMaterials().Select(m => m.Name)); + } + + [Fact] + public void Add_SameGradeUnderTwoMaterials_ListsItUnderEach() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + + database.AddMaterial(new MaterialListAddition { Name = "Stainless", Grade = "Grade A" }); + var (_, added) = database.AddMaterial(new MaterialListAddition { Name = "Mild Steel", Grade = "Grade A" }); + + Assert.True(added); + Assert.All(database.ListMaterials(), m => Assert.Equal("Grade A", Assert.Single(m.Grades))); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Add_BlankName_IsRefusedWithNoChange(string? name) + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Throws(() => database.AddMaterial(new MaterialListAddition { Name = name, Grade = "304" })); + Assert.Empty(database.ListMaterials()); + } + + [Fact] + public async Task Api_PostReturnsCreatedThenOkAndGetListsTheMaterial() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var first = await client.PostAsync("/api/materials", Json("""{"name":"Stainless","grade":"304"}""")); + using var repeat = await client.PostAsync("/api/materials", Json("""{"name":"stainless","grade":"304"}""")); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, repeat.StatusCode); + var entry = (await repeat.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(("Stainless", "304"), (entry.Name, Assert.Single(entry.Grades))); + var listed = Assert.Single((await client.GetFromJsonAsync>("/api/materials", Options))!); + Assert.Equal(("Stainless", "304"), (listed.Name, Assert.Single(listed.Grades))); + } + + [Theory] + [InlineData("""{"grade":"304"}""")] + [InlineData("""{"name":" "}""")] + [InlineData("""{"name":"Stainless","density":0.29}""")] + [InlineData("""{"name":"Stainless","grade":7}""")] + [InlineData("""not json""")] + [InlineData("""null""")] + public async Task Api_RejectedAddition_IsBadRequestWithNoChange(string body) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/materials", Json(body)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Empty((await client.GetFromJsonAsync>("/api/materials", Options))!); + } + + [Fact] + public async Task Api_OverlongName_IsBadRequest() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var name = new string('x', DrawingLibraryRules.MaxTextLength + 1); + + using var response = await client.PostAsync("/api/materials", Json($$"""{"name":"{{name}}"}""")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static NestRecord Nest(string name, string material, string grade) => new() + { + Name = name, + Material = material, + MaterialGrade = grade, + Thickness = 0.25, + DateCreated = new DateTime(2026, 1, 1), + DateModified = new DateTime(2026, 1, 2), + }; + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index 58c98653..8f58cb9a 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests Assert.Equal("Stainless", record.Material); Assert.Equal("", record.MaterialGrade); Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); - Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index 9237f364..88775970 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -294,7 +294,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'")); var release = upgraded.GetRelease(releaseId)!; Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone)); diff --git a/OpenNest.Server/MaterialRoutes.cs b/OpenNest.Server/MaterialRoutes.cs new file mode 100644 index 00000000..fc3590da --- /dev/null +++ b/OpenNest.Server/MaterialRoutes.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// /api/materials: the company material list. Adding is idempotent by +/// : 201 when a material or grade was added, 200 when both +/// were already listed. Either way the reply is the material as listed. +/// +internal static class MaterialRoutes +{ + private static readonly JsonSerializerOptions AdditionOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + public static void MapMaterials(this IEndpointRouteBuilder app) + { + app.MapGet("/api/materials", (NestDatabase db) => Results.Ok(db.ListMaterials())); + + app.MapPost("/api/materials", async (HttpRequest request, NestDatabase db) => + { + var (json, error) = await DrawingSetRoutes.ReadJson(request, "material"); + if (error is not null) return error; + + MaterialListAddition? addition; + try { addition = JsonSerializer.Deserialize(json!, AdditionOptions); } + catch (JsonException) { return Results.BadRequest("Invalid material JSON."); } + if (MaterialListRules.GetError(addition) is { } invalid) + return Results.BadRequest(invalid); + + var (entry, added) = db.AddMaterial(addition!); + return added ? Results.Created("/api/materials", entry) : Results.Ok(entry); + }); + } +} diff --git a/OpenNest.Server/NestDatabase.Materials.cs b/OpenNest.Server/NestDatabase.Materials.cs new file mode 100644 index 00000000..57475de0 --- /dev/null +++ b/OpenNest.Server/NestDatabase.Materials.cs @@ -0,0 +1,110 @@ +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// The company material list: materials and each one's material_grades, keyed by +/// . Nests, drawings and releases keep their own free-text +/// material and grade; the list only suggests names and fixes their spelling. +/// +public sealed partial class NestDatabase +{ + // v9 -> v10. The list starts empty: it is filled through POST /api/materials, not from + // the free-text materials already stored. IF NOT EXISTS keeps the step safe on a database + // rewound to v9. + private const string MaterialSchema = """ + CREATE TABLE IF NOT EXISTS materials ( + key TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + createdAt TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS material_grades ( + materialKey TEXT NOT NULL REFERENCES materials(key), + key TEXT NOT NULL, + name TEXT NOT NULL, + createdAt TEXT NOT NULL, + PRIMARY KEY (materialKey, key)); + PRAGMA user_version=10; + """; + + /// Every listed material with its grades, both in case-insensitive name order. + public IReadOnlyList ListMaterials() + { + lock (_sync) + { + var entries = new List(); + var byKey = new Dictionary(); + using (var command = _connection.CreateCommand()) + { + command.CommandText = "SELECT key, name FROM materials ORDER BY name COLLATE NOCASE, key"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var entry = new MaterialListEntry { Name = reader.GetString(1) }; + byKey[reader.GetString(0)] = entry; + entries.Add(entry); + } + } + + using (var command = _connection.CreateCommand()) + { + command.CommandText = "SELECT materialKey, name FROM material_grades ORDER BY name COLLATE NOCASE, key"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + byKey[reader.GetString(0)].Grades.Add(reader.GetString(1)); + } + + return entries; + } + } + + /// + /// Adds a material and, when given, one of its grades; names already listed by key keep + /// their spelling. Returns the material as now listed and whether anything was added. + /// + public (MaterialListEntry Entry, bool Added) AddMaterial(MaterialListAddition addition) + { + if (MaterialListRules.GetError(addition) is { } error) + throw new ArgumentException(error, nameof(addition)); + + var name = MaterialListRules.Spelling(addition.Name); + var key = SharedListNames.Key(name); + var grade = MaterialListRules.Spelling(addition.Grade); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var now = DateTime.Now.ToString("o"); + var added = InsertMaterial(key, name, now, transaction); + if (grade.Length > 0) + added |= InsertGrade(key, SharedListNames.Key(grade), grade, now, transaction); + transaction.Commit(); + return (ListMaterials().Single(entry => SharedListNames.Key(entry.Name) == key), added); + } + } + + private bool InsertMaterial(string key, string name, string createdAt, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "INSERT OR IGNORE INTO materials (key, name, createdAt) VALUES ($key, $name, $createdAt)"; + command.Parameters.AddWithValue("$key", key); + command.Parameters.AddWithValue("$name", name); + command.Parameters.AddWithValue("$createdAt", createdAt); + return command.ExecuteNonQuery() > 0; + } + + private bool InsertGrade(string materialKey, string key, string name, string createdAt, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT OR IGNORE INTO material_grades (materialKey, key, name, createdAt) + VALUES ($materialKey, $key, $name, $createdAt) + """; + command.Parameters.AddWithValue("$materialKey", materialKey); + command.Parameters.AddWithValue("$key", key); + command.Parameters.AddWithValue("$name", name); + command.Parameters.AddWithValue("$createdAt", createdAt); + return command.ExecuteNonQuery() > 0; + } +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index e716fcd0..420ed94a 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -346,7 +346,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 9) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 10) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -419,6 +419,12 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = ReleaseUpgradeToV9; command.ExecuteNonQuery(); } + if (version < 10) + { + // The company material list, empty until filled through the API. + command.CommandText = MaterialSchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index e08e3b48..2a0c5784 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -142,6 +142,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i app.MapDrawingLibrary(); app.MapDrawingSets(); app.MapReleases(); +app.MapMaterials(); app.Run(); diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 4911bc92..8b93482d 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -641,6 +641,31 @@ Schema v8 adds a `materialGrade` text column (default blank) to `nests` and A server older than v8 refuses to open a v8 database, so rolling back needs the pre-upgrade backup. +## Material list endpoints + +The server keeps the company material list: each material with its grades. Both +are keyed by `SharedListNames.Key`, so names that differ only in case or spacing +are the same entry, and an entry keeps the spelling it was first added with +(trimmed, internal spaces collapsed). Nests, drawings and releases keep their own +free-text material and grade. The list only suggests names and records the +company spelling; it does not check or rewrite stored records. A grade belongs to +one material, and the same grade name may be listed under several materials. +Wire types are `OpenNest.Data.MaterialListEntry` and `MaterialListAddition`. + +| Method | Path | Body | Response | +|---|---|---|---| +| GET | `/api/materials` | — | `[{ "name", "grades": [...] }]`, materials and grades in case-insensitive name order | +| POST | `/api/materials` | `{ "name": "...", "grade": "..." }` (`grade` optional) | The material as listed: 201 when the material or grade was added, 200 when both were already listed | + +Validation (400, no state change, `MaterialListRules`): a JSON object with no +other properties; `name` not blank; name and grade at most 200 characters with +no NUL characters. + +Schema v10 adds empty `materials` and `material_grades` tables. Existing +records are not read into the list; fill it through `POST /api/materials`. No +other table changes. A server older than v10 refuses to open a v10 database, so +rolling back needs the pre-upgrade backup. + ## Storage `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with