feat: add models and workspace repository layer
This commit is contained in:
1 parent
c2ee02c209
commit
941e22c7ff
9 files changed
+1143
No files matched your search
@@ -0,0 +1,77 @@
|
||||
using YKanBan.Models;
|
||||
|
||||
namespace YKanBan.Tests.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the RGB color value type: component storage and the text
|
||||
/// boundary in both directions.
|
||||
/// </summary>
|
||||
[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<FormatException>(() => 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for workspace CRUD, the three cascade chains, edit-confirm
|
||||
/// transaction atomicity and the timestamp refresh rules.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceRepositoryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SqliteException>(() =>
|
||||
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<SqliteException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the workspace session lifecycle: the exclusive lock is held for
|
||||
/// the whole session and the repository is usable through it.
|
||||
/// </summary>
|
||||
[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<WorkspaceLockException>(() => 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<WorkspaceNotInitializedException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A card with its assigned tags loaded.
|
||||
/// </summary>
|
||||
/// <param name="Id">Row id.</param>
|
||||
/// <param name="ColumnId">Owning column.</param>
|
||||
/// <param name="Title">Possibly empty; the empty string is the canonical "no title".</param>
|
||||
/// <param name="Content">Multiline body, possibly empty.</param>
|
||||
/// <param name="CreatedAt">Unix seconds of creation.</param>
|
||||
/// <param name="UpdatedAt">Unix seconds of the last confirmed edit write.</param>
|
||||
/// <param name="Tags">Tags assigned to this card, ordered by tag id.</param>
|
||||
public sealed record CardModel(
|
||||
long Id,
|
||||
long ColumnId,
|
||||
string Title,
|
||||
string Content,
|
||||
long CreatedAt,
|
||||
long UpdatedAt,
|
||||
IReadOnlyList<TagModel> Tags);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A board column as stored in the workspace database.
|
||||
/// </summary>
|
||||
/// <param name="Id">Row id.</param>
|
||||
/// <param name="Title">Non-empty, table-unique title.</param>
|
||||
/// <param name="Description">Free-form description, possibly empty.</param>
|
||||
/// <param name="CreatedAt">Unix seconds of creation.</param>
|
||||
/// <param name="UpdatedAt">Unix seconds of the last title/description change.</param>
|
||||
public sealed record ColumnModel(long Id, string Title, string Description, long CreatedAt, long UpdatedAt);
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 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; <see cref="ToString"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct RgbColor(byte R, byte G, byte B)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a "#RRGGBB" text with case-insensitive hex digits.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to parse.</param>
|
||||
/// <returns>The parsed color.</returns>
|
||||
/// <exception cref="FormatException">The text is not in #RRGGBB form.</exception>
|
||||
public static RgbColor Parse(string text) =>
|
||||
TryParse(text, out RgbColor color)
|
||||
? color
|
||||
: throw new FormatException($"Not a valid #RRGGBB color: '{text}'.");
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a "#RRGGBB" text. There is no whitespace tolerance.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to parse.</param>
|
||||
/// <param name="color">The parsed color on success, otherwise the default value.</param>
|
||||
/// <returns><see langword="true"/> when the text is exactly #RRGGBB.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical lowercase "#RRGGBB" form used by the database and exports.
|
||||
/// </summary>
|
||||
/// <returns>The lowercase #RRGGBB text.</returns>
|
||||
public override string ToString() => $"#{R:x2}{G:x2}{B:x2}";
|
||||
|
||||
/// <summary>
|
||||
/// Parses exactly two hex digits into a byte value.
|
||||
/// </summary>
|
||||
/// <param name="text">The source text.</param>
|
||||
/// <param name="start">Index of the first of the two digits.</param>
|
||||
/// <param name="value">The parsed byte value.</param>
|
||||
/// <returns><see langword="true"/> when both characters are hex digits.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts one hex digit to its value.
|
||||
/// </summary>
|
||||
/// <param name="character">The character to convert.</param>
|
||||
/// <returns>The digit value, or -1 for any other character.</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A tag assignable to cards. Tags are never garbage-collected: unreferenced
|
||||
/// tags stay until they are deleted explicitly.
|
||||
/// </summary>
|
||||
/// <param name="Id">Row id.</param>
|
||||
/// <param name="Name">Arbitrary non-empty text; no charset or length rules.</param>
|
||||
/// <param name="Color">The tag color; persisted as #RRGGBB text.</param>
|
||||
/// <param name="Description">Free-form description, possibly empty.</param>
|
||||
public sealed record TagModel(long Id, string Name, RgbColor Color, string Description);
|
||||
@@ -0,0 +1,534 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceRepository : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the repository over an initialized workspace database.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The workspace folder.</param>
|
||||
public WorkspaceRepository(string folderPath)
|
||||
{
|
||||
_connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the underlying connection.
|
||||
/// </summary>
|
||||
public void Dispose() => _connection.Dispose();
|
||||
|
||||
#region Columns
|
||||
|
||||
/// <summary>
|
||||
/// Returns all columns ordered by id ascending.
|
||||
/// </summary>
|
||||
/// <returns>All columns in id order.</returns>
|
||||
public IReadOnlyList<ColumnModel> 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<ColumnModel>();
|
||||
while (reader.Read())
|
||||
{
|
||||
columns.Add(ReadColumn(reader));
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the column with the given id.
|
||||
/// </summary>
|
||||
/// <param name="columnId">The column id.</param>
|
||||
/// <returns>The column, or <see langword="null"/> when it does not exist.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a column with created_at equal to updated_at.
|
||||
/// </summary>
|
||||
/// <param name="title">The non-empty, table-unique title.</param>
|
||||
/// <param name="description">The optional description.</param>
|
||||
/// <returns>The created column.</returns>
|
||||
/// <exception cref="SqliteException">The title is empty or not unique.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the title and description. A call with no effective change
|
||||
/// writes nothing and leaves the timestamp alone.
|
||||
/// </summary>
|
||||
/// <param name="columnId">The column to update.</param>
|
||||
/// <param name="title">The new non-empty, table-unique title.</param>
|
||||
/// <param name="description">The new description.</param>
|
||||
/// <returns>The updated column, or the unchanged one for a no-op.</returns>
|
||||
/// <exception cref="InvalidOperationException">The column does not exist.</exception>
|
||||
/// <exception cref="SqliteException">The new title is empty or not unique.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a column; the DDL cascade removes its cards and their tag links.
|
||||
/// </summary>
|
||||
/// <param name="columnId">The column to delete.</param>
|
||||
/// <returns><see langword="true"/> when a row was deleted.</returns>
|
||||
public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cards
|
||||
|
||||
/// <summary>
|
||||
/// Returns all cards with their assigned tags loaded, ordered by id ascending.
|
||||
/// </summary>
|
||||
/// <returns>All cards in id order.</returns>
|
||||
public IReadOnlyList<CardModel> 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<CardModel>();
|
||||
var tagsByCard = new Dictionary<long, List<TagModel>>();
|
||||
while (reader.Read())
|
||||
{
|
||||
long cardId = reader.GetInt64(0);
|
||||
if (!tagsByCard.TryGetValue(cardId, out List<TagModel>? tags))
|
||||
{
|
||||
tags = new List<TagModel>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the card with the given id, with its tags loaded.
|
||||
/// </summary>
|
||||
/// <param name="cardId">The card id.</param>
|
||||
/// <returns>The card, or <see langword="null"/> when it does not exist.</returns>
|
||||
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<TagModel>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a card with its initial tag assignment and created_at equal to updated_at.
|
||||
/// </summary>
|
||||
/// <param name="columnId">The owning column.</param>
|
||||
/// <param name="title">The card title, possibly empty.</param>
|
||||
/// <param name="content">The card content, possibly empty.</param>
|
||||
/// <param name="tagIds">The tags to assign initially.</param>
|
||||
/// <returns>The created card with tags loaded.</returns>
|
||||
/// <exception cref="SqliteException">The column or a tag id does not exist.</exception>
|
||||
public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection<long> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cardId">The card to update.</param>
|
||||
/// <param name="title">The new title.</param>
|
||||
/// <param name="content">The new content.</param>
|
||||
/// <param name="tagIds">The complete set of tags to assign.</param>
|
||||
/// <returns>The updated card with tags loaded, or the unchanged one for a no-op.</returns>
|
||||
/// <exception cref="InvalidOperationException">The card does not exist.</exception>
|
||||
/// <exception cref="SqliteException">A tag id does not exist; nothing is written.</exception>
|
||||
public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection<long> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cardId">The card to move.</param>
|
||||
/// <param name="targetColumnId">The destination column.</param>
|
||||
/// <returns><see langword="true"/> when the card actually moved.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a card; the DDL cascade removes its tag links.
|
||||
/// </summary>
|
||||
/// <param name="cardId">The card to delete.</param>
|
||||
/// <returns><see langword="true"/> when a row was deleted.</returns>
|
||||
public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tags
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tags ordered by id ascending; unreferenced tags are included.
|
||||
/// </summary>
|
||||
/// <returns>All tags in id order.</returns>
|
||||
public IReadOnlyList<TagModel> 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<TagModel>();
|
||||
while (reader.Read())
|
||||
{
|
||||
tags.Add(ReadTag(reader, 0));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns every tag together with the number of cards using it, ordered by tag id.
|
||||
/// </summary>
|
||||
/// <returns>The tag/usage pairs in tag id order.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tag.
|
||||
/// </summary>
|
||||
/// <param name="name">The non-empty, table-unique name.</param>
|
||||
/// <param name="color">The tag color.</param>
|
||||
/// <param name="description">The optional description.</param>
|
||||
/// <returns>The created tag.</returns>
|
||||
/// <exception cref="SqliteException">The name is empty or not unique.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites name, color and description. A call with no effective change writes nothing.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag to update.</param>
|
||||
/// <param name="name">The new non-empty, table-unique name.</param>
|
||||
/// <param name="color">The new color.</param>
|
||||
/// <param name="description">The new description.</param>
|
||||
/// <returns>The updated tag, or the unchanged one for a no-op.</returns>
|
||||
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
|
||||
/// <exception cref="SqliteException">The new name is empty or not unique.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tag; the DDL cascade removes its card links while the cards survive.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag to delete.</param>
|
||||
/// <returns><see langword="true"/> when a row was deleted.</returns>
|
||||
public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tag with the given id.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag id.</param>
|
||||
/// <returns>The tag, or <see langword="null"/> when it does not exist.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tags currently assigned to a card, ordered by tag id.
|
||||
/// </summary>
|
||||
/// <param name="cardId">The card id.</param>
|
||||
/// <returns>The assigned tags in id order.</returns>
|
||||
private IReadOnlyList<TagModel> 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<TagModel>();
|
||||
while (reader.Read())
|
||||
{
|
||||
tags.Add(ReadTag(reader, 0));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a columns row from the reader cursor.
|
||||
/// </summary>
|
||||
/// <param name="reader">The reader positioned on a columns row.</param>
|
||||
/// <returns>The column model.</returns>
|
||||
private static ColumnModel ReadColumn(SqliteDataReader reader) =>
|
||||
new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4));
|
||||
|
||||
/// <summary>
|
||||
/// Reads a tags row from the reader cursor, starting at the given column
|
||||
/// offset; the color text is converted to its components.
|
||||
/// </summary>
|
||||
/// <param name="reader">The reader positioned on a row containing a tag.</param>
|
||||
/// <param name="offset">Index of the tag's id column.</param>
|
||||
/// <returns>The tag model.</returns>
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a command bound to this connection.
|
||||
/// </summary>
|
||||
/// <param name="sql">The command text.</param>
|
||||
/// <param name="transaction">The transaction to enlist in, when any.</param>
|
||||
/// <returns>The prepared command.</returns>
|
||||
private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)
|
||||
{
|
||||
SqliteCommand command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a parameterized non-query statement inside a transaction.
|
||||
/// </summary>
|
||||
/// <param name="transaction">The transaction to enlist in.</param>
|
||||
/// <param name="sql">The statement to execute.</param>
|
||||
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a row inside a transaction and returns the generated rowid.
|
||||
/// </summary>
|
||||
/// <param name="transaction">The transaction to enlist in.</param>
|
||||
/// <param name="sql">The INSERT statement.</param>
|
||||
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
|
||||
/// <returns>The rowid assigned to the inserted row.</returns>
|
||||
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()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a row by id.
|
||||
/// </summary>
|
||||
/// <param name="sql">The DELETE statement with an <c>$id</c> parameter.</param>
|
||||
/// <param name="id">The id to delete.</param>
|
||||
/// <returns><see langword="true"/> when a row was deleted.</returns>
|
||||
private bool ExecuteDelete(string sql, long id)
|
||||
{
|
||||
using SqliteCommand command = CreateCommand(sql);
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
return command.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceSession : IDisposable
|
||||
{
|
||||
private readonly WorkspaceLock _workspaceLock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the session around an acquired lock and an open repository.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The canonical workspace folder path.</param>
|
||||
/// <param name="workspaceLock">The exclusive lock held while this session is open.</param>
|
||||
/// <param name="repository">The data access object for this workspace.</param>
|
||||
private WorkspaceSession(string folderPath, WorkspaceLock workspaceLock, WorkspaceRepository repository)
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
_workspaceLock = workspaceLock;
|
||||
Repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical workspace folder path.
|
||||
/// </summary>
|
||||
public string FolderPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the data access object for this workspace.
|
||||
/// </summary>
|
||||
public WorkspaceRepository Repository { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Opens a workspace: acquires its exclusive lock and opens its repository.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The workspace folder to open.</param>
|
||||
/// <returns>The opened session; dispose it to release the lock and connection.</returns>
|
||||
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
|
||||
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the repository connection, then the exclusive lock.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
_workspaceLock.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user