mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 02:41:06 -04:00
Releasing a drawing set makes one nest per material group, so a kit run spans several nests. The server now records which set revision was released, the multiplier and the nests it made, so a run can be tracked across its nests and a nest can say where it came from. The client uploads every nest first and posts the release last: a failure part-way leaves ordinary nests and no release, never a release pointing at missing nests. For the same reason a release is accepted for an older set revision or an archived set, since the nests already exist by then. The release id is client-allocated and a nest belongs to at most one release, so a retried post gets 409 with the release that already holds it. Releases are append-only and read joined to each nest's current metadata, so status and deletion are always current. The tables join schema v7, as the set tables have not shipped yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
19 KiB
C#
393 lines
19 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server;
|
|
|
|
/// <summary>
|
|
/// Drawing set storage. <c>drawing_set_revisions</c> holds every immutable revision's details
|
|
/// and archived flag, <c>drawing_set_members</c> every revision's ordered members, and
|
|
/// <c>drawing_sets</c> each set's current revision for browsing. Members reference library
|
|
/// drawings by id and are read joined to each drawing's current record. Every change appends
|
|
/// a revision, with its members, in one transaction.
|
|
/// </summary>
|
|
public sealed partial class NestDatabase
|
|
{
|
|
private const string SetColumnDefinitions = """
|
|
id TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
nameKey TEXT NOT NULL,
|
|
customer TEXT NOT NULL DEFAULT '',
|
|
customerKey TEXT NOT NULL DEFAULT '',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
savedAt TEXT NOT NULL,
|
|
savedBy TEXT NOT NULL DEFAULT ''
|
|
""";
|
|
|
|
private const string DrawingSetSchema = $"""
|
|
CREATE TABLE drawing_set_revisions ({SetColumnDefinitions}, PRIMARY KEY (id, revision));
|
|
CREATE TABLE drawing_sets ({SetColumnDefinitions}, PRIMARY KEY (id));
|
|
CREATE TABLE drawing_set_members (
|
|
setId TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
drawingId TEXT NOT NULL REFERENCES drawings(id),
|
|
quantity INTEGER NOT NULL,
|
|
PRIMARY KEY (setId, revision, position),
|
|
UNIQUE (setId, revision, drawingId));
|
|
CREATE INDEX ix_drawing_sets_name ON drawing_sets(customerKey, nameKey);
|
|
CREATE INDEX ix_drawing_sets_savedAt ON drawing_sets(savedAt DESC);
|
|
CREATE INDEX ix_drawing_set_members_drawing ON drawing_set_members(drawingId);
|
|
{SetReleaseTables}
|
|
PRAGMA user_version=7;
|
|
""";
|
|
|
|
private const string SetColumns = "s.id, s.revision, s.name, s.customer, s.notes, s.archived, s.savedAt, s.savedBy";
|
|
|
|
private const string SetStoredColumns = "id, revision, name, customer, notes, archived, savedAt, savedBy, nameKey, customerKey";
|
|
|
|
// Read after SetColumns: the member count of the row's own revision.
|
|
private const string SetMemberCount =
|
|
"(SELECT COUNT(*) FROM drawing_set_members m WHERE m.setId = s.id AND m.revision = s.revision)";
|
|
|
|
private const string SetSearchPredicate = """
|
|
s.name LIKE $pattern ESCAPE '\' OR s.customer LIKE $pattern ESCAPE '\' OR s.notes LIKE $pattern ESCAPE '\'
|
|
""";
|
|
|
|
private static readonly string JoinedDrawingColumns =
|
|
string.Join(", ", DrawingColumns.Split(',').Select(column => "d." + column.Trim()));
|
|
|
|
/// <summary>The current revision with its members, or null for an unknown id.</summary>
|
|
public DrawingSetRecord? GetDrawingSet(Guid id)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var record = ReadSet($"SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s WHERE s.id=$id", id, null);
|
|
return record is null ? null : WithMembers(record);
|
|
}
|
|
}
|
|
|
|
/// <summary>One immutable revision with its members, or null when the set or revision is unknown.</summary>
|
|
public DrawingSetRecord? GetDrawingSetRevision(Guid id, int revision)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var record = ReadSet(
|
|
$"SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id AND s.revision=$revision",
|
|
id, revision);
|
|
return record is null ? null : WithMembers(record);
|
|
}
|
|
}
|
|
|
|
/// <summary>One bounded page of matching sets (without members) plus the total, read in one monitor hold.</summary>
|
|
public DrawingSetPage QueryDrawingSets(DrawingSetQuery query)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
var error = query.GetValidationError();
|
|
if (error is not null)
|
|
throw new ArgumentException(error, nameof(query));
|
|
|
|
var conditions = new List<string>();
|
|
if (!query.IncludeArchived) conditions.Add("s.archived=0");
|
|
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + SetSearchPredicate + ")");
|
|
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("s.customerKey=$customer");
|
|
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
|
|
|
|
lock (_sync)
|
|
{
|
|
using var count = _connection.CreateCommand();
|
|
count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}";
|
|
AddSetFilterParameters(count, query);
|
|
var total = checked((int)(long)count.ExecuteScalar()!);
|
|
|
|
using var command = _connection.CreateCommand();
|
|
var direction = query.Descending ? "DESC" : "ASC";
|
|
command.CommandText = $"""
|
|
SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where}
|
|
ORDER BY {SetSortColumn(query.Sort)} {direction}, s.id {direction}
|
|
LIMIT $limit OFFSET $offset
|
|
""";
|
|
AddSetFilterParameters(command, query);
|
|
command.Parameters.AddWithValue("$limit", query.Limit);
|
|
command.Parameters.AddWithValue("$offset", query.Offset);
|
|
return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = query.Offset, Limit = query.Limit };
|
|
}
|
|
}
|
|
|
|
/// <summary>Stores revision 1 under a client-allocated id; an existing id is a conflict.</summary>
|
|
public DrawingSetRecord InsertDrawingSet(Guid id, DrawingSetRecord record)
|
|
{
|
|
if (id == Guid.Empty) throw new ArgumentException("A set id is required.", nameof(id));
|
|
ValidateSet(record);
|
|
lock (_sync)
|
|
{
|
|
using var transaction = _connection.BeginTransaction();
|
|
if (GetDrawingSet(id) is { } existing)
|
|
throw new DrawingSetConflictException(existing, "A drawing set with this id already exists.");
|
|
RequireMemberDrawings(record, transaction);
|
|
WriteSetRevision(id, record, record.Members, 1, archived: false, transaction);
|
|
transaction.Commit();
|
|
return GetDrawingSet(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends a revision with new details and members. Returns null for an unknown id;
|
|
/// an identical save (details and members in order) is a no-op.
|
|
/// </summary>
|
|
public DrawingSetRecord? UpdateDrawingSet(Guid id, DrawingSetRecord record, int expectedRevision)
|
|
{
|
|
ValidateSet(record);
|
|
lock (_sync)
|
|
{
|
|
using var transaction = _connection.BeginTransaction();
|
|
var current = GetDrawingSet(id);
|
|
if (current is null) return null;
|
|
if (current.Revision != expectedRevision)
|
|
throw new DrawingSetConflictException(current, "The drawing set has changed. Reload before saving.");
|
|
if (current.Archived)
|
|
throw new DrawingSetConflictException(current, "The drawing set is archived. Restore it before saving.");
|
|
if (SameSet(current, record))
|
|
return current;
|
|
|
|
RequireMemberDrawings(record, transaction);
|
|
WriteSetRevision(id, record, record.Members, checked(current.Revision + 1), archived: false, transaction);
|
|
transaction.Commit();
|
|
return GetDrawingSet(id);
|
|
}
|
|
}
|
|
|
|
/// <summary>Archives or restores a set as a new revision with the same members. Returns null for an unknown id.</summary>
|
|
public DrawingSetRecord? SetDrawingSetArchived(Guid id, bool archived, int expectedRevision)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
using var transaction = _connection.BeginTransaction();
|
|
var current = GetDrawingSet(id);
|
|
if (current is null) return null;
|
|
if (current.Revision != expectedRevision)
|
|
throw new DrawingSetConflictException(current, "The drawing set has changed. Reload and try again.");
|
|
if (current.Archived == archived)
|
|
throw new DrawingSetConflictException(current,
|
|
archived ? "The drawing set is already archived." : "The drawing set is not archived.");
|
|
|
|
WriteSetRevision(id, current, current.Members, checked(current.Revision + 1), archived, transaction);
|
|
transaction.Commit();
|
|
return GetDrawingSet(id);
|
|
}
|
|
}
|
|
|
|
/// <summary>One page of revisions (without members), newest first, or null for an unknown id.</summary>
|
|
public DrawingSetPage? DrawingSetHistory(Guid id, int offset, int limit)
|
|
{
|
|
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
|
|
throw new ArgumentOutOfRangeException(nameof(limit));
|
|
lock (_sync)
|
|
{
|
|
if (GetDrawingSet(id) is null) return null;
|
|
using var count = _connection.CreateCommand();
|
|
count.CommandText = "SELECT COUNT(*) FROM drawing_set_revisions WHERE id=$id";
|
|
count.Parameters.AddWithValue("$id", id.ToString());
|
|
var total = checked((int)(long)count.ExecuteScalar()!);
|
|
using var command = _connection.CreateCommand();
|
|
command.CommandText = $"""
|
|
SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id
|
|
ORDER BY s.revision DESC LIMIT $limit OFFSET $offset
|
|
""";
|
|
command.Parameters.AddWithValue("$id", id.ToString());
|
|
command.Parameters.AddWithValue("$limit", limit);
|
|
command.Parameters.AddWithValue("$offset", offset);
|
|
return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sets, archived ones included, whose current revision contains the drawing, by
|
|
/// name; null for an unknown drawing.
|
|
/// </summary>
|
|
public DrawingSetPage? DrawingSetsContaining(Guid drawingId, int offset, int limit)
|
|
{
|
|
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
|
|
throw new ArgumentOutOfRangeException(nameof(limit));
|
|
const string where = """
|
|
WHERE EXISTS (SELECT 1 FROM drawing_set_members m
|
|
WHERE m.setId = s.id AND m.revision = s.revision AND m.drawingId = $drawing)
|
|
""";
|
|
lock (_sync)
|
|
{
|
|
if (GetDrawing(drawingId) is null) return null;
|
|
using var count = _connection.CreateCommand();
|
|
count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}";
|
|
count.Parameters.AddWithValue("$drawing", drawingId.ToString());
|
|
var total = checked((int)(long)count.ExecuteScalar()!);
|
|
using var command = _connection.CreateCommand();
|
|
command.CommandText = $"""
|
|
SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where}
|
|
ORDER BY s.name COLLATE NOCASE, s.id LIMIT $limit OFFSET $offset
|
|
""";
|
|
command.Parameters.AddWithValue("$drawing", drawingId.ToString());
|
|
command.Parameters.AddWithValue("$limit", limit);
|
|
command.Parameters.AddWithValue("$offset", offset);
|
|
return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
|
|
}
|
|
}
|
|
|
|
private static void ValidateSet(DrawingSetRecord record)
|
|
{
|
|
var error = DrawingSetRules.GetError(record);
|
|
if (error is not null)
|
|
throw new ArgumentException(error);
|
|
}
|
|
|
|
// Drawings are never deleted, so a member checked here stays valid; archived drawings are allowed.
|
|
private void RequireMemberDrawings(DrawingSetRecord record, SqliteTransaction transaction)
|
|
{
|
|
using var command = _connection.CreateCommand();
|
|
command.Transaction = transaction;
|
|
command.CommandText = "SELECT 1 FROM drawings WHERE id=$id";
|
|
var parameter = command.Parameters.Add("$id", SqliteType.Text);
|
|
foreach (var member in record.Members)
|
|
{
|
|
parameter.Value = member.DrawingId.ToString();
|
|
if (command.ExecuteScalar() is null)
|
|
throw new ArgumentException($"Drawing {member.DrawingId} is not in the library.");
|
|
}
|
|
}
|
|
|
|
// Members compare by drawing and quantity in order; the joined drawings are not part of the set.
|
|
private static bool SameSet(DrawingSetRecord current, DrawingSetRecord record) =>
|
|
current.Name == record.Name && current.Customer == record.Customer && current.Notes == record.Notes &&
|
|
current.Members.Select(m => (m.DrawingId, m.Quantity))
|
|
.SequenceEqual(record.Members.Select(m => (m.DrawingId, m.Quantity)));
|
|
|
|
// Called only under _sync with the caller's transaction; the current row, its revision
|
|
// snapshot and the revision's members are one write.
|
|
private void WriteSetRevision(Guid id, DrawingSetRecord record, IReadOnlyList<DrawingSetMember> members,
|
|
int revision, bool archived, SqliteTransaction transaction)
|
|
{
|
|
using var command = _connection.CreateCommand();
|
|
command.Transaction = transaction;
|
|
command.CommandText = $"""
|
|
INSERT OR REPLACE INTO drawing_sets ({SetStoredColumns})
|
|
VALUES ($id, $revision, $name, $customer, $notes, $archived, $savedAt, $savedBy, $nameKey, $customerKey)
|
|
""";
|
|
command.Parameters.AddWithValue("$id", id.ToString());
|
|
command.Parameters.AddWithValue("$revision", revision);
|
|
command.Parameters.AddWithValue("$name", record.Name);
|
|
command.Parameters.AddWithValue("$customer", record.Customer);
|
|
command.Parameters.AddWithValue("$notes", record.Notes);
|
|
command.Parameters.AddWithValue("$archived", archived ? 1 : 0);
|
|
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
|
|
command.Parameters.AddWithValue("$savedBy", record.SavedBy);
|
|
command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name));
|
|
command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer));
|
|
command.ExecuteNonQuery();
|
|
|
|
command.Parameters.Clear();
|
|
command.CommandText = $"INSERT INTO drawing_set_revisions ({SetStoredColumns}) SELECT {SetStoredColumns} FROM drawing_sets WHERE id=$id";
|
|
command.Parameters.AddWithValue("$id", id.ToString());
|
|
command.ExecuteNonQuery();
|
|
|
|
command.Parameters.Clear();
|
|
command.CommandText = """
|
|
INSERT INTO drawing_set_members (setId, revision, position, drawingId, quantity)
|
|
VALUES ($id, $revision, $position, $drawing, $quantity)
|
|
""";
|
|
command.Parameters.AddWithValue("$id", id.ToString());
|
|
command.Parameters.AddWithValue("$revision", revision);
|
|
var position = command.Parameters.Add("$position", SqliteType.Integer);
|
|
var drawing = command.Parameters.Add("$drawing", SqliteType.Text);
|
|
var quantity = command.Parameters.Add("$quantity", SqliteType.Integer);
|
|
for (var i = 0; i < members.Count; i++)
|
|
{
|
|
position.Value = i;
|
|
drawing.Value = members[i].DrawingId.ToString();
|
|
quantity.Value = members[i].Quantity;
|
|
command.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
// Called only under _sync. Reads one record row; the revision parameter is bound when given.
|
|
private DrawingSetRecord? ReadSet(string sql, Guid id, int? revision)
|
|
{
|
|
using var command = _connection.CreateCommand();
|
|
command.CommandText = sql;
|
|
command.Parameters.AddWithValue("$id", id.ToString());
|
|
if (revision is { } value)
|
|
command.Parameters.AddWithValue("$revision", value);
|
|
return ReadSets(command).FirstOrDefault();
|
|
}
|
|
|
|
private static List<DrawingSetRecord> ReadSets(SqliteCommand command)
|
|
{
|
|
var records = new List<DrawingSetRecord>();
|
|
using var reader = command.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
records.Add(new DrawingSetRecord
|
|
{
|
|
Id = Guid.Parse(reader.GetString(0)),
|
|
Revision = reader.GetInt32(1),
|
|
Name = reader.GetString(2),
|
|
Customer = reader.GetString(3),
|
|
Notes = reader.GetString(4),
|
|
Archived = reader.GetInt32(5) != 0,
|
|
SavedAt = DateTime.Parse(reader.GetString(6)),
|
|
SavedBy = reader.GetString(7),
|
|
MemberCount = reader.GetInt32(8),
|
|
});
|
|
}
|
|
return records;
|
|
}
|
|
|
|
// Called only under _sync. Members of the record's own revision, each joined to its drawing's current record.
|
|
private DrawingSetRecord WithMembers(DrawingSetRecord record)
|
|
{
|
|
using var command = _connection.CreateCommand();
|
|
command.CommandText = $"""
|
|
SELECT m.drawingId, m.quantity, {JoinedDrawingColumns}
|
|
FROM drawing_set_members m LEFT JOIN drawings d ON d.id = m.drawingId
|
|
WHERE m.setId=$id AND m.revision=$revision ORDER BY m.position
|
|
""";
|
|
command.Parameters.AddWithValue("$id", record.Id.ToString());
|
|
command.Parameters.AddWithValue("$revision", record.Revision);
|
|
var members = new List<DrawingSetMember>();
|
|
using var reader = command.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
members.Add(new DrawingSetMember
|
|
{
|
|
DrawingId = Guid.Parse(reader.GetString(0)),
|
|
Quantity = reader.GetInt32(1),
|
|
Drawing = reader.IsDBNull(2) ? null : ReadDrawing(reader, first: 2),
|
|
});
|
|
}
|
|
record.Members = members;
|
|
return record;
|
|
}
|
|
|
|
private static void AddSetFilterParameters(SqliteCommand command, DrawingSetQuery query)
|
|
{
|
|
AddSearchParameter(command, query.NormalizedSearch);
|
|
if (SharedListNames.Key(query.Customer).Length > 0)
|
|
command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer));
|
|
}
|
|
|
|
// Fixed allowlist: request text never reaches the ORDER BY clause.
|
|
private static string SetSortColumn(DrawingSetSortField field) => field switch
|
|
{
|
|
DrawingSetSortField.SavedAt => "s.savedAt",
|
|
DrawingSetSortField.Name => "s.name COLLATE NOCASE",
|
|
DrawingSetSortField.Customer => "s.customer COLLATE NOCASE",
|
|
DrawingSetSortField.Revision => "s.revision",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
|
|
};
|
|
}
|
|
|
|
/// <summary>A guarded drawing set write found a different current state; nothing was changed.</summary>
|
|
public sealed class DrawingSetConflictException(DrawingSetRecord current, string message) : Exception(message)
|
|
{
|
|
public DrawingSetRecord Current { get; } = current;
|
|
}
|