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