From 941e22c7ff7672e870cc8ebfa4fe9e2b60369f34 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Fri, 2 Oct 2026 22:12:38 +0800 Subject: [PATCH] feat: add models and workspace repository layer --- YKanBan.Tests/Models/RgbColorTests.cs | 77 +++ .../Workspace/WorkspaceRepositoryTests.cs | 276 +++++++++ .../Workspace/WorkspaceSessionTests.cs | 60 ++ YKanBan/Models/CardModel.cs | 20 + YKanBan/Models/ColumnModel.cs | 11 + YKanBan/Models/RgbColor.cs | 87 +++ YKanBan/Models/TagModel.cs | 11 + .../Storage/Workspace/WorkspaceRepository.cs | 534 ++++++++++++++++++ YKanBan/Storage/Workspace/WorkspaceSession.cs | 67 +++ 9 files changed, 1143 insertions(+) create mode 100644 YKanBan.Tests/Models/RgbColorTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs create mode 100644 YKanBan/Models/CardModel.cs create mode 100644 YKanBan/Models/ColumnModel.cs create mode 100644 YKanBan/Models/RgbColor.cs create mode 100644 YKanBan/Models/TagModel.cs create mode 100644 YKanBan/Storage/Workspace/WorkspaceRepository.cs create mode 100644 YKanBan/Storage/Workspace/WorkspaceSession.cs diff --git a/YKanBan.Tests/Models/RgbColorTests.cs b/YKanBan.Tests/Models/RgbColorTests.cs new file mode 100644 index 0000000..35db145 --- /dev/null +++ b/YKanBan.Tests/Models/RgbColorTests.cs @@ -0,0 +1,77 @@ +using YKanBan.Models; + +namespace YKanBan.Tests.Models; + +/// +/// Tests for the RGB color value type: component storage and the text +/// boundary in both directions. +/// +[TestClass] +public class RgbColorTests +{ + [TestMethod] + public void ParseSplitsComponents() + { + RgbColor color = RgbColor.Parse("#102030"); + Assert.AreEqual((byte)0x10, color.R); + Assert.AreEqual((byte)0x20, color.G); + Assert.AreEqual((byte)0x30, color.B); + } + + [TestMethod] + [DataRow("#a1B2c3")] + [DataRow("#A1B2C3")] + [DataRow("#a1b2c3")] + public void ParseIsCaseInsensitive(string text) + { + Assert.AreEqual(new RgbColor(0xA1, 0xB2, 0xC3), RgbColor.Parse(text)); + } + + [TestMethod] + [DataRow("")] + [DataRow("112233")] + [DataRow("#11223")] + [DataRow("#1122334")] + [DataRow("#GGGGGG")] + [DataRow("#12345g")] + [DataRow(" #112233")] + [DataRow("#112233 ")] + public void ParseRejectsMalformedText(string text) + { + Assert.ThrowsExactly(() => RgbColor.Parse(text)); + } + + [TestMethod] + public void TryParseRejectsNull() + { + Assert.IsFalse(RgbColor.TryParse(null, out RgbColor color)); + Assert.AreEqual(default, color); + } + + [TestMethod] + public void TryParseAcceptsValidText() + { + Assert.IsTrue(RgbColor.TryParse("#010203", out RgbColor color)); + Assert.AreEqual(new RgbColor(1, 2, 3), color); + } + + [TestMethod] + public void ToStringUsesCanonicalLowercase() + { + Assert.AreEqual("#ff0a00", new RgbColor(0xFF, 0x0A, 0x00).ToString()); + } + + [TestMethod] + public void TextRoundTrips() + { + var color = new RgbColor(0x12, 0xAB, 0xCD); + Assert.AreEqual(color, RgbColor.Parse(color.ToString())); + } + + [TestMethod] + public void EqualityComparesComponents() + { + Assert.AreEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 3)); + Assert.AreNotEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 4)); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs new file mode 100644 index 0000000..0771973 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -0,0 +1,276 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Models; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Tests for workspace CRUD, the three cascade chains, edit-confirm +/// transaction atomicity and the timestamp refresh rules. +/// +[TestClass] +public class WorkspaceRepositoryTests +{ + /// + /// Owns a workspace with fixed-timestamp fixture data (one column, one + /// card, two tags linked to the card) plus the repository under test. + /// Fixture timestamps are small literal values so that any real write, + /// which stamps the current Unix time, is observable. + /// + private sealed class RepositoryFixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public WorkspaceRepository Repository { get; } + + public long ColumnId { get; } = 1; + + public long CardId { get; } = 1; + + public long BugTagId { get; } = 1; + + public long UiTagId { get; } = 2; + + public RepositoryFixture() + { + WorkspaceInitializer.Initialize(Directory.FullPath); + + using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) + { + SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);"); + SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);"); + SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');"); + SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('ui', '#00ff00', '');"); + SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"); + SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 2);"); + } + + Repository = new WorkspaceRepository(Directory.FullPath); + } + + public void Dispose() + { + Repository.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void GetColumnsReturnsTheSeededColumn() + { + using var fixture = new RepositoryFixture(); + ColumnModel column = fixture.Repository.GetColumn(fixture.ColumnId)!; + Assert.AreEqual("Col", column.Title); + Assert.AreEqual(1, fixture.Repository.GetColumns().Count); + } + + [TestMethod] + public void AddColumnSetsEqualTimestamps() + { + using var fixture = new RepositoryFixture(); + ColumnModel column = fixture.Repository.AddColumn("New", "desc"); + Assert.IsTrue(column.CreatedAt > 0L); + Assert.AreEqual(column.CreatedAt, column.UpdatedAt); + } + + [TestMethod] + public void UpdateColumnWithoutChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + + ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", ""); + + // A no-op must not refresh the fixed fixture timestamp. + Assert.AreEqual(100L, result.UpdatedAt); + } + + [TestMethod] + public void UpdateColumnChangeRefreshesTimestamp() + { + using var fixture = new RepositoryFixture(); + + ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc"); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(100L, updated.CreatedAt); + } + + [TestMethod] + public void DeleteColumnCascadesToCardsAndTagLinks() + { + using var fixture = new RepositoryFixture(); + + Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId)); + + Assert.AreEqual(0, fixture.Repository.GetCards().Count); + // The tags themselves survive the column deletion. + Assert.AreEqual(2, fixture.Repository.GetTags().Count); + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;")); + } + + [TestMethod] + public void DeleteCardRemovesTagLinksButKeepsTags() + { + using var fixture = new RepositoryFixture(); + + Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId)); + + Assert.IsNull(fixture.Repository.GetCard(fixture.CardId)); + Assert.AreEqual(2, fixture.Repository.GetTags().Count); + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;")); + } + + [TestMethod] + public void DeleteTagDetachesFromCardsButKeepsCards() + { + using var fixture = new RepositoryFixture(); + + Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId)); + + Assert.AreEqual(1, fixture.Repository.GetTags().Count); + CardModel card = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual(1, card.Tags.Count); + Assert.AreEqual("ui", card.Tags[0].Name); + } + + [TestMethod] + public void UpdateCardWithoutEffectiveChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + + CardModel result = fixture.Repository.UpdateCard( + fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]); + + Assert.AreEqual(100L, result.UpdatedAt); + } + + [TestMethod] + public void UpdateCardTitleChangeRefreshesTimestamp() + { + using var fixture = new RepositoryFixture(); + + CardModel updated = fixture.Repository.UpdateCard( + fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(100L, updated.CreatedAt); + } + + [TestMethod] + public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp() + { + using var fixture = new RepositoryFixture(); + + CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(1, updated.Tags.Count); + } + + [TestMethod] + public void UpdateCardWithUnknownTagRollsBackAtomically() + { + using var fixture = new RepositoryFixture(); + + // Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back. + Assert.ThrowsExactly(() => + fixture.Repository.UpdateCard(fixture.CardId, "TX", "CX", [fixture.BugTagId, 999])); + + CardModel after = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual("T", after.Title); + Assert.AreEqual("C", after.Content); + Assert.AreEqual(100L, after.UpdatedAt); + Assert.AreEqual(2, after.Tags.Count); + } + + [TestMethod] + public void MoveCardUpdatesColumnWithoutRefreshingTimestamp() + { + using var fixture = new RepositoryFixture(); + long target = fixture.Repository.AddColumn("Target", "").Id; + + Assert.IsTrue(fixture.Repository.MoveCard(fixture.CardId, target)); + + CardModel moved = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual(target, moved.ColumnId); + // The move is a write but deliberately leaves the card's updated_at alone. + Assert.AreEqual(100L, moved.UpdatedAt); + } + + [TestMethod] + public void MoveCardToCurrentColumnIsNoOp() + { + using var fixture = new RepositoryFixture(); + + Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId)); + + Assert.AreEqual(100L, fixture.Repository.GetCard(fixture.CardId)!.UpdatedAt); + } + + [TestMethod] + public void AddCardCreatesWithEqualTimestampsAndLinkedTags() + { + using var fixture = new RepositoryFixture(); + + CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]); + + Assert.IsTrue(card.CreatedAt > 0L); + Assert.AreEqual(card.CreatedAt, card.UpdatedAt); + Assert.AreEqual(1, card.Tags.Count); + Assert.AreEqual("ui", card.Tags[0].Name); + } + + [TestMethod] + public void UpdateTagWithoutChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + + TagModel result = fixture.Repository.UpdateTag( + fixture.BugTagId, "bug", RgbColor.Parse("#ff0000"), ""); + + Assert.AreEqual("bug", result.Name); + Assert.AreEqual(new RgbColor(0xFF, 0x00, 0x00), result.Color); + } + + [TestMethod] + public void UpdateTagChangeWrites() + { + using var fixture = new RepositoryFixture(); + + TagModel updated = fixture.Repository.UpdateTag( + fixture.BugTagId, "defect", RgbColor.Parse("#ee0a0a"), "desc"); + + Assert.AreEqual("defect", updated.Name); + Assert.AreEqual(new RgbColor(0xEE, 0x0A, 0x0A), updated.Color); + Assert.AreEqual("desc", updated.Description); + Assert.AreEqual("defect", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.BugTagId).Name); + } + + [TestMethod] + public void AddTagWithDuplicateNameIsRejected() + { + using var fixture = new RepositoryFixture(); + Assert.ThrowsExactly(() => + fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), "")); + } + + [TestMethod] + public void GetTagsWithUsageCountsAssignments() + { + using var fixture = new RepositoryFixture(); + + IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage(); + Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "bug").UsageCount); + Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "ui").UsageCount); + + // Detaching one tag via an edit confirmation drops its usage to zero but keeps the tag. + fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.UiTagId]); + + IReadOnlyList<(TagModel Tag, long UsageCount)> after = fixture.Repository.GetTagsWithUsage(); + Assert.AreEqual(0L, after.Single(entry => entry.Tag.Name == "bug").UsageCount); + Assert.AreEqual(1L, after.Single(entry => entry.Tag.Name == "ui").UsageCount); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs new file mode 100644 index 0000000..7bade97 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs @@ -0,0 +1,60 @@ +using YKanBan.Models; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Tests for the workspace session lifecycle: the exclusive lock is held for +/// the whole session and the repository is usable through it. +/// +[TestClass] +public class WorkspaceSessionTests +{ + [TestMethod] + public void OpenHoldsLockUntilDisposed() + { + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + + using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath); + + // A second open while the session is alive must fail on the lock. + Assert.ThrowsExactly(() => WorkspaceSession.Open(directory.FullPath)); + } + + [TestMethod] + public void DisposeReleasesLockForReopen() + { + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + + WorkspaceSession.Open(directory.FullPath).Dispose(); + + using WorkspaceSession reopened = WorkspaceSession.Open(directory.FullPath); + Assert.AreEqual(directory.FullPath, reopened.FolderPath); + } + + [TestMethod] + public void OpenUninitializedFolderThrowsNotInitialized() + { + using var directory = new TempDirectory(); + + Assert.ThrowsExactly( + () => WorkspaceSession.Open(directory.FullPath)); + } + + [TestMethod] + public void RepositoryIsUsableThroughSession() + { + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + + using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath); + TagModel tag = session.Repository.AddTag("bug", RgbColor.Parse("#112233"), ""); + + Assert.AreEqual("bug", session.Repository.GetTags().Single().Name); + Assert.AreEqual(tag.Id, session.Repository.GetTags().Single().Id); + } +} diff --git a/YKanBan/Models/CardModel.cs b/YKanBan/Models/CardModel.cs new file mode 100644 index 0000000..d976b6b --- /dev/null +++ b/YKanBan/Models/CardModel.cs @@ -0,0 +1,20 @@ +namespace YKanBan.Models; + +/// +/// A card with its assigned tags loaded. +/// +/// Row id. +/// Owning column. +/// Possibly empty; the empty string is the canonical "no title". +/// Multiline body, possibly empty. +/// Unix seconds of creation. +/// Unix seconds of the last confirmed edit write. +/// Tags assigned to this card, ordered by tag id. +public sealed record CardModel( + long Id, + long ColumnId, + string Title, + string Content, + long CreatedAt, + long UpdatedAt, + IReadOnlyList Tags); diff --git a/YKanBan/Models/ColumnModel.cs b/YKanBan/Models/ColumnModel.cs new file mode 100644 index 0000000..ba2fa74 --- /dev/null +++ b/YKanBan/Models/ColumnModel.cs @@ -0,0 +1,11 @@ +namespace YKanBan.Models; + +/// +/// A board column as stored in the workspace database. +/// +/// Row id. +/// Non-empty, table-unique title. +/// Free-form description, possibly empty. +/// Unix seconds of creation. +/// Unix seconds of the last title/description change. +public sealed record ColumnModel(long Id, string Title, string Description, long CreatedAt, long UpdatedAt); diff --git a/YKanBan/Models/RgbColor.cs b/YKanBan/Models/RgbColor.cs new file mode 100644 index 0000000..b070fc3 --- /dev/null +++ b/YKanBan/Models/RgbColor.cs @@ -0,0 +1,87 @@ +namespace YKanBan.Models; + +/// +/// An RGB color without an alpha channel, stored as one byte per component — +/// mirroring the color structures of WinForms, WPF and Avalonia minus the +/// alpha byte. The type is total: every combination of components is valid, +/// and all validation happens at the text boundary. +/// +/// It converts to and from the persisted "#RRGGBB" text form; +/// always produces the canonical lowercase form (for example "#ff0a00"). It is +/// not specific to tags and may be reused anywhere a color value is needed. +/// +public readonly record struct RgbColor(byte R, byte G, byte B) +{ + /// + /// Parses a "#RRGGBB" text with case-insensitive hex digits. + /// + /// The text to parse. + /// The parsed color. + /// The text is not in #RRGGBB form. + public static RgbColor Parse(string text) => + TryParse(text, out RgbColor color) + ? color + : throw new FormatException($"Not a valid #RRGGBB color: '{text}'."); + + /// + /// Attempts to parse a "#RRGGBB" text. There is no whitespace tolerance. + /// + /// The text to parse. + /// The parsed color on success, otherwise the default value. + /// when the text is exactly #RRGGBB. + public static bool TryParse(string? text, out RgbColor color) + { + color = default; + if (text is not { Length: 7 } || text[0] != '#') + { + return false; + } + + // Two hex digits per component: R, G, B. + if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b)) + { + color = new RgbColor(r, g, b); + return true; + } + return false; + } + + /// + /// Returns the canonical lowercase "#RRGGBB" form used by the database and exports. + /// + /// The lowercase #RRGGBB text. + public override string ToString() => $"#{R:x2}{G:x2}{B:x2}"; + + /// + /// Parses exactly two hex digits into a byte value. + /// + /// The source text. + /// Index of the first of the two digits. + /// The parsed byte value. + /// when both characters are hex digits. + private static bool TryHexPair(string text, int start, out byte value) + { + value = 0; + int high = HexDigit(text[start]); + int low = HexDigit(text[start + 1]); + if (high < 0 || low < 0) + { + return false; + } + value = (byte)((high << 4) | low); + return true; + } + + /// + /// Converts one hex digit to its value. + /// + /// The character to convert. + /// The digit value, or -1 for any other character. + private static int HexDigit(char character) => character switch + { + >= '0' and <= '9' => character - '0', + >= 'a' and <= 'f' => character - 'a' + 10, + >= 'A' and <= 'F' => character - 'A' + 10, + _ => -1, + }; +} diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs new file mode 100644 index 0000000..d86a3d8 --- /dev/null +++ b/YKanBan/Models/TagModel.cs @@ -0,0 +1,11 @@ +namespace YKanBan.Models; + +/// +/// A tag assignable to cards. Tags are never garbage-collected: unreferenced +/// tags stay until they are deleted explicitly. +/// +/// Row id. +/// Arbitrary non-empty text; no charset or length rules. +/// The tag color; persisted as #RRGGBB text. +/// Free-form description, possibly empty. +public sealed record TagModel(long Id, string Name, RgbColor Color, string Description); diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs new file mode 100644 index 0000000..479296c --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -0,0 +1,534 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Models; + +namespace YKanBan.Storage.Workspace; + +/// +/// Data access for one open workspace database: CRUD for columns, cards and +/// tags, card moves and tag assignment. Cascading deletes are left to the DDL. +/// +/// Timestamp rules: +/// - cards.updated_at refreshes only when a card edit is confirmed with +/// changes; moving a card to another column never refreshes it; +/// - columns.updated_at refreshes only when title or description changes; +/// - a confirmation with no effective change performs no write at all. +/// +public sealed class WorkspaceRepository : IDisposable +{ + private readonly SqliteConnection _connection; + + /// + /// Opens the repository over an initialized workspace database. + /// + /// The workspace folder. + public WorkspaceRepository(string folderPath) + { + _connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); + } + + /// + /// Closes the underlying connection. + /// + public void Dispose() => _connection.Dispose(); + + #region Columns + + /// + /// Returns all columns ordered by id ascending. + /// + /// All columns in id order. + public IReadOnlyList GetColumns() + { + using var command = CreateCommand( + "SELECT id, title, description, created_at, updated_at FROM columns ORDER BY id ASC;"); + using var reader = command.ExecuteReader(); + + var columns = new List(); + while (reader.Read()) + { + columns.Add(ReadColumn(reader)); + } + return columns; + } + + /// + /// Returns the column with the given id. + /// + /// The column id. + /// The column, or when it does not exist. + public ColumnModel? GetColumn(long columnId) + { + using var command = CreateCommand( + "SELECT id, title, description, created_at, updated_at FROM columns WHERE id = $id;"); + command.Parameters.AddWithValue("$id", columnId); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadColumn(reader) : null; + } + + /// + /// Creates a column with created_at equal to updated_at. + /// + /// The non-empty, table-unique title. + /// The optional description. + /// The created column. + /// The title is empty or not unique. + public ColumnModel AddColumn(string title, string description) + { + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);", + ("$title", title), ("$description", description), ("$now", now)); + transaction.Commit(); + + return new ColumnModel(id, title, description, now, now); + } + + /// + /// Rewrites the title and description. A call with no effective change + /// writes nothing and leaves the timestamp alone. + /// + /// The column to update. + /// The new non-empty, table-unique title. + /// The new description. + /// The updated column, or the unchanged one for a no-op. + /// The column does not exist. + /// The new title is empty or not unique. + public ColumnModel UpdateColumn(long columnId, string title, string description) + { + ColumnModel existing = GetColumn(columnId) + ?? throw new InvalidOperationException($"Column {columnId} does not exist."); + + // Unchanged title and description: no write, no timestamp refresh. + if (existing.Title == title && existing.Description == description) + { + return existing; + } + + long now = UnixTime.Now; + using var command = CreateCommand( + "UPDATE columns SET title = $title, description = $description, updated_at = $now WHERE id = $id;"); + command.Parameters.AddWithValue("$title", title); + command.Parameters.AddWithValue("$description", description); + command.Parameters.AddWithValue("$now", now); + command.Parameters.AddWithValue("$id", columnId); + command.ExecuteNonQuery(); + + return new ColumnModel(columnId, title, description, existing.CreatedAt, now); + } + + /// + /// Deletes a column; the DDL cascade removes its cards and their tag links. + /// + /// The column to delete. + /// when a row was deleted. + public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId); + + #endregion + + #region Cards + + /// + /// Returns all cards with their assigned tags loaded, ordered by id ascending. + /// + /// All cards in id order. + public IReadOnlyList GetCards() + { + using var command = CreateCommand(""" + SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, + t.id, t.name, t.color, t.description + FROM cards c + LEFT JOIN card_tags ct ON ct.card_id = c.id + LEFT JOIN tags t ON t.id = ct.tag_id + ORDER BY c.id ASC; + """); + using var reader = command.ExecuteReader(); + + // Single grouped pass: one output card per distinct card id, with its tags accumulated alongside. + var cards = new List(); + var tagsByCard = new Dictionary>(); + while (reader.Read()) + { + long cardId = reader.GetInt64(0); + if (!tagsByCard.TryGetValue(cardId, out List? tags)) + { + tags = new List(); + tagsByCard[cardId] = tags; + cards.Add(new CardModel( + cardId, + reader.GetInt64(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetInt64(5), + tags)); + } + + if (!reader.IsDBNull(6)) + { + tags.Add(ReadTag(reader, 6)); + } + } + return cards; + } + + /// + /// Returns the card with the given id, with its tags loaded. + /// + /// The card id. + /// The card, or when it does not exist. + public CardModel? GetCard(long cardId) + { + using var command = CreateCommand(""" + SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, + t.id, t.name, t.color, t.description + FROM cards c + LEFT JOIN card_tags ct ON ct.card_id = c.id + LEFT JOIN tags t ON t.id = ct.tag_id + WHERE c.id = $id + ORDER BY c.id ASC; + """); + command.Parameters.AddWithValue("$id", cardId); + using var reader = command.ExecuteReader(); + + // The LEFT JOIN may repeat the card row once per tag; build it on the first row only. + CardModel? card = null; + var tags = new List(); + while (reader.Read()) + { + card ??= new CardModel( + reader.GetInt64(0), + reader.GetInt64(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetInt64(5), + tags); + + if (!reader.IsDBNull(6)) + { + tags.Add(ReadTag(reader, 6)); + } + } + return card; + } + + /// + /// Creates a card with its initial tag assignment and created_at equal to updated_at. + /// + /// The owning column. + /// The card title, possibly empty. + /// The card content, possibly empty. + /// The tags to assign initially. + /// The created card with tags loaded. + /// The column or a tag id does not exist. + public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds) + { + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES ($columnId, $title, $content, $now, $now);", + ("$columnId", columnId), ("$title", title), ("$content", content), ("$now", now)); + foreach (long tagId in tagIds.Distinct()) + { + ExecuteNonQuery(transaction, + "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", + ("$cardId", id), ("$tagId", tagId)); + } + transaction.Commit(); + + return new CardModel(id, columnId, title, content, now, now, LoadTags(id)); + } + + /// + /// Applies one card edit confirmation as a single atomic unit: the card row + /// (title, content, refreshed updated_at) plus the complete tag assignment. + /// A confirmation with no effective change writes nothing; a failure + /// mid-way rolls the whole edit back. + /// + /// The card to update. + /// The new title. + /// The new content. + /// The complete set of tags to assign. + /// The updated card with tags loaded, or the unchanged one for a no-op. + /// The card does not exist. + /// A tag id does not exist; nothing is written. + public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds) + { + CardModel existing = GetCard(cardId) + ?? throw new InvalidOperationException($"Card {cardId} does not exist."); + + // No effective change in title, content or tag set: no write, no timestamp refresh. + bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id)); + if (existing.Title == title && existing.Content == content && !tagsChanged) + { + return existing; + } + + // The confirmed write refreshes updated_at even when only the tag set changed. + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + ExecuteNonQuery(transaction, + "UPDATE cards SET title = $title, content = $content, updated_at = $now WHERE id = $id;", + ("$title", title), ("$content", content), ("$now", now), ("$id", cardId)); + + // Tag assignment is rewritten wholesale; card_tags carries nothing beyond the pair. + ExecuteNonQuery(transaction, "DELETE FROM card_tags WHERE card_id = $id;", ("$id", cardId)); + foreach (long tagId in tagIds.Distinct()) + { + ExecuteNonQuery(transaction, + "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", + ("$cardId", cardId), ("$tagId", tagId)); + } + transaction.Commit(); + + return new CardModel(cardId, existing.ColumnId, title, content, existing.CreatedAt, now, LoadTags(cardId)); + } + + /// + /// Moves a card to another column. This writes to the database but + /// deliberately does NOT refresh the card's updated_at; moving to the + /// current column is a no-op. + /// + /// The card to move. + /// The destination column. + /// when the card actually moved. + public bool MoveCard(long cardId, long targetColumnId) + { + using var command = CreateCommand( + "UPDATE cards SET column_id = $columnId WHERE id = $id AND column_id <> $columnId;"); + command.Parameters.AddWithValue("$columnId", targetColumnId); + command.Parameters.AddWithValue("$id", cardId); + return command.ExecuteNonQuery() > 0; + } + + /// + /// Deletes a card; the DDL cascade removes its tag links. + /// + /// The card to delete. + /// when a row was deleted. + public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId); + + #endregion + + #region Tags + + /// + /// Returns all tags ordered by id ascending; unreferenced tags are included. + /// + /// All tags in id order. + public IReadOnlyList GetTags() + { + using var command = CreateCommand( + "SELECT id, name, color, description FROM tags ORDER BY id ASC;"); + using var reader = command.ExecuteReader(); + + var tags = new List(); + while (reader.Read()) + { + tags.Add(ReadTag(reader, 0)); + } + return tags; + } + + /// + /// Returns every tag together with the number of cards using it, ordered by tag id. + /// + /// The tag/usage pairs in tag id order. + public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage() + { + using var command = CreateCommand(""" + SELECT t.id, t.name, t.color, t.description, COUNT(ct.card_id) + FROM tags t + LEFT JOIN card_tags ct ON ct.tag_id = t.id + GROUP BY t.id, t.name, t.color, t.description + ORDER BY t.id ASC; + """); + using var reader = command.ExecuteReader(); + + var tags = new List<(TagModel Tag, long UsageCount)>(); + while (reader.Read()) + { + tags.Add((ReadTag(reader, 0), reader.GetInt64(4))); + } + return tags; + } + + /// + /// Creates a tag. + /// + /// The non-empty, table-unique name. + /// The tag color. + /// The optional description. + /// The created tag. + /// The name is empty or not unique. + public TagModel AddTag(string name, RgbColor color, string description) + { + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);", + ("$name", name), ("$color", color.ToString()), ("$description", description)); + transaction.Commit(); + + return new TagModel(id, name, color, description); + } + + /// + /// Rewrites name, color and description. A call with no effective change writes nothing. + /// + /// The tag to update. + /// The new non-empty, table-unique name. + /// The new color. + /// The new description. + /// The updated tag, or the unchanged one for a no-op. + /// The tag does not exist. + /// The new name is empty or not unique. + public TagModel UpdateTag(long tagId, string name, RgbColor color, string description) + { + TagModel existing = GetTag(tagId) + ?? throw new InvalidOperationException($"Tag {tagId} does not exist."); + + // Unchanged name, color and description: no write. + if (existing.Name == name && existing.Color == color && existing.Description == description) + { + return existing; + } + + using var command = CreateCommand( + "UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;"); + command.Parameters.AddWithValue("$name", name); + command.Parameters.AddWithValue("$color", color.ToString()); + command.Parameters.AddWithValue("$description", description); + command.Parameters.AddWithValue("$id", tagId); + command.ExecuteNonQuery(); + + return new TagModel(tagId, name, color, description); + } + + /// + /// Deletes a tag; the DDL cascade removes its card links while the cards survive. + /// + /// The tag to delete. + /// when a row was deleted. + public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId); + + #endregion + + #region Helpers + + /// + /// Returns the tag with the given id. + /// + /// The tag id. + /// The tag, or when it does not exist. + private TagModel? GetTag(long tagId) + { + using var command = CreateCommand( + "SELECT id, name, color, description FROM tags WHERE id = $id;"); + command.Parameters.AddWithValue("$id", tagId); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadTag(reader, 0) : null; + } + + /// + /// Returns the tags currently assigned to a card, ordered by tag id. + /// + /// The card id. + /// The assigned tags in id order. + private IReadOnlyList LoadTags(long cardId) + { + using var command = CreateCommand(""" + SELECT t.id, t.name, t.color, t.description + FROM tags t + JOIN card_tags ct ON ct.tag_id = t.id + WHERE ct.card_id = $cardId + ORDER BY t.id ASC; + """); + command.Parameters.AddWithValue("$cardId", cardId); + using var reader = command.ExecuteReader(); + + var tags = new List(); + while (reader.Read()) + { + tags.Add(ReadTag(reader, 0)); + } + return tags; + } + + /// + /// Reads a columns row from the reader cursor. + /// + /// The reader positioned on a columns row. + /// The column model. + private static ColumnModel ReadColumn(SqliteDataReader reader) => + new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4)); + + /// + /// Reads a tags row from the reader cursor, starting at the given column + /// offset; the color text is converted to its components. + /// + /// The reader positioned on a row containing a tag. + /// Index of the tag's id column. + /// The tag model. + private static TagModel ReadTag(SqliteDataReader reader, int offset) => + new(reader.GetInt64(offset), reader.GetString(offset + 1), RgbColor.Parse(reader.GetString(offset + 2)), reader.GetString(offset + 3)); + + /// + /// Creates a command bound to this connection. + /// + /// The command text. + /// The transaction to enlist in, when any. + /// The prepared command. + private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null) + { + SqliteCommand command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + return command; + } + + /// + /// Executes a parameterized non-query statement inside a transaction. + /// + /// The transaction to enlist in. + /// The statement to execute. + /// Name/value pairs bound as SQL parameters. + private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) + { + using SqliteCommand command = CreateCommand(sql, transaction); + foreach ((string name, object? value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + command.ExecuteNonQuery(); + } + + /// + /// Inserts a row inside a transaction and returns the generated rowid. + /// + /// The transaction to enlist in. + /// The INSERT statement. + /// Name/value pairs bound as SQL parameters. + /// The rowid assigned to the inserted row. + private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) + { + ExecuteNonQuery(transaction, sql, parameters); + using SqliteCommand rowId = CreateCommand("SELECT last_insert_rowid();", transaction); + return (long)rowId.ExecuteScalar()!; + } + + /// + /// Deletes a row by id. + /// + /// The DELETE statement with an $id parameter. + /// The id to delete. + /// when a row was deleted. + private bool ExecuteDelete(string sql, long id) + { + using SqliteCommand command = CreateCommand(sql); + command.Parameters.AddWithValue("$id", id); + return command.ExecuteNonQuery() > 0; + } + + #endregion +} diff --git a/YKanBan/Storage/Workspace/WorkspaceSession.cs b/YKanBan/Storage/Workspace/WorkspaceSession.cs new file mode 100644 index 0000000..4d11333 --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceSession.cs @@ -0,0 +1,67 @@ +namespace YKanBan.Storage.Workspace; + +/// +/// One open workspace for the process lifetime: the exclusive lock plus the +/// single repository connection. Opening acquires the lock first, so a folder +/// without .ykanban, or one already locked by another instance, fails before +/// any database work happens. Disposing releases the repository and then the +/// lock. +/// +public sealed class WorkspaceSession : IDisposable +{ + private readonly WorkspaceLock _workspaceLock; + + /// + /// Initializes the session around an acquired lock and an open repository. + /// + /// The canonical workspace folder path. + /// The exclusive lock held while this session is open. + /// The data access object for this workspace. + private WorkspaceSession(string folderPath, WorkspaceLock workspaceLock, WorkspaceRepository repository) + { + FolderPath = folderPath; + _workspaceLock = workspaceLock; + Repository = repository; + } + + /// + /// Gets the canonical workspace folder path. + /// + public string FolderPath { get; } + + /// + /// Gets the data access object for this workspace. + /// + public WorkspaceRepository Repository { get; } + + /// + /// Opens a workspace: acquires its exclusive lock and opens its repository. + /// + /// The workspace folder to open. + /// The opened session; dispose it to release the lock and connection. + /// The folder has no .ykanban structure. + /// Another instance already holds the lock. + public static WorkspaceSession Open(string folderPath) + { + WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath); + try + { + var repository = new WorkspaceRepository(folderPath); + return new WorkspaceSession(folderPath, workspaceLock, repository); + } + catch + { + workspaceLock.Dispose(); + throw; + } + } + + /// + /// Releases the repository connection, then the exclusive lock. + /// + public void Dispose() + { + Repository.Dispose(); + _workspaceLock.Dispose(); + } +}