diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs index e77d592f..a828b968 100644 --- a/OpenNest.Data/NestRecord.cs +++ b/OpenNest.Data/NestRecord.cs @@ -34,6 +34,12 @@ public sealed class NestRecord public double Thickness { get; set; } + /// + /// The nest's units, which is in. The server reads them from the + /// archive like the counts; inches when the archive has none or the server predates them. + /// + public Units Units { get; set; } + /// quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers). public NestStatus Status { get; set; } = NestStatus.Quote; diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs index fc13cb60..04923ca7 100644 --- a/OpenNest.Data/NestRecordFactory.cs +++ b/OpenNest.Data/NestRecordFactory.cs @@ -46,6 +46,7 @@ public static class NestRecordFactory Material = nest.Material?.Name ?? "", MaterialGrade = nest.Material?.Grade ?? "", Thickness = nest.Thickness, + Units = nest.Units, Status = nest.Status, // The trailing empty plate is the editor's new-plate slot, not a plate of the nest. PlateCount = PlateDisplayNumbering.DisplayedPlateCount(nest.Plates), diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 372efb15..6585b4b7 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, 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=12"); + Execute(factory.DatabasePath, "PRAGMA user_version=13"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 7265f605..86b0ffb7 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index fe4fe93c..9aa24cbf 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -24,7 +24,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, 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); diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index 0e04840b..f51502bb 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs new file mode 100644 index 00000000..b2262637 --- /dev/null +++ b/OpenNest.Server.Tests/NestUnitsTests.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server.Tests; + +/// +/// Nest records carry the archive's units, which their thickness is in, so a list of nests can +/// tell a millimeter nest from an inch one without reading each archive. +/// +public sealed class NestUnitsTests +{ + [Fact] + public async Task Upload_TakesUnitsFromTheArchive_NotTheClientsClaim() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + nest.Units = Units.Millimeters; + nest.Thickness = 6.35; + + using var created = await client.PostAsync("/api/nests", + NestRevisionTests.Body(new NestRecord { Name = "Metric", Units = Units.Inches }, NestApiTests.Archive(nest))); + var json = await created.Content.ReadAsStringAsync(); + var id = JsonDocument.Parse(json).RootElement.GetProperty("id").GetGuid(); + + Assert.Equal("millimeters", JsonDocument.Parse(json).RootElement.GetProperty("units").GetString()); + using var listed = JsonDocument.Parse(await client.GetStringAsync("/api/nests/query")); + var item = Assert.Single(listed.RootElement.GetProperty("items").EnumerateArray()); + Assert.Equal("millimeters", item.GetProperty("units").GetString()); + Assert.Equal(6.35, item.GetProperty("thickness").GetDouble()); + using var record = JsonDocument.Parse(await client.GetStringAsync($"/api/nests/{id}")); + Assert.Equal("millimeters", record.RootElement.GetProperty("units").GetString()); + } + + [Fact] + public void MetadataOnlySave_KeepsTheArchivesUnits() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var id = Guid.NewGuid(); + var created = database.Insert(id, Record("Metric", Units.Millimeters), [1]); + + var updated = database.UpdateMetadata(id, Record("Metric renamed", Units.Inches), created.Revision)!; + + Assert.Equal(Units.Millimeters, updated.Units); + Assert.Equal([Units.Millimeters, Units.Millimeters], database.History(id, 0, 10)!.Items.Select(r => r.Units)); + } + + [Fact] + public void Version11Database_ReadsEachStoredArchivesUnits() + { + using var factory = new ServerFactory(); + var metric = Guid.NewGuid(); + var inch = Guid.NewGuid(); + var unreadable = Guid.NewGuid(); + var metricNest = NestApiTests.SyntheticNest(); + metricNest.Units = Units.Millimeters; + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + var created = database.Insert(metric, Record("Metric", Units.Inches), NestApiTests.Archive(metricNest)); + database.UpdateMetadata(metric, Record("Metric renamed", Units.Inches), created.Revision); + database.Insert(inch, Record("Inch", Units.Inches), NestApiTests.Archive(NestApiTests.SyntheticNest())); + database.Insert(unreadable, Record("Broken", Units.Inches), [1]); + } + Execute(factory.DatabasePath, """ + ALTER TABLE nests DROP COLUMN units; + ALTER TABLE nest_revisions DROP COLUMN units; + PRAGMA user_version=11; + """); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(Units.Millimeters, upgraded.Get(metric)!.Units); + Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units)); + Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units); + Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units); + Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + } + + private static NestRecord Record(string name, Units units) => new() + { + Name = name, + Material = "Steel", + Thickness = 0.25, + Units = units, + 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/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs index 3c09519e..26c25774 100644 --- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); var kept = upgraded.GetRelease(releaseId)!; Assert.Empty(kept.Drawings); Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity)); diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index db7afb90..fd8e4493 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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(12L, 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/NestArchive.cs b/OpenNest.Server/NestArchive.cs index 27da30d8..14820162 100644 --- a/OpenNest.Server/NestArchive.cs +++ b/OpenNest.Server/NestArchive.cs @@ -20,7 +20,7 @@ internal static class NestArchive private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); /// - /// Overwrites the record's plate count, part count, material and thickness with the + /// Overwrites the record's plate count, part count, material, thickness and units with the /// archive's own. Returns an error when the bytes are not a readable nest archive. /// public static string? Apply(NestRecord record, byte[] file) @@ -33,6 +33,7 @@ internal static class NestArchive // falls back to the plate defaults. Thickness falls back when not positive. record.Material = (nest.Material ?? defaults.Material)?.Name ?? ""; record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness; + record.Units = UnitsOf(nest); // NestReader drops parts whose drawing is missing. Cut-off parts are never written as // parts, so every remaining part counts. @@ -106,8 +107,7 @@ internal static class NestArchive }) .ToList(); - // NestReader: absent or unknown units keep the nest default, inches. - var units = Enum.TryParse(nest.Units, ignoreCase: true, out var parsed) ? parsed : Units.Inches; + var units = UnitsOf(nest); sheets = new NestSheets { @@ -121,6 +121,13 @@ internal static class NestArchive return null; } + /// The archive's units, for backfilling records stored before they were kept; inches when unreadable. + public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches; + + // NestReader: absent or unknown units keep the nest default, inches. + private static Units UnitsOf(NestJson nest) => + Enum.TryParse(nest.Units, ignoreCase: true, out var parsed) ? parsed : Units.Inches; + private static string? Read(byte[] file, out NestJson nest) { nest = null!; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index d558db46..f1c1e242 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -154,10 +154,10 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = """ INSERT INTO nests (id, name, customer, dateCreated, dateModified, material, - materialGrade, thickness, status, plateCount, partCount, comments, madeBy, + materialGrade, thickness, units, status, plateCount, partCount, comments, madeBy, fileSize, savedAt, file) VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material, - $materialGrade, $thickness, $status, $plateCount, $partCount, $comments, + $materialGrade, $thickness, $units, $status, $plateCount, $partCount, $comments, $madeBy, $fileSize, $savedAt, $file) """; AddRecordParameters(command, id, record, file, updateFile: true); @@ -186,9 +186,10 @@ public sealed partial class NestDatabase : IDisposable if (current.Revision != expectedRevision) throw new RevisionConflictException(current); if (file is null) { - // The counts describe the archive, which a metadata-only save leaves unchanged. + // The counts and units describe the archive, which a metadata-only save leaves unchanged. record.PlateCount = current.PlateCount; record.PartCount = current.PartCount; + record.Units = current.Units; } file ??= GetFile(id)!; if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current; @@ -210,7 +211,7 @@ public sealed partial class NestDatabase : IDisposable UPDATE nests SET name = $name, customer = $customer, dateCreated = $dateCreated, dateModified = $dateModified, material = $material, - materialGrade = $materialGrade, thickness = $thickness, status = $status, plateCount = $plateCount, + materialGrade = $materialGrade, thickness = $thickness, units = $units, status = $status, plateCount = $plateCount, partCount = $partCount, comments = $comments, madeBy = $madeBy, savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{fileClause} WHERE id = $id @@ -320,7 +321,7 @@ public sealed partial class NestDatabase : IDisposable private static bool SameMetadata(NestRecord a, NestRecord b) => a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated && a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade && - a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount && + a.Thickness == b.Thickness && a.Units == b.Units && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount && a.Comments == b.Comments && a.MadeBy == b.MadeBy; private void Snapshot(Guid id, SqliteTransaction transaction) @@ -341,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 11) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 12) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -426,10 +427,47 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = ReleaseUpgradeToV11; command.ExecuteNonQuery(); } + if (version < 12) + AddUnits(command); transaction.Commit(); } } + // v12: records keep the archive's units, which their thickness is in. Every stored archive, + // current or historical, is read once so millimeter nests are not reported as inches. + private static void AddUnits(SqliteCommand command) + { + foreach (var table in new[] { "nests", "nest_revisions" }) + { + command.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name='units'"; + if ((long)command.ExecuteScalar()! == 0) + { + command.CommandText = $"ALTER TABLE {table} ADD COLUMN units TEXT NOT NULL DEFAULT 'Inches'"; + command.ExecuteNonQuery(); + } + var millimeters = new List(); + command.CommandText = $"SELECT rowid, file FROM {table}"; + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + if (NestArchive.ReadUnits((byte[])reader.GetValue(1)) == Units.Millimeters) + millimeters.Add(reader.GetInt64(0)); + } + } + command.CommandText = $"UPDATE {table} SET units='Millimeters' WHERE rowid=$rowid"; + var rowid = command.Parameters.Add("$rowid", SqliteType.Integer); + foreach (var id in millimeters) + { + rowid.Value = id; + command.ExecuteNonQuery(); + } + command.Parameters.Clear(); + } + command.CommandText = "PRAGMA user_version=12"; + command.ExecuteNonQuery(); + } + /// Checks the live connection without exposing storage error details. public bool IsHealthy() { @@ -464,7 +502,7 @@ public sealed partial class NestDatabase : IDisposable private const string RecordColumns = """ id, name, customer, dateCreated, dateModified, material, thickness, status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision, - materialGrade + materialGrade, units """; // Case-insensitive (ASCII) substring over the text columns and the status's stored @@ -557,6 +595,7 @@ public sealed partial class NestDatabase : IDisposable command.Parameters.AddWithValue("$material", record.Material); command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade); command.Parameters.AddWithValue("$thickness", record.Thickness); + command.Parameters.AddWithValue("$units", record.Units.ToString()); command.Parameters.AddWithValue("$status", record.Status.ToString()); command.Parameters.AddWithValue("$plateCount", record.PlateCount); command.Parameters.AddWithValue("$partCount", record.PartCount); @@ -594,6 +633,7 @@ public sealed partial class NestDatabase : IDisposable Deleted = reader.GetInt32(first + 15) != 0, RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16), MaterialGrade = reader.GetString(first + 17), + Units = Enum.TryParse(reader.GetString(first + 18), true, out var units) ? units : Units.Inches, }; } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 6c50e274..4f4f90dc 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -83,6 +83,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr | `material` | string | Material name (type), e.g. `Stainless`. Read from the uploaded archive (nest material, else plate defaults' on an explicit null); a metadata-only PUT may change it, and the desktop applies the record's value when opening the nest. | | `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. | | `thickness` | number | Read from the uploaded archive (nest thickness, else plate defaults' when not positive); editable like `material`. | +| `units` | `"inches"` \| `"millimeters"` | The nest's units, which `thickness` is in. Server-computed from the uploaded archive (absent or unknown values are inches, as `NestReader` reads them); ignored on upload and kept by a metadata-only PUT. Schema v12 read every stored archive, current and historical, to fill it. Older servers omit it, which readers take as inches. | | `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | | `plateCount` / `partCount` | integer | Server-computed from the uploaded archive (non-cutoff parts only; a trailing plate with no parts or cut-offs is not counted); ignored on upload and kept by a metadata-only PUT. | | `comments` | string | | @@ -247,7 +248,8 @@ only the requested page of metadata (never archive bytes): can merge into): `status` (`draft`, `quote`, `toBeCut` or `hasBeenCut`), `material` and `materialGrade` (compared by `SharedListNames.Key`, so case and spacing are ignored; a present but blank `materialGrade` asks for nests with no - grade) and `thickness` (a number, zero or greater, matched at four decimals). + grade) and `thickness` (a number, zero or greater, matched at four decimals in + each nest's own units). The client sends them only when set, so plain browsing still works against servers without them. - `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500).