diff --git a/OpenNest.Data/IDrawingSetStore.cs b/OpenNest.Data/IDrawingSetStore.cs
index a9fc2df0..b8872bd5 100644
--- a/OpenNest.Data/IDrawingSetStore.cs
+++ b/OpenNest.Data/IDrawingSetStore.cs
@@ -44,9 +44,18 @@ public interface IDrawingSetStore
/// One release with its sets and nests, or null for an unknown id.
Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default);
- /// One page of releases with their sets and nests, newest first, optionally only those including a set or a nest.
- Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0,
- int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
+ ///
+ /// One page of releases with their sets and nests, newest first, optionally only those
+ /// including a set, a nest, or a drawing released on its own.
+ ///
+ Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null,
+ int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
+
+ ///
+ /// Raises when the server cannot record drawings released
+ /// on their own. A release asks before building its nests, since the record is posted last.
+ ///
+ Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default);
/// Undoes a release, all or nothing; a refusal raises .
Task UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default);
diff --git a/OpenNest.Data/RemoteDrawingSetStore.cs b/OpenNest.Data/RemoteDrawingSetStore.cs
index ccf3c986..80edfe42 100644
--- a/OpenNest.Data/RemoteDrawingSetStore.cs
+++ b/OpenNest.Data/RemoteDrawingSetStore.cs
@@ -21,6 +21,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private const string ReleaseFeature = "releasing several drawing sets at once";
+ private const string DrawingReleaseFeature = "releasing library drawings on their own";
+
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -173,11 +175,16 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
throw new ServerTooOldException(ReleaseFeature);
if (response.StatusCode == HttpStatusCode.Conflict)
throw new SetReleasePostConflictException(await ReadRelease(response, cancellationToken));
+ // A server without drawings on their own refuses such a release as malformed.
+ if (response.StatusCode == HttpStatusCode.BadRequest && release.Drawings.Count > 0)
+ await EnsureDrawingReleasesSupportedAsync(cancellationToken);
await EnsureSuccess(response, "record the release", cancellationToken);
var stored = await ReadRelease(response, cancellationToken);
if (stored.Id != release.Id
|| !stored.Sets.Select(s => (s?.SetId, s?.SetRevision, s?.Quantity))
.SequenceEqual(release.Sets.Select(s => ((Guid?)s.SetId, (int?)s.SetRevision, (int?)s.Quantity)))
+ || !stored.Drawings.Select(d => (d?.DrawingId, d?.Quantity))
+ .SequenceEqual(release.Drawings.Select(d => ((Guid?)d.DrawingId, (int?)d.Quantity)))
|| !stored.Nests.Select(n => (n?.NestId, n?.Kind, n?.RevisionAfter))
.SequenceEqual(release.Nests.Select(n => ((Guid?)n.NestId, (SetReleaseNestKind?)n.Kind, (int?)n.RevisionAfter))))
throw new IOException("The nest server recorded a different release than was posted.");
@@ -200,29 +207,47 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
return release;
}
- public async Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0,
- int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
+ public async Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null,
+ int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
{
if (setId == Guid.Empty) throw new ArgumentException("A drawing set id is required.", nameof(setId));
if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId));
+ if (drawingId == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(drawingId));
ValidatePaging(offset, limit);
var query = Paging(offset, limit);
if (setId is not null) query += $"&setId={setId}";
if (nestId is not null) query += $"&nestId={nestId}";
+ if (drawingId is not null) query += $"&drawingId={drawingId}";
using var response = await _httpClient.GetAsync(Url($"api/releases/query?{query}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
- throw new ServerTooOldException(ReleaseFeature);
+ throw new ServerTooOldException(drawingId is null ? ReleaseFeature : DrawingReleaseFeature);
+ // A server without drawings on their own refuses the unknown drawingId parameter.
+ if (response.StatusCode == HttpStatusCode.BadRequest && drawingId is not null)
+ throw new ServerTooOldException(DrawingReleaseFeature);
await EnsureSuccess(response, "read releases", cancellationToken);
var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty release page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
- || page.Items.Any(item => item is null || item.Sets is null || item.Nests is null
+ || page.Items.Any(item => item is null || item.Sets is null || item.Drawings is null || item.Nests is null
|| (setId is not null && item.Sets.All(s => s?.SetId != setId))
- || (nestId is not null && item.Nests.All(n => n?.NestId != nestId))))
+ || (nestId is not null && item.Nests.All(n => n?.NestId != nestId))
+ || (drawingId is not null && item.Drawings.All(d => d?.DrawingId != drawingId))))
throw new IOException("Nest server returned an invalid or unbounded release page.");
return page;
}
+ ///
+ /// Asks for releases of a drawing no release can name: a server from before drawings on
+ /// their own answers 400 for the unknown parameter (or 404 without releases at all).
+ ///
+ public async Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default)
+ {
+ using var probe = await _httpClient.GetAsync(Url($"api/releases/query?drawingId={Guid.NewGuid()}&limit=1"), cancellationToken);
+ if (probe.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest)
+ throw new ServerTooOldException(DrawingReleaseFeature);
+ await EnsureSuccess(probe, "check the release support", cancellationToken);
+ }
+
///
/// Undoes a release on the server, all or nothing. A refusal raises
/// : the release was undone before, or nests
@@ -377,6 +402,11 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
SetRevision = s.SetRevision,
Quantity = s.Quantity,
}).ToList(),
+ Drawings = release.Drawings.Select(d => new SetReleaseDrawing
+ {
+ DrawingId = d.DrawingId,
+ Quantity = d.Quantity,
+ }).ToList(),
Nests = release.Nests.Select(n => new SetReleaseNest
{
NestId = n.NestId,
@@ -442,7 +472,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private static async Task ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken)
{
var release = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
- if (release is null || release.Sets is null || release.Nests is null)
+ if (release is null || release.Sets is null || release.Drawings is null || release.Nests is null)
throw new IOException("Nest server returned an empty set release record.");
return release;
}
diff --git a/OpenNest.Data/SetReleaseNest.cs b/OpenNest.Data/SetReleaseNest.cs
index c3f2da12..2b62a072 100644
--- a/OpenNest.Data/SetReleaseNest.cs
+++ b/OpenNest.Data/SetReleaseNest.cs
@@ -50,7 +50,8 @@ public sealed class SetReleaseNest
public CutType CutType { get; set; } = CutType.Laser;
///
- /// The drawing quantities this release added to the nest, per set. Empty for releases
+ /// The drawing quantities this release added to the nest, per set and for the drawings
+ /// released on their own. Empty for releases
/// recorded before items were kept (schema v8).
///
public IReadOnlyList Items { get; set; } = Array.Empty();
@@ -68,10 +69,11 @@ public sealed class SetReleaseNest
public NestRecord? Nest { get; set; }
}
-/// A drawing quantity one set of a release added to one nest.
+/// A drawing quantity one set of a release, or the drawing released on its own, added to one nest.
public sealed class SetReleaseNestItem
{
- public Guid SetId { get; set; }
+ /// The set that asked for the quantity; null for a drawing released on its own.
+ public Guid? SetId { get; set; }
public Guid DrawingId { get; set; }
diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs
index 8572397a..b7945071 100644
--- a/OpenNest.Data/SetReleasePlan.cs
+++ b/OpenNest.Data/SetReleasePlan.cs
@@ -6,12 +6,21 @@ namespace OpenNest.Data;
/// One set of a release plan: a set read from the server and how many kits of it.
public sealed record SetReleasePlanLine(DrawingSetRecord Set, int Quantity);
-/// How many of an item one set of the release asked for.
-public sealed record SetReleaseItemSource(Guid SetId, int Quantity);
+///
+/// One library drawing released on its own, outside any set: its current record, as read from
+/// the library, and how many of it.
+///
+public sealed record SetReleaseDrawingLine(DrawingLibraryRecord Drawing, int Quantity);
+
+///
+/// How many of an item one set of the release asked for, kits included;
+/// is null for the drawing released on its own.
+///
+public sealed record SetReleaseItemSource(Guid? SetId, int Quantity);
///
/// One library drawing as released: its current record and the quantity summed over every
-/// set of the release that lists it, kits included.
+/// set of the release that lists it, kits included, and over the drawing released on its own.
///
public sealed class SetReleaseItem
{
@@ -23,7 +32,10 @@ public sealed class SetReleaseItem
Quantity = checked(sources.Sum(s => s.Quantity));
}
- /// The member that first listed the drawing, in set order.
+ ///
+ /// The member that first listed the drawing, in set order; for a drawing that no set
+ /// lists, a member made from its line released on its own.
+ ///
public DrawingSetMember Member { get; }
///
@@ -32,7 +44,10 @@ public sealed class SetReleaseItem
///
public DrawingLibraryRecord Drawing { get; }
- /// Each set's share: its member quantity times its kits, in set order.
+ ///
+ /// Each set's share, its member quantity times its kits, in set order, then the quantity
+ /// released on its own (with no set).
+ ///
public IReadOnlyList Sources { get; }
/// The total over .
@@ -100,9 +115,10 @@ public sealed class SetReleaseGroup
}
///
-/// How one or more drawing set revisions split into nests, before anything is downloaded or
-/// created. Pure: it reads the members' joined drawing records only. A drawing listed by
-/// several sets becomes one item with the quantities summed, each set's share kept. Items are
+/// How drawing set revisions and library drawings released on their own split into nests,
+/// before anything is downloaded or created. Pure: it reads the members' joined drawing
+/// records and the drawings' records only. A drawing listed by several sets, or by a set and
+/// on its own, becomes one item with the quantities summed, each share kept. Items are
/// grouped by material name and grade (compared by ),
/// thickness (rounded as the library stores it), units and cut type. Archived drawings are
/// skipped unless included (S5). A group is refused when two of its drawings share a name,
@@ -110,21 +126,26 @@ public sealed class SetReleaseGroup
///
public sealed class SetReleasePlan
{
- private SetReleasePlan(IReadOnlyList lines, bool includeArchived,
- IReadOnlyList groups, IReadOnlyList skippedArchived)
+ private SetReleasePlan(IReadOnlyList lines, IReadOnlyList drawings,
+ bool includeArchived, IReadOnlyList groups, IReadOnlyList skippedArchived)
{
Lines = lines;
+ Drawings = drawings;
IncludeArchived = includeArchived;
Groups = groups;
SkippedArchived = skippedArchived;
- var customers = lines.Select(l => (l.Set.Customer ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList();
+ var customers = lines.Select(l => l.Set.Customer).Concat(drawings.Select(d => d.Drawing.Customer))
+ .Select(c => (c ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList();
Customer = customers.Count == 1 ? customers[0] : "";
}
/// The sets released and how many kits of each, as read from the server.
public IReadOnlyList Lines { get; }
- /// The customer every set names, or blank when they differ.
+ /// The library drawings released on their own and how many of each, as read from the library.
+ public IReadOnlyList Drawings { get; }
+
+ /// The customer every set and every drawing on its own names, or blank when they differ.
public string Customer { get; }
public bool IncludeArchived { get; }
@@ -135,7 +156,10 @@ public sealed class SetReleasePlan
///
public IReadOnlyList Groups { get; }
- /// Members left out because their drawing is archived and archived drawings were not included.
+ ///
+ /// Members left out because their drawing is archived and archived drawings were not
+ /// included; a drawing released on its own is listed as a member made from its line.
+ ///
public IReadOnlyList SkippedArchived { get; }
/// A set read from the server, so every member carries its drawing's current record.
@@ -143,11 +167,22 @@ public sealed class SetReleasePlan
Build(new[] { new SetReleasePlanLine(set, multiplier) }, includeArchived);
/// Sets read from the server, so every member carries its drawing's current record.
- public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived)
+ public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived) =>
+ Build(lines, Array.Empty(), includeArchived);
+
+ /// Sets read from the server, so every member carries its drawing's current record; may be empty.
+ /// Library drawings released on their own, as read from the library; may be empty.
+ public static SetReleasePlan Build(IReadOnlyList lines, IReadOnlyList drawings,
+ bool includeArchived)
{
ArgumentNullException.ThrowIfNull(lines);
- if (lines.Count == 0 || lines.Count > SetReleaseRules.MaxSetLines)
- throw new ArgumentException($"Release between 1 and {SetReleaseRules.MaxSetLines} sets.", nameof(lines));
+ ArgumentNullException.ThrowIfNull(drawings);
+ if (lines.Count + drawings.Count == 0)
+ throw new ArgumentException("Release at least one set or drawing.", nameof(lines));
+ if (lines.Count > SetReleaseRules.MaxSetLines)
+ throw new ArgumentException($"Release at most {SetReleaseRules.MaxSetLines} sets.", nameof(lines));
+ if (drawings.Count > SetReleaseRules.MaxDrawingLines)
+ throw new ArgumentException($"Release at most {SetReleaseRules.MaxDrawingLines} drawings on their own.", nameof(drawings));
var seenSets = new HashSet();
foreach (var line in lines)
{
@@ -164,32 +199,47 @@ public sealed class SetReleasePlan
throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(lines));
}
- // One entry per drawing, in first-listed order; each set's share kept.
+ var seenDrawings = new HashSet();
+ foreach (var line in drawings)
+ {
+ var drawing = line?.Drawing ?? throw new ArgumentException("Every drawing line needs a drawing.", nameof(drawings));
+ if (line.Quantity < 1 || line.Quantity > SetReleaseRules.MaxDrawingQuantity)
+ throw new ArgumentOutOfRangeException(nameof(drawings), line.Quantity,
+ $"The quantity must be between 1 and {SetReleaseRules.MaxDrawingQuantity}.");
+ if (drawing.Id == Guid.Empty || drawing.Revision < 1)
+ throw new ArgumentException("Only a drawing read from the library can be released.", nameof(drawings));
+ if (!seenDrawings.Add(drawing.Id))
+ throw new ArgumentException($"Drawing '{drawing.Name}' is listed twice. Release it once with a larger quantity.",
+ nameof(drawings));
+ }
+
+ // One entry per drawing, in first-listed order (sets, then drawings on their own); each share kept.
var skipped = new List();
var order = new List();
var entries = new Dictionary Sources)>();
- foreach (var line in lines)
+ var shares = lines.SelectMany(line => line.Set.Members.Select(member =>
+ (Member: member, Share: new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity)))))
+ .Concat(drawings.Select(line =>
+ (Member: new DrawingSetMember { DrawingId = line.Drawing.Id, Quantity = line.Quantity, Drawing = line.Drawing },
+ Share: new SetReleaseItemSource(null, line.Quantity))));
+ foreach (var (member, share) in shares)
{
- foreach (var member in line.Set.Members)
+ if (member.Drawing!.Archived && !includeArchived)
{
- if (member.Drawing!.Archived && !includeArchived)
- {
- skipped.Add(member);
- continue;
- }
+ skipped.Add(member);
+ continue;
+ }
- var share = new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity));
- if (entries.TryGetValue(member.DrawingId, out var entry))
- {
- entry.Sources.Add(share);
- if (member.Drawing.Revision > entry.Drawing.Revision)
- entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources);
- }
- else
- {
- order.Add(member.DrawingId);
- entries[member.DrawingId] = (member, member.Drawing, new List { share });
- }
+ if (entries.TryGetValue(member.DrawingId, out var entry))
+ {
+ entry.Sources.Add(share);
+ if (member.Drawing.Revision > entry.Drawing.Revision)
+ entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources);
+ }
+ else
+ {
+ order.Add(member.DrawingId);
+ entries[member.DrawingId] = (member, member.Drawing, new List { share });
}
}
@@ -211,7 +261,7 @@ public sealed class SetReleasePlan
.ThenBy(g => g.Units)
.ThenBy(g => g.CutType)
.ToList();
- return new SetReleasePlan(lines.ToList(), includeArchived, groups, skipped);
+ return new SetReleasePlan(lines.ToList(), drawings.ToList(), includeArchived, groups, skipped);
}
///
diff --git a/OpenNest.Data/SetReleaseRecord.cs b/OpenNest.Data/SetReleaseRecord.cs
index 9acda321..61c551ef 100644
--- a/OpenNest.Data/SetReleaseRecord.cs
+++ b/OpenNest.Data/SetReleaseRecord.cs
@@ -1,8 +1,9 @@
namespace OpenNest.Data;
///
-/// One release of one or more drawing sets: the nests it made or merged into, one per
-/// material group, with every member quantity multiplied by its set line's quantity.
+/// One release of drawing sets, library drawings on their own, or both: the nests it made or
+/// merged into, one per material group, with every member quantity multiplied by its set
+/// line's quantity.
/// Releases are append-only; undoing one restores or deletes its nests and marks it
/// undone, it never removes the record.
///
@@ -14,6 +15,12 @@ public sealed class SetReleaseRecord
/// The sets released and how many times each, in the order posted.
public IReadOnlyList Sets { get; set; } = Array.Empty();
+ ///
+ /// Library drawings released on their own, outside any set, in the order posted. Empty for
+ /// a release of sets only and for every release recorded before schema v11.
+ ///
+ public IReadOnlyList Drawings { get; set; } = Array.Empty();
+
public string Notes { get; set; } = "";
/// When the server stored the release. Server-computed.
@@ -48,3 +55,14 @@ public sealed class SetReleaseLine
/// The set's name at . Server-joined; ignored on post.
public string SetName { get; set; } = "";
}
+
+/// One library drawing released on its own, outside any set, and how many of it.
+public sealed class SetReleaseDrawing
+{
+ public Guid DrawingId { get; set; }
+
+ public int Quantity { get; set; } = 1;
+
+ /// The drawing's current library name. Server-joined; ignored on post.
+ public string DrawingName { get; set; } = "";
+}
diff --git a/OpenNest.Data/SetReleaseRules.cs b/OpenNest.Data/SetReleaseRules.cs
index 227576c2..ef16a5ad 100644
--- a/OpenNest.Data/SetReleaseRules.cs
+++ b/OpenNest.Data/SetReleaseRules.cs
@@ -3,8 +3,8 @@ using OpenNest;
namespace OpenNest.Data;
///
-/// Bounds shared by the set release client and server. Whether the set revisions, nests
-/// and nest revisions exist, and whether a created nest already belongs to a release, is
+/// Bounds shared by the set release client and server. Whether the set revisions, drawings,
+/// nests and nest revisions exist, and whether a created nest already belongs to a release, is
/// checked by the server.
///
public static class SetReleaseRules
@@ -15,11 +15,17 @@ public static class SetReleaseRules
/// Most sets in one release.
public const int MaxSetLines = 50;
+ /// Most drawings released on their own in one release, as many as one set may list.
+ public const int MaxDrawingLines = DrawingSetRules.MaxMembers;
+
+ /// Most of one drawing released on its own, as for one set member.
+ public const int MaxDrawingQuantity = DrawingSetRules.MaxQuantity;
+
/// One nest per material group.
public const int MaxNests = DrawingSetRules.MaxMembers;
- /// Most drawing quantities recorded against one nest: every member of every set.
- public const int MaxItemsPerNest = DrawingSetRules.MaxMembers * MaxSetLines;
+ /// Most drawing quantities recorded against one nest: every member of every set, and every drawing on its own.
+ public const int MaxItemsPerNest = DrawingSetRules.MaxMembers * MaxSetLines + MaxDrawingLines;
/// The first rule breaks, or null when it may be posted.
public static string? GetError(SetReleaseRecord? release)
@@ -28,7 +34,8 @@ public static class SetReleaseRules
return "Missing release.";
var error = DrawingLibraryRules.TextError("releasedBy", release.ReleasedBy, DrawingLibraryRules.MaxTextLength)
?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength)
- ?? SetsError(release.Sets);
+ ?? SetsError(release.Sets, release.Drawings)
+ ?? DrawingsError(release.Drawings);
if (error is not null)
return error;
if (release.Nests is null || release.Nests.Count == 0)
@@ -37,6 +44,7 @@ public static class SetReleaseRules
return $"A release may have at most {MaxNests} nests.";
var sets = release.Sets.Select(s => s.SetId).ToHashSet();
+ var drawings = release.Drawings.Select(d => d.DrawingId).ToHashSet();
var seen = new HashSet();
for (var i = 0; i < release.Nests.Count; i++)
{
@@ -55,7 +63,7 @@ public static class SetReleaseRules
: null)
?? (!Enum.IsDefined(nest.Units) ? $"nests[{i}].units must be a supported unit name." : null)
?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null)
- ?? ItemsError(i, nest.Items, sets);
+ ?? ItemsError(i, nest.Items, sets, drawings);
if (error is not null)
return error;
}
@@ -77,10 +85,13 @@ public static class SetReleaseRules
return DrawingLibraryRules.TextError("undoneBy", request.UndoneBy, DrawingLibraryRules.MaxTextLength);
}
- private static string? SetsError(IReadOnlyList? lines)
+ // A release names at least one set or one drawing on its own.
+ private static string? SetsError(IReadOnlyList? lines, IReadOnlyList? drawings)
{
- if (lines is null || lines.Count == 0)
- return "sets must list at least one set.";
+ if (lines is null)
+ return "sets must be a list.";
+ if (lines.Count == 0 && (drawings is null || drawings.Count == 0))
+ return "sets or drawings must list at least one set or drawing.";
if (lines.Count > MaxSetLines)
return $"A release may have at most {MaxSetLines} sets.";
@@ -103,6 +114,30 @@ public static class SetReleaseRules
return null;
}
+ private static string? DrawingsError(IReadOnlyList? drawings)
+ {
+ if (drawings is null)
+ return "drawings must be a list.";
+ if (drawings.Count > MaxDrawingLines)
+ return $"A release may have at most {MaxDrawingLines} drawings on their own.";
+
+ var seen = new HashSet();
+ for (var i = 0; i < drawings.Count; i++)
+ {
+ var drawing = drawings[i];
+ if (drawing is null)
+ return $"drawings[{i}] is required.";
+ if (drawing.DrawingId == Guid.Empty)
+ return $"drawings[{i}].drawingId is required.";
+ if (!seen.Add(drawing.DrawingId))
+ return $"drawings[{i}] repeats drawing {drawing.DrawingId}.";
+ if (drawing.Quantity < 1 || drawing.Quantity > MaxDrawingQuantity)
+ return $"drawings[{i}].quantity must be between 1 and {MaxDrawingQuantity}.";
+ }
+
+ return null;
+ }
+
private static string? RevisionsError(int i, SetReleaseNest nest)
{
if (!Enum.IsDefined(nest.Kind))
@@ -116,26 +151,32 @@ public static class SetReleaseRules
return nest.RevisionAfter > before ? null : $"nests[{i}].revisionAfter must be later than revisionBefore.";
}
- private static string? ItemsError(int i, IReadOnlyList? items, HashSet sets)
+ // An item with no set is the quantity of a drawing released on its own.
+ private static string? ItemsError(int i, IReadOnlyList? items, HashSet sets,
+ HashSet drawings)
{
if (items is null)
return $"nests[{i}].items is required.";
if (items.Count > MaxItemsPerNest)
return $"nests[{i}] may record at most {MaxItemsPerNest} items.";
- var seen = new HashSet<(Guid, Guid)>();
+ var seen = new HashSet<(Guid?, Guid)>();
for (var j = 0; j < items.Count; j++)
{
var item = items[j];
var name = $"nests[{i}].items[{j}]";
if (item is null)
return $"{name} is required.";
- if (!sets.Contains(item.SetId))
+ if (item.SetId is { } setId && !sets.Contains(setId))
return $"{name}.setId must be one of the release's sets.";
if (item.DrawingId == Guid.Empty)
return $"{name}.drawingId is required.";
+ if (item.SetId is null && !drawings.Contains(item.DrawingId))
+ return $"{name} has no setId, so drawingId must be one of the release's drawings.";
if (!seen.Add((item.SetId, item.DrawingId)))
- return $"{name} repeats drawing {item.DrawingId} of set {item.SetId}.";
+ return item.SetId is null
+ ? $"{name} repeats drawing {item.DrawingId}."
+ : $"{name} repeats drawing {item.DrawingId} of set {item.SetId}.";
if (item.DrawingRevision < 1)
return $"{name}.drawingRevision must be a positive revision.";
if (item.Quantity < 1)
diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs
index 035c212a..e6bfbf74 100644
--- a/OpenNest.Data/SetReleaseSession.cs
+++ b/OpenNest.Data/SetReleaseSession.cs
@@ -133,7 +133,7 @@ public sealed class SetReleaseResult
}
///
-/// Releases one or more drawing sets: one nest per chosen group of a ,
+/// Releases drawing sets and library drawings on their own: one nest per chosen group of a ,
/// either new (uploaded as a ) or an existing draft of the same
/// group that the release merges into, each built through
/// from the items' exact library revisions, and only then recorded as one release (S7).
@@ -146,7 +146,8 @@ public sealed class SetReleaseResult
///
/// Every group is built before any is uploaded, so a refusal (a package that no longer
/// matches its record, units the new nest does not use, an insert refusal) creates
-/// nothing. Uploads then run in order; the first failure stops them, and cancellation is
+/// nothing, and so does a server too old to record drawings released on their own, which is
+/// checked first. Uploads then run in order; the first failure stops them, and cancellation is
/// honoured between uploads, never during one. Nests already uploaded stay as ordinary
/// nests and are listed in the result; the release is posted only when every chosen group
/// was created. Nothing is rolled back.
@@ -238,6 +239,32 @@ public sealed class SetReleaseSession
MergedInto = mergeInto.TryGetValue(g, out var draft) ? draft : null,
}).ToList();
+ // A server that cannot record drawings on their own would refuse the release after its
+ // nests were saved, so it is asked before anything is built.
+ if (plan.Drawings.Count > 0)
+ {
+ try
+ {
+ await _sets.EnsureDrawingReleasesSupportedAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return new SetReleaseResult(results, canceled: true, null, "");
+ }
+ catch (ServerTooOldException ex)
+ {
+ Refuse(results[0], ex.Message);
+ return new SetReleaseResult(results, canceled: false, null, "");
+ }
+ catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
+ or TimeoutException)
+ {
+ results[0].Outcome = SetReleaseGroupOutcome.Failed;
+ results[0].Error = $"Could not check that the nest server records drawings released on their own: {ex.Message}";
+ return new SetReleaseResult(results, canceled: false, null, "");
+ }
+ }
+
// Build every nest first: a refusal here leaves nothing on the server.
for (var i = 0; i < results.Count; i++)
{
@@ -295,6 +322,11 @@ public sealed class SetReleaseSession
SetRevision = l.Set.Revision,
Quantity = l.Quantity,
}).ToList(),
+ Drawings = plan.Drawings.Select(d => new SetReleaseDrawing
+ {
+ DrawingId = d.Drawing.Id,
+ Quantity = d.Quantity,
+ }).ToList(),
Notes = notes ?? "",
ReleasedBy = releasedBy ?? "",
Nests = results.Select(r => new SetReleaseNest
@@ -395,7 +427,7 @@ public sealed class SetReleaseSession
var record = item.Drawing;
var bytes = await _library.GetRevisionPackageAsync(record.Id, record.Revision, cancellationToken);
if (bytes == null)
- return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set.");
+ return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set or drawing.");
var package = await Task.Run(() => _readPackage(bytes), cancellationToken);
var insert = LibraryDrawingInsert.Plan(nest, _serverUrl, record, package, item.Quantity);
diff --git a/OpenNest.Data/SetReleaseText.cs b/OpenNest.Data/SetReleaseText.cs
index 77232025..aaceb811 100644
--- a/OpenNest.Data/SetReleaseText.cs
+++ b/OpenNest.Data/SetReleaseText.cs
@@ -8,15 +8,22 @@ public static class SetReleaseText
{
///
/// Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith",
- /// or "Merged from sets 'Kit A' revision 3 ×2 and 'Kit B' revision 1 ×1 ..." for a release
- /// that added to rather than creating it.
+ /// "Released from drawing 'Bracket' ×4", or "Merged from sets 'Kit A' revision 3 ×2 and
+ /// 'Kit B' revision 1 ×1 ..." for a release that added to rather
+ /// than creating it.
///
public static string Origin(SetReleaseRecord release, Guid nestId = default)
{
ArgumentNullException.ThrowIfNull(release);
var merged = release.Nests?.FirstOrDefault(n => n.NestId == nestId)?.Kind == SetReleaseNestKind.Merged;
var sets = release.Sets ?? Array.Empty();
- var text = $"{(merged ? "Merged" : "Released")} from {(sets.Count == 1 ? "set" : "sets")} {Sets(sets)}";
+ var drawings = release.Drawings ?? Array.Empty();
+ var sources = new List();
+ if (sets.Count > 0)
+ sources.Add($"{(sets.Count == 1 ? "set" : "sets")} {Sets(sets)}");
+ if (drawings.Count > 0)
+ sources.Add(Drawings(drawings));
+ var text = $"{(merged ? "Merged" : "Released")} from {string.Join(" and ", sources)}";
if (release.ReleasedAt != default)
text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}";
if (!string.IsNullOrWhiteSpace(release.ReleasedBy))
@@ -34,6 +41,22 @@ public static class SetReleaseText
: string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1];
}
+ ///
+ /// The drawings a release had on their own: "drawing 'Bracket' ×4", "drawings 'A' ×1 and
+ /// 'B' ×2", or "5 drawings" when there are more than three.
+ ///
+ public static string Drawings(IReadOnlyList drawings)
+ {
+ ArgumentNullException.ThrowIfNull(drawings);
+ if (drawings.Count > 3)
+ return $"{drawings.Count} drawings";
+ var names = drawings.Select(d => $"'{d.DrawingName}' ×{d.Quantity}").ToList();
+ var text = names.Count <= 1
+ ? string.Concat(names)
+ : string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1];
+ return $"{(drawings.Count == 1 ? "drawing" : "drawings")} {text}";
+ }
+
///
/// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted",
/// or "Undone". Deleted nests are left out of the count.
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index 9f26da8e..372efb15 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, 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=11");
+ Execute(factory.DatabasePath, "PRAGMA user_version=12");
Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
index 8f9c11f7..7265f605 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
diff --git a/OpenNest.Server.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs
index 93521b54..70613f98 100644
--- a/OpenNest.Server.Tests/DrawingSetFixtures.cs
+++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs
@@ -34,12 +34,12 @@ internal static class DrawingSetFixtures
return (await Upload(client, record, Package(name))).Id;
}
- /// Drops the v9 release tables, for fixtures that rebuild a schema older than v7.
+ /// Drops the v11 release tables, for fixtures that rebuild a schema older than v7.
public const string DropReleaseTables = """
- DROP TABLE release_nest_items; DROP TABLE release_nests; DROP TABLE release_sets; DROP TABLE releases;
+ DROP TABLE release_drawings; DROP TABLE release_nest_items; DROP TABLE release_nests; DROP TABLE release_sets; DROP TABLE releases;
""";
- /// Puts back the v7/v8 release tables in place of the v9 ones, for fixtures that rebuild v7 or v8.
+ /// Puts back the v7/v8 release tables in place of the v11 ones, for fixtures that rebuild v7 or v8.
public const string ReleaseTablesBackToV8 = DropReleaseTables + """
CREATE TABLE set_releases (
id TEXT NOT NULL PRIMARY KEY,
diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs
index 5dd5fe97..fe4fe93c 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, 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 8f58cb9a..0e04840b 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(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
new file mode 100644
index 00000000..3c09519e
--- /dev/null
+++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
@@ -0,0 +1,342 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+using OpenNest.IO;
+using OpenNest.Shapes;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+using static OpenNest.Server.Tests.DrawingSetFixtures;
+
+namespace OpenNest.Server.Tests;
+
+///
+/// Library drawings released on their own (schema v11): stored beside or instead of sets,
+/// items without a set, the drawingId query filter, the v10 upgrade and the whole path
+/// through the real clients, including undo.
+///
+public sealed class SetReleaseDrawingTests
+{
+ // A release of drawings only: one nest per id, each holding the first drawing's quantity.
+ private static SetReleaseRecord DrawingRelease(Guid drawing, int quantity, params Guid[] nests)
+ {
+ var release = Release(Guid.Empty, 1, nests);
+ release.Sets = Array.Empty();
+ release.Drawings = new[] { new SetReleaseDrawing { DrawingId = drawing, Quantity = quantity } };
+ foreach (var nest in release.Nests)
+ nest.Items = new[] { new SetReleaseNestItem { DrawingId = drawing, DrawingRevision = 1, Quantity = quantity } };
+ return release;
+ }
+
+ [Fact]
+ public void DrawingsOnTheirOwn_AreStoredWithSetsAndItemsWithoutASet()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var bracket = AddDrawing(database, "Synthetic bracket");
+ var plate = AddDrawing(database, "Synthetic plate");
+ var kit = Set(members: (bracket, 1));
+ database.InsertDrawingSet(kit.Id, kit);
+ var nest = AddNest(database);
+ var release = Release(kit.Id, 1, nest);
+ release.Drawings = new[]
+ {
+ new SetReleaseDrawing { DrawingId = plate, Quantity = 40, DrawingName = "ignored" },
+ new SetReleaseDrawing { DrawingId = bracket, Quantity = 5 },
+ };
+ release.Nests[0].Items = new[]
+ {
+ new SetReleaseNestItem { SetId = kit.Id, DrawingId = bracket, DrawingRevision = 1, Quantity = 2 },
+ new SetReleaseNestItem { DrawingId = bracket, DrawingRevision = 1, Quantity = 5 },
+ new SetReleaseNestItem { DrawingId = plate, DrawingRevision = 1, Quantity = 40 },
+ };
+
+ var stored = database.InsertRelease(release);
+
+ Assert.Equal(new[] { (plate, 40, "Synthetic plate"), (bracket, 5, "Synthetic bracket") },
+ stored.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName)));
+ Assert.Equal(new[] { ((Guid?)kit.Id, bracket, 2), (null, bracket, 5), (null, plate, 40) },
+ stored.Nests[0].Items.Select(i => (i.SetId, i.DrawingId, i.Quantity)));
+ Assert.Equal(release.Id, database.QueryReleases(null, null, 0, 100, drawingId: plate).Items.Single().Id);
+ Assert.Equal(release.Id, database.QueryReleases(kit.Id, null, 0, 100, drawingId: bracket).Items.Single().Id);
+ Assert.Empty(database.QueryReleases(null, null, 0, 100, drawingId: AddDrawing(database, "Never released")).Items);
+ Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings"));
+ }
+
+ [Fact]
+ public void ReleaseOfDrawingsOnly_IsStored_AndUndoDeletesItsNest()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var created = AddNest(database, "Created nest");
+ var draft = AddNest(database, "Draft nest");
+ var release = DrawingRelease(drawing, 12, created, draft).Merged(1, 1, Resave(database, draft));
+
+ var stored = database.InsertRelease(release);
+ var undone = database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })!;
+
+ Assert.Empty(stored.Sets);
+ Assert.Equal((drawing, 12), (stored.Drawings.Single().DrawingId, stored.Drawings.Single().Quantity));
+ Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Deleted, SetReleaseNestUndo.Restored }, undone.Nests.Select(n => n.Undo));
+ Assert.Null(database.Get(created));
+ Assert.Equal(1, database.Get(draft)!.RestoredFromRevision);
+ Assert.Equal("Released from drawing 'Synthetic bracket' ×12 by Synthetic operator (undone)",
+ SetReleaseText.Origin(new SetReleaseRecord
+ {
+ Drawings = undone.Drawings,
+ Nests = undone.Nests,
+ ReleasedBy = undone.ReleasedBy,
+ UndoneAt = undone.UndoneAt,
+ }));
+ }
+
+ [Fact]
+ public void Refusals_ForDrawingsLeaveNoState()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var nest = AddNest(database);
+
+ Assert.Contains("does not exist", Assert.Throws(() =>
+ database.InsertRelease(DrawingRelease(Guid.NewGuid(), 1, nest))).Message);
+ var twice = DrawingRelease(drawing, 1, nest);
+ twice.Drawings = twice.Drawings.Concat(twice.Drawings).ToList();
+ Assert.Contains("repeats drawing", Assert.Throws(() => database.InsertRelease(twice)).Message);
+ var stranger = DrawingRelease(drawing, 1, nest);
+ stranger.Nests[0].Items = new[] { new SetReleaseNestItem { DrawingId = Guid.NewGuid(), DrawingRevision = 1, Quantity = 1 } };
+ Assert.Contains("one of the release's drawings", Assert.Throws(() => database.InsertRelease(stranger)).Message);
+ var tooMany = DrawingRelease(drawing, SetReleaseRules.MaxDrawingQuantity + 1, nest);
+ Assert.Throws(() => database.InsertRelease(tooMany));
+ var nothing = DrawingRelease(drawing, 1, nest);
+ nothing.Drawings = Array.Empty();
+ nothing.Nests[0].Items = Array.Empty();
+ Assert.Contains("at least one set or drawing", Assert.Throws(() => database.InsertRelease(nothing)).Message);
+
+ Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM releases"));
+ Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings"));
+ }
+
+ [Fact]
+ public void Version10Items_SurviveTheUpgrade_AndMayThenGoWithoutASet()
+ {
+ using var factory = new ServerFactory();
+ var drawing = Guid.Empty;
+ var kit = Set();
+ var releaseId = Guid.Empty;
+ using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
+ {
+ drawing = AddDrawing(database);
+ kit = Set(members: (drawing, 1));
+ database.InsertDrawingSet(kit.Id, kit);
+ var release = Release(kit.Id, 1, AddNest(database));
+ release.Nests[0].Items = new[] { new SetReleaseNestItem { SetId = kit.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 2 } };
+ releaseId = database.InsertRelease(release).Id;
+ }
+ // The v10 layout: no release_drawings, and every item names a set.
+ Execute(factory.DatabasePath, """
+ DROP TABLE release_drawings;
+ CREATE TABLE items_v10 (
+ releaseId TEXT NOT NULL, nestId TEXT NOT NULL, position INTEGER NOT NULL, setId TEXT NOT NULL,
+ drawingId TEXT NOT NULL, drawingRevision INTEGER NOT NULL, quantity INTEGER NOT NULL,
+ PRIMARY KEY (releaseId, nestId, position), UNIQUE (releaseId, nestId, setId, drawingId),
+ FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId));
+ INSERT INTO items_v10 SELECT * FROM release_nest_items;
+ DROP TABLE release_nest_items;
+ ALTER TABLE items_v10 RENAME TO release_nest_items;
+ PRAGMA user_version=10;
+ """);
+
+ using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
+
+ Assert.Equal(11L, 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));
+ Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM pragma_table_info('release_nest_items') WHERE name='setId' AND \"notnull\"=0"));
+ Assert.Single(upgraded.InsertRelease(DrawingRelease(drawing, 3, AddNest(upgraded))).Drawings);
+ }
+
+ [Fact]
+ public async Task Api_PostsDrawingsWithANullItemSet_AndFiltersByDrawing()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var drawing = await AddDrawing(client, "Synthetic gusset");
+ var release = DrawingRelease(drawing, 7, await AddNest(client));
+
+ using var posted = await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(release, Options)));
+
+ Assert.Equal(HttpStatusCode.OK, posted.StatusCode);
+ var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!;
+ Assert.Empty(json["sets"]!.AsArray());
+ Assert.Equal("Synthetic gusset", json["drawings"]![0]!["drawingName"]!.GetValue());
+ Assert.Null(json["nests"]![0]!["items"]![0]!["setId"]);
+ var page = (await client.GetFromJsonAsync($"/api/releases/query?drawingId={drawing}", Options))!;
+ Assert.Equal(release.Id, page.Items.Single().Id);
+ Assert.Equal(0, (await client.GetFromJsonAsync($"/api/releases/query?drawingId={Guid.NewGuid()}", Options))!.Total);
+ foreach (var query in new[] { "drawingId=x", $"drawingId={Guid.Empty}", $"drawingId={drawing}&drawingId={drawing}" })
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/releases/query?{query}")).StatusCode);
+
+ // A client from before drawings on their own sends no drawings at all.
+ var set = await Create(client, Set());
+ var legacy = JsonNode.Parse(JsonSerializer.Serialize(Release(set.Id, 1, await AddNest(client)), Options))!.AsObject();
+ legacy.Remove("drawings");
+ using var old = await client.PostAsync("/api/releases", Json(legacy.ToJsonString()));
+ Assert.Equal(HttpStatusCode.OK, old.StatusCode);
+ }
+
+ public static TheoryData RejectedDrawingReleases()
+ {
+ var valid = JsonSerializer.Serialize(DrawingRelease(DrawingA, 2, NestA), Options);
+ string With(Action edit)
+ {
+ var node = JsonNode.Parse(valid)!.AsObject();
+ edit(node);
+ return node.ToJsonString();
+ }
+
+ return new TheoryData
+ {
+ { "no sets and no drawings", With(n => { n["drawings"] = new JsonArray(); n["nests"]![0]!["items"] = new JsonArray(); }) },
+ { "null drawings", With(n => n["drawings"] = null) },
+ { "unknown drawing", With(n => n["drawings"]![0]!["drawingId"] = Guid.NewGuid().ToString()) },
+ { "empty drawing id", With(n => n["drawings"]![0]!["drawingId"] = Guid.Empty.ToString()) },
+ { "zero quantity", With(n => n["drawings"]![0]!["quantity"] = 0) },
+ { "quantity over the limit", With(n => n["drawings"]![0]!["quantity"] = SetReleaseRules.MaxDrawingQuantity + 1) },
+ { "repeated drawing", With(n => n["drawings"]!.AsArray().Add(n["drawings"]![0]!.DeepClone())) },
+ { "item of a drawing not released", With(n => n["nests"]![0]!["items"]![0]!["drawingId"] = Guid.NewGuid().ToString()) },
+ { "repeated item", With(n => n["nests"]![0]!["items"]!.AsArray().Add(n["nests"]![0]!["items"]![0]!.DeepClone())) },
+ };
+ }
+
+ private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a2");
+ private static readonly Guid DrawingA = Guid.Parse("00000000-0000-0000-0000-0000000000c2");
+
+ [Theory]
+ [MemberData(nameof(RejectedDrawingReleases))]
+ public async Task Api_InvalidDrawingRelease_IsRejectedWithNoState(string row, string json)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ await AddDrawing(client, edit: r => r.Id = DrawingA);
+ await AddNest(client, id: NestA);
+
+ using var response = await client.PostAsync("/api/releases", Json(json));
+
+ Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode);
+ Assert.Equal(0, (await client.GetFromJsonAsync("/api/releases/query", Options))!.Total);
+ }
+
+ [Fact]
+ public async Task Client_ConfirmsSupport_PostsAndQueriesByDrawing()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString());
+ var drawing = await AddDrawing(client, "Synthetic gusset");
+ var release = DrawingRelease(drawing, 9, await AddNest(client));
+
+ await store.EnsureDrawingReleasesSupportedAsync();
+ var stored = await store.PostReleaseAsync(release);
+
+ Assert.Equal(("Synthetic gusset", 9), (stored.Drawings.Single().DrawingName, stored.Drawings.Single().Quantity));
+ Assert.Equal(release.Id, (await store.QueryReleasesAsync(drawingId: drawing)).Items.Single().Id);
+ Assert.Empty((await store.QueryReleasesAsync(drawingId: Guid.NewGuid())).Items);
+ var unknown = DrawingRelease(Guid.NewGuid(), 1, await AddNest(client));
+ Assert.DoesNotContain("does not support", (await Assert.ThrowsAsync(() => store.PostReleaseAsync(unknown))).Message);
+ }
+
+ [Fact]
+ public async Task Release_OfASetAndDrawingsOnTheirOwn_SharesNests_RecordsBoth_AndUndoes()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var url = client.BaseAddress!.ToString();
+ using var library = new RemoteDrawingLibrary(client, url);
+ using var sets = new RemoteDrawingSetStore(client, url);
+ using var nests = new RemoteNestRepository(client, url);
+ async Task Store(string name, double length, string material)
+ {
+ var record = new DrawingLibraryRecord
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ Customer = "Synthetic customer",
+ MaterialName = material,
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Laser,
+ SavedBy = "Synthetic operator",
+ };
+ var drawing = new RectangleShape { Name = name, Length = length, Width = 2 }.GetDrawing();
+ return await library.UploadAsync(record, DrawingPackage.Write(drawing, Units.Inches));
+ }
+ var a = await Store("SYN-A", 4, "Synthetic Steel");
+ var b = await Store("SYN-B", 5, "Synthetic Steel");
+ var c = await Store("SYN-C", 6, "Synthetic Aluminum");
+ var kit = await sets.GetAsync((await sets.CreateAsync(new DrawingSetRecord
+ {
+ Id = Guid.NewGuid(),
+ Name = "Synthetic kit",
+ Customer = "Synthetic customer",
+ Members = new[] { new DrawingSetMember { DrawingId = a.Id, Quantity = 2 } },
+ })).Id);
+ var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(kit!, 3) },
+ new[] { new SetReleaseDrawingLine(a, 1), new SetReleaseDrawingLine(b, 4), new SetReleaseDrawingLine(c, 10) },
+ includeArchived: false);
+ var number = 0;
+ var session = new SetReleaseSession(sets, library, nests, url,
+ bytes =>
+ {
+ var contents = DrawingPackage.Read(bytes);
+ return new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings);
+ },
+ nest =>
+ {
+ using var stream = new MemoryStream();
+ new NestWriter(nest).Write(stream);
+ return stream.ToArray();
+ },
+ (group, _) => Task.FromResult(new Nest($"Synthetic loose {++number}") { Units = group.Units }));
+
+ var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "");
+
+ Assert.Equal("", result.ReleaseError);
+ var release = result.Release!;
+ Assert.Equal(new[] { (a.Id, 1, "SYN-A"), (b.Id, 4, "SYN-B"), (c.Id, 10, "SYN-C") },
+ release.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName)));
+ var steel = release.Nests.Single(n => n.MaterialName == "Synthetic Steel");
+ Assert.Equal(new[] { ((Guid?)kit!.Id, a.Id, 6), (null, a.Id, 1), (null, b.Id, 4) },
+ steel.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity)));
+ Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(drawingId: c.Id)).Items.Select(r => r.Id));
+ Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(setId: kit.Id)).Items.Select(r => r.Id));
+ Assert.Equal("Released from set 'Synthetic kit' revision 1 ×3 and drawings 'SYN-A' ×1, 'SYN-B' ×4 and 'SYN-C' ×10",
+ SetReleaseText.Origin(new SetReleaseRecord { Sets = release.Sets, Drawings = release.Drawings }));
+
+ var undone = await sets.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic operator" });
+ Assert.All(undone.Nests, n => Assert.Equal(SetReleaseNestUndo.Deleted, n.Undo));
+ foreach (var group in result.Groups)
+ Assert.Null(await nests.GetMetadataAsync(group.Record!.Id));
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+}
diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs
index 88775970..db7afb90 100644
--- a/OpenNest.Server.Tests/SetReleaseTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseTests.cs
@@ -48,7 +48,7 @@ public sealed class SetReleaseTests
stored.Nests.Select(n => (n.NestId, n.Kind, n.RevisionBefore, n.RevisionAfter)));
Assert.Equal(("Synthetic Steel 1", "Grade A", 0.1235, Units.Millimeters, CutType.Waterjet),
(stored.Nests[1].MaterialName, stored.Nests[1].MaterialGrade, stored.Nests[1].Thickness, stored.Nests[1].Units, stored.Nests[1].CutType));
- Assert.Equal(new[] { (kit.Id, 2), (other.Id, 6) }, stored.Nests[1].Items.Select(i => (i.SetId, i.Quantity)));
+ Assert.Equal(new[] { ((Guid?)kit.Id, 2), (other.Id, 6) }, stored.Nests[1].Items.Select(i => (i.SetId, i.Quantity)));
Assert.Empty(stored.Nests[0].Items);
Assert.Equal(("Created nest", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted));
Assert.Null(stored.Nests[0].Undo);
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
- Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(11L, 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/NestDatabase.Releases.cs b/OpenNest.Server/NestDatabase.Releases.cs
index cb4137e5..7985dce4 100644
--- a/OpenNest.Server/NestDatabase.Releases.cs
+++ b/OpenNest.Server/NestDatabase.Releases.cs
@@ -6,9 +6,10 @@ namespace OpenNest.Server;
///
/// Release storage. releases records who released and whether it was undone;
/// release_sets which set revisions were released and how many kits of each;
+/// release_drawings which library drawings were released on their own and how many;
/// release_nests the nests each release created or merged into, with the nest
-/// revision before and after; release_nest_items the drawing quantities each set
-/// added to each nest. A created nest belongs to that release only; a merged nest may be in
+/// revision before and after; release_nest_items the drawing quantities each set, or
+/// each drawing on its own (no set), added to each nest. A created nest belongs to that release only; a merged nest may be in
/// several. Releases are append-only and read joined to each nest's current metadata.
///
public sealed partial class NestDatabase
@@ -106,6 +107,37 @@ public sealed partial class NestDatabase
PRAGMA user_version=9;
""";
+ // v10 -> v11. Releases may name library drawings on their own, and an item without a set
+ // is such a drawing's quantity. SQLite cannot drop NOT NULL from a column, so the item
+ // table is rebuilt with every row kept. IF NOT EXISTS keeps the step safe on a database
+ // rewound to v10.
+ private const string ReleaseUpgradeToV11 = """
+ CREATE TABLE IF NOT EXISTS release_drawings (
+ releaseId TEXT NOT NULL REFERENCES releases(id),
+ position INTEGER NOT NULL,
+ drawingId TEXT NOT NULL REFERENCES drawings(id),
+ quantity INTEGER NOT NULL,
+ PRIMARY KEY (releaseId, position),
+ UNIQUE (releaseId, drawingId));
+ CREATE INDEX IF NOT EXISTS ix_release_drawings_drawing ON release_drawings(drawingId);
+ CREATE TABLE release_nest_items_v11 (
+ releaseId TEXT NOT NULL,
+ nestId TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ setId TEXT,
+ drawingId TEXT NOT NULL,
+ drawingRevision INTEGER NOT NULL,
+ quantity INTEGER NOT NULL,
+ PRIMARY KEY (releaseId, nestId, position),
+ UNIQUE (releaseId, nestId, setId, drawingId),
+ FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId));
+ INSERT INTO release_nest_items_v11 (releaseId, nestId, position, setId, drawingId, drawingRevision, quantity)
+ SELECT releaseId, nestId, position, setId, drawingId, drawingRevision, quantity FROM release_nest_items;
+ DROP TABLE release_nest_items;
+ ALTER TABLE release_nest_items_v11 RENAME TO release_nest_items;
+ PRAGMA user_version=11;
+ """;
+
private const string ReleaseSelect =
"SELECT x.id, x.notes, x.releasedAt, x.releasedBy, x.undoneAt, x.undoneBy FROM releases x";
@@ -113,8 +145,8 @@ public sealed partial class NestDatabase
string.Join(", ", RecordColumns.Split(',').Select(column => "n." + column.Trim()));
///
- /// Records a release whose nests already exist. Set revisions must exist but need not be
- /// current, and sets may be archived: the nests already exist, so a release is never
+ /// Records a release whose nests already exist. Set revisions and drawings released on
+ /// their own must exist but need not be current, and sets and drawings may be archived: the nests already exist, so a release is never
/// refused for a state the operator cannot fix. A merged nest need not still be at the
/// revision the release wrote.
///
@@ -142,6 +174,12 @@ public sealed partial class NestDatabase
}
}
+ foreach (var drawing in release.Drawings)
+ {
+ if (GetDrawing(drawing.DrawingId) is null)
+ throw new ArgumentException($"Drawing {drawing.DrawingId} does not exist.");
+ }
+
foreach (var nest in release.Nests)
{
if (Get(nest.NestId) is null)
@@ -176,9 +214,10 @@ public sealed partial class NestDatabase
///
/// One page of releases, newest first, optionally only those that include
- /// or .
+ /// , or
+ /// released on its own.
///
- public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit)
+ public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit, Guid? drawingId = null)
{
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit));
@@ -187,12 +226,15 @@ public sealed partial class NestDatabase
filters.Add("EXISTS (SELECT 1 FROM release_sets s WHERE s.releaseId = x.id AND s.setId = $setId)");
if (nestId is not null)
filters.Add("EXISTS (SELECT 1 FROM release_nests r WHERE r.releaseId = x.id AND r.nestId = $nestId)");
+ if (drawingId is not null)
+ filters.Add("EXISTS (SELECT 1 FROM release_drawings d WHERE d.releaseId = x.id AND d.drawingId = $drawingId)");
var where = filters.Count == 0 ? "" : "WHERE " + string.Join(" AND ", filters);
void AddFilters(SqliteCommand command)
{
if (setId is not null) command.Parameters.AddWithValue("$setId", setId.Value.ToString());
if (nestId is not null) command.Parameters.AddWithValue("$nestId", nestId.Value.ToString());
+ if (drawingId is not null) command.Parameters.AddWithValue("$drawingId", drawingId.Value.ToString());
}
lock (_sync)
@@ -378,6 +420,23 @@ public sealed partial class NestDatabase
command.ExecuteNonQuery();
}
+ command.Parameters.Clear();
+ command.CommandText = """
+ INSERT INTO release_drawings (releaseId, position, drawingId, quantity)
+ VALUES ($id, $position, $drawing, $quantity)
+ """;
+ command.Parameters.AddWithValue("$id", release.Id.ToString());
+ position = command.Parameters.Add("$position", SqliteType.Integer);
+ var drawing = command.Parameters.Add("$drawing", SqliteType.Text);
+ quantity = command.Parameters.Add("$quantity", SqliteType.Integer);
+ for (var i = 0; i < release.Drawings.Count; i++)
+ {
+ position.Value = i;
+ drawing.Value = release.Drawings[i].DrawingId.ToString();
+ quantity.Value = release.Drawings[i].Quantity;
+ command.ExecuteNonQuery();
+ }
+
command.Parameters.Clear();
command.CommandText = """
INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter,
@@ -431,7 +490,7 @@ public sealed partial class NestDatabase
{
var item = nest.Items[i];
position.Value = i;
- setId.Value = item.SetId.ToString();
+ setId.Value = (object?)item.SetId?.ToString() ?? DBNull.Value;
drawingId.Value = item.DrawingId.ToString();
drawingRevision.Value = item.DrawingRevision;
quantity.Value = item.Quantity;
@@ -471,8 +530,9 @@ public sealed partial class NestDatabase
return releases;
}
- // Called only under _sync. Reads the release's sets (named as at their revision) and its
- // nests, each joined to its current metadata, deleted or not, with its items.
+ // Called only under _sync. Reads the release's sets (named as at their revision), its
+ // drawings on their own (named as now) and its nests, each joined to its current metadata,
+ // deleted or not, with its items.
private void Complete(SetReleaseRecord release)
{
var id = release.Id.ToString();
@@ -499,6 +559,28 @@ public sealed partial class NestDatabase
release.Sets = sets;
}
+ using (var command = _connection.CreateCommand())
+ {
+ command.CommandText = """
+ SELECT d.drawingId, d.quantity, COALESCE(x.name, '')
+ FROM release_drawings d LEFT JOIN drawings x ON x.id = d.drawingId
+ WHERE d.releaseId=$id ORDER BY d.position
+ """;
+ command.Parameters.AddWithValue("$id", id);
+ var drawings = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ drawings.Add(new SetReleaseDrawing
+ {
+ DrawingId = Guid.Parse(reader.GetString(0)),
+ Quantity = reader.GetInt32(1),
+ DrawingName = reader.GetString(2),
+ });
+ }
+ release.Drawings = drawings;
+ }
+
var items = new Dictionary>();
using (var command = _connection.CreateCommand())
{
@@ -515,7 +597,7 @@ public sealed partial class NestDatabase
items[nestId] = list = new List();
list.Add(new SetReleaseNestItem
{
- SetId = Guid.Parse(reader.GetString(1)),
+ SetId = reader.IsDBNull(1) ? null : Guid.Parse(reader.GetString(1)),
DrawingId = Guid.Parse(reader.GetString(2)),
DrawingRevision = reader.GetInt32(3),
Quantity = reader.GetInt32(4),
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index 420ed94a..299da577 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 > 10) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version > 11) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -425,6 +425,12 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = MaterialSchema;
command.ExecuteNonQuery();
}
+ if (version < 11)
+ {
+ // Releases may name library drawings on their own.
+ command.CommandText = ReleaseUpgradeToV11;
+ command.ExecuteNonQuery();
+ }
transaction.Commit();
}
}
diff --git a/OpenNest.Server/ReleaseRequest.cs b/OpenNest.Server/ReleaseRequest.cs
index 5025df29..505a423c 100644
--- a/OpenNest.Server/ReleaseRequest.cs
+++ b/OpenNest.Server/ReleaseRequest.cs
@@ -13,7 +13,7 @@ namespace OpenNest.Server;
///
internal static class ReleaseRequest
{
- private static readonly string[] QueryKeys = { "setId", "nestId", "offset", "limit" };
+ private static readonly string[] QueryKeys = { "setId", "nestId", "drawingId", "offset", "limit" };
// Integer enum values are refused; names are additionally checked before deserializing.
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
@@ -23,10 +23,11 @@ internal static class ReleaseRequest
};
public static bool TryParseQuery(IQueryCollection values, out Guid? setId, out Guid? nestId,
- out int offset, out int limit, out string error)
+ out Guid? drawingId, out int offset, out int limit, out string error)
{
setId = null;
nestId = null;
+ drawingId = null;
offset = 0;
limit = DrawingLibraryRules.DefaultRevisionLimit;
foreach (var key in values.Keys)
@@ -45,6 +46,7 @@ internal static class ReleaseRequest
}
if (!TryId(values, "setId", out setId, out error) || !TryId(values, "nestId", out nestId, out error)
+ || !TryId(values, "drawingId", out drawingId, out error)
|| !DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error)
|| !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error))
return false;
@@ -60,7 +62,8 @@ internal static class ReleaseRequest
///
/// Reads and validates one release JSON object. sets and nests must be
- /// present, enums must be single names, and joined or server-computed values are dropped.
+ /// present, drawings may be left out (clients from before drawings on their own),
+ /// enums must be single names, and joined or server-computed values are dropped.
///
public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error)
{
@@ -85,6 +88,7 @@ internal static class ReleaseRequest
return false;
}
+ DrawingSetRequest.RemoveJoined(root, "drawings", "drawingName");
if (!DrawingSetRequest.RemoveJoined(root, "nests", "nest"))
{
error = "nests is required.";
diff --git a/OpenNest.Server/ReleaseRoutes.cs b/OpenNest.Server/ReleaseRoutes.cs
index eade8bd6..7d2fd7a2 100644
--- a/OpenNest.Server/ReleaseRoutes.cs
+++ b/OpenNest.Server/ReleaseRoutes.cs
@@ -3,7 +3,7 @@ using OpenNest.Data;
namespace OpenNest.Server;
///
-/// /api/releases: append-only records of drawing set releases into nests, and their
+/// /api/releases: append-only records of releases of drawing sets and library drawings into nests, and their
/// undo. A release is posted after every nest it names is uploaded; its id is allocated by
/// the client, so a post whose reply was lost can be sent again (409 with the stored release).
///
@@ -28,8 +28,9 @@ internal static class ReleaseRoutes
});
app.MapGet("/api/releases/query", (HttpRequest request, NestDatabase db) =>
- ReleaseRequest.TryParseQuery(request.Query, out var setId, out var nestId, out var offset, out var limit, out var error)
- ? Results.Ok(db.QueryReleases(setId, nestId, offset, limit))
+ ReleaseRequest.TryParseQuery(request.Query, out var setId, out var nestId, out var drawingId, out var offset,
+ out var limit, out var error)
+ ? Results.Ok(db.QueryReleases(setId, nestId, offset, limit, drawingId))
: Results.BadRequest(error));
app.MapGet("/api/releases/{id:guid}", (Guid id, NestDatabase db) =>
diff --git a/OpenNest.Tests/Data/InMemoryDrawingSets.cs b/OpenNest.Tests/Data/InMemoryDrawingSets.cs
index 36cfb19e..3ad589cb 100644
--- a/OpenNest.Tests/Data/InMemoryDrawingSets.cs
+++ b/OpenNest.Tests/Data/InMemoryDrawingSets.cs
@@ -116,6 +116,12 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
public int PostAttempts { get; private set; }
public bool FailNextPost { get; set; }
+ /// Plays a server from before drawings released on their own.
+ public bool WithoutDrawingReleases { get; set; }
+
+ /// Library drawing names joined onto stored releases, by id.
+ public Dictionary DrawingNames { get; } = new();
+
public Task PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default)
{
PostAttempts++;
@@ -137,6 +143,12 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
Quantity = s.Quantity,
SetName = _revisions[s.SetId].First(r => r.Revision == s.SetRevision).Name,
}).ToList(),
+ Drawings = release.Drawings.Select(d => new SetReleaseDrawing
+ {
+ DrawingId = d.DrawingId,
+ Quantity = d.Quantity,
+ DrawingName = DrawingNames.TryGetValue(d.DrawingId, out var name) ? name : "",
+ }).ToList(),
Notes = release.Notes,
ReleasedBy = release.ReleasedBy,
ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc),
@@ -149,12 +161,15 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
public Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(Releases.FirstOrDefault(r => r.Id == id));
- public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, int limit = 100,
- CancellationToken cancellationToken = default)
+ public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null,
+ int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
{
+ if (drawingId is not null && WithoutDrawingReleases)
+ throw new ServerTooOldException("releasing library drawings on their own");
var newest = Enumerable.Reverse(Releases
.Where(r => setId is null || r.Sets.Any(s => s.SetId == setId))
.Where(r => nestId is null || r.Nests.Any(n => n.NestId == nestId))
+ .Where(r => drawingId is null || r.Drawings.Any(d => d.DrawingId == drawingId))
.ToList()).ToList();
return Task.FromResult(new SetReleasePage
{
@@ -165,6 +180,11 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
});
}
+ public Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default) =>
+ WithoutDrawingReleases
+ ? Task.FromException(new ServerTooOldException("releasing library drawings on their own"))
+ : Task.CompletedTask;
+
/// Every undo request, in order.
public List<(Guid Id, SetReleaseUndoRequest Request)> UndoRequests { get; } = new();
diff --git a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs
index e9d6bece..b37da5fb 100644
--- a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs
+++ b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs
@@ -50,6 +50,27 @@ public class RemoteDrawingSetStoreTests
Assert.Contains("drawing sets", query.Message);
}
+ [Fact]
+ public async Task ServerWithoutDrawingsOnTheirOwn_RefusesTheDrawingFilter_AndTheSupportCheck()
+ {
+ // A server from before drawings on their own refuses the unknown query parameter.
+ var handler = new StubHandler(request => request.RequestUri!.Query.Contains("drawingId=")
+ ? new HttpResponseMessage(HttpStatusCode.BadRequest)
+ : StubHandler.Json(new { items = Array.Empty