feat(m2): repository layer
- Data models: ColumnModel, CardModel (tags loaded), TagModel - PathComparer: platform-parameterized path normalization (case rule injected, trailing separator trimmed, roots kept) - WorkspaceRepository: column/card/tag CRUD, card moves, tag assignment; single-transaction edit confirmation with atomic rollback; no-change confirmations write nothing; move refreshes last_edited_at but not card updated_at; write-through to app.db after every successful data write - WorkspaceConnectionPool: fixed-capacity LRU session pool (lock + repository); eviction releases resources only; typed folder state errors; sidebar registry registration on open - 35 new unit tests: cascade chains, atomicity, timestamp rules, LRU behavior, lock conflicts, path equivalence
This commit is contained in:
1 parent
31fc58d9c7
commit
01367edaa9
10 files changed
+1308
No files matched your search
@@ -0,0 +1,93 @@
|
||||
using YKanBan.Platform;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Platform;
|
||||
|
||||
/// <summary>Tests for path normalization and platform-parameterized comparison.</summary>
|
||||
[TestClass]
|
||||
public class PathComparerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void OrdinalComparerTreatsCaseAsSignificant()
|
||||
{
|
||||
// Unix-style rule, injected so it is testable on any host.
|
||||
var comparer = new PathComparer(ignoreCase: false);
|
||||
Assert.IsFalse(comparer.Equals(@"C:\A", @"C:\a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IgnoreCaseComparerTreatsCaseAsInsignificant()
|
||||
{
|
||||
// Windows-style rule.
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
Assert.IsTrue(comparer.Equals(@"C:\A", @"c:\a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EqualPathsProduceEqualHashCodes()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
Assert.AreEqual(
|
||||
comparer.GetHashCode(@"C:\Users\Data\MyRepo"),
|
||||
comparer.GetHashCode(@"c:\users\data\myrepo"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RelativeSegmentsCollapse()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string viaParent = Path.Combine(directory.FullPath, "x", "..", "a");
|
||||
string direct = Path.Combine(directory.FullPath, "a");
|
||||
|
||||
Assert.IsTrue(comparer.Equals(viaParent, direct));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SeparatorStylesUnify()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(@"C:\a/b\c", @"C:\a\b\c"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TrailingSeparatorIsTrimmedForNonRoots()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string withTrailing = directory.FullPath + Path.DirectorySeparatorChar;
|
||||
Assert.IsTrue(comparer.Equals(withTrailing, directory.FullPath));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NormalizeReturnsCanonicalAbsoluteForm()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: false);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string withRelativeSegment = Path.Combine(directory.FullPath, "sub", "..");
|
||||
Assert.AreEqual(directory.FullPath, comparer.Normalize(withRelativeSegment));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RootPathKeepsItsTrailingSeparator()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
|
||||
Assert.AreEqual(@"C:\", comparer.Normalize(@"C:\"));
|
||||
Assert.AreEqual(@"C:\x", comparer.Normalize(@"C:\x\"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ForCurrentPlatformMatchesHostRule()
|
||||
{
|
||||
PathComparer comparer = PathComparer.ForCurrentPlatform();
|
||||
|
||||
Assert.AreEqual(
|
||||
OperatingSystem.IsWindows(),
|
||||
comparer.Equals(@"C:\Same", @"c:\same"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using YKanBan.Platform;
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Storage.Workspace;
|
||||
|
||||
/// <summary>Tests for the fixed-capacity LRU workspace session pool.</summary>
|
||||
[TestClass]
|
||||
public class WorkspaceConnectionPoolTests
|
||||
{
|
||||
/// <summary>Owns three initialized workspaces (a, b, c), a registry and the pool under test.</summary>
|
||||
private sealed class PoolFixture : IDisposable
|
||||
{
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public AppDatabase AppDb { get; }
|
||||
|
||||
public WorkspaceConnectionPool Pool { get; }
|
||||
|
||||
public PoolFixture(int capacity = 2)
|
||||
{
|
||||
foreach (string name in new[] { "a", "b", "c" })
|
||||
{
|
||||
string path = Path.Combine(Directory.FullPath, name);
|
||||
System.IO.Directory.CreateDirectory(path);
|
||||
WorkspaceInitializer.Initialize(path);
|
||||
}
|
||||
AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"));
|
||||
Pool = new WorkspaceConnectionPool(capacity, AppDb, new PathComparer(ignoreCase: true));
|
||||
}
|
||||
|
||||
/// <summary>Workspace folder path by name.</summary>
|
||||
public string Workspace(string name) => Path.Combine(Directory.FullPath, name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Pool.Dispose();
|
||||
AppDb.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenBeyondCapacityEvictsLeastRecentlyUsed()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
Assert.AreEqual(2, fixture.Pool.OpenCount);
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("c")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenTwiceReturnsSameSession()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
WorkspaceSession first = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
WorkspaceSession second = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
|
||||
Assert.AreSame(first, second);
|
||||
Assert.AreEqual(1, fixture.Pool.OpenCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TouchPromotesRecency()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Touch(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
// "a" was touched after "b" opened, so "b" is the LRU victim now.
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EvictedWorkspaceCanBeReopened()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
// "a" was evicted; reopening it works and now evicts the LRU "b".
|
||||
WorkspaceSession reopened = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.AreEqual("a", Path.GetFileName(reopened.FolderPath));
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenRegistersWorkspaceInSidebarRegistry()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
|
||||
IReadOnlyList<string> registered = fixture.AppDb.GetWorkspaces().Select(record => record.Path).ToList();
|
||||
Assert.AreEqual(2, registered.Count);
|
||||
Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("a"))));
|
||||
Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("b"))));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenMissingDirectoryThrows()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string missing = Path.Combine(fixture.Directory.FullPath, "nowhere");
|
||||
|
||||
Assert.ThrowsExactly<WorkspaceDirectoryMissingException>(() => fixture.Pool.Open(missing));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenFolderWithoutYkanbanThrows()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string plain = Path.Combine(fixture.Directory.FullPath, "plain");
|
||||
System.IO.Directory.CreateDirectory(plain);
|
||||
|
||||
Assert.ThrowsExactly<WorkspaceNotInitializedException>(() => fixture.Pool.Open(plain));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenWithLockHeldElsewhereThrowsThenSucceedsAfterRelease()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
using WorkspaceLock holder = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
Assert.ThrowsExactly<WorkspaceLockException>(() => fixture.Pool.Open(fixture.Workspace("a")));
|
||||
|
||||
holder.Dispose();
|
||||
WorkspaceSession session = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.AreEqual("a", Path.GetFileName(session.FolderPath));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveReleasesResourcesAndForgetsRegistryEntry()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.IsTrue(fixture.Pool.Remove(fixture.Workspace("a")));
|
||||
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
|
||||
// Resources are truly released: the lock can be taken again.
|
||||
using WorkspaceLock reacquired = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PathEquivalentsShareOneSession()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string path = fixture.Workspace("a");
|
||||
string differentlyCased = Path.Combine(
|
||||
Path.GetDirectoryName(path)!,
|
||||
Path.GetFileName(path).ToUpperInvariant());
|
||||
|
||||
WorkspaceSession session = fixture.Pool.Open(path);
|
||||
WorkspaceSession same = fixture.Pool.Open(differentlyCased);
|
||||
|
||||
Assert.AreSame(session, same);
|
||||
Assert.AreEqual(1, fixture.Pool.OpenCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DisposeReleasesAllLocks()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Dispose();
|
||||
|
||||
using WorkspaceLock lockA = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
using WorkspaceLock lockB = WorkspaceLock.Acquire(fixture.Workspace("b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.AppData;
|
||||
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, timestamp refresh rules and the last_edited_at
|
||||
/// write-through.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceRepositoryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns a workspace with fixed-timestamp fixture data (one column, one
|
||||
/// card, two tags linked to the card), a fake-clock app.db and the
|
||||
/// repository under test.
|
||||
/// </summary>
|
||||
private sealed class RepositoryFixture : IDisposable
|
||||
{
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public long Clock { get; set; } = 1000;
|
||||
|
||||
public AppDatabase AppDb { get; }
|
||||
|
||||
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);
|
||||
|
||||
// Fixture rows carry literal timestamps so refresh vs. no-refresh stays observable.
|
||||
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);");
|
||||
|
||||
AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"), () => Clock);
|
||||
Repository = new WorkspaceRepository(Directory.FullPath, AppDb);
|
||||
}
|
||||
|
||||
/// <summary>The last_edited_at currently mirrored into the registry.</summary>
|
||||
public long LastEditedAt => AppDb.GetWorkspaces().Single().LastEditedAt;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
AppDb.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnWithoutChangeWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
ColumnModel before = fixture.Repository.GetColumn(fixture.ColumnId)!;
|
||||
|
||||
ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
|
||||
|
||||
Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnChangeRefreshesColumnTimestampAndLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteColumnCascadesToCardsAndTagLinks()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
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;"));
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[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();
|
||||
fixture.Clock = 2000;
|
||||
CardModel before = fixture.Repository.GetCard(fixture.CardId)!;
|
||||
|
||||
CardModel result = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]);
|
||||
|
||||
Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTitleChangeRefreshesTimestampsAndLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]);
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTagSetOnlyChangeStillRefreshesCardTimestamp()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(1, updated.Tags.Count);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardWithUnknownTagRollsBackAtomically()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
// 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);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardRefreshesLastEditedButNotCardTimestamp()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
long target = fixture.Repository.AddColumn("Target", "").Id;
|
||||
fixture.Clock = 2000;
|
||||
|
||||
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);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardToCurrentColumnWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
|
||||
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
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);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagWithoutChangeWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", "");
|
||||
|
||||
Assert.AreEqual("bug", result.Name);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagChangeWritesAndTouchesLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 3000;
|
||||
|
||||
TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc");
|
||||
|
||||
Assert.AreEqual("defect", updated.Name);
|
||||
Assert.AreEqual("#ee0a0a", updated.Color);
|
||||
Assert.AreEqual("desc", updated.Description);
|
||||
Assert.AreEqual(3000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[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.Clock = 2000;
|
||||
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,18 @@
|
||||
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,9 @@
|
||||
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 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,8 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until 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">Fixed #RRGGBB form.</param>
|
||||
/// <param name="Description">Free-form description, possibly empty.</param>
|
||||
public sealed record TagModel(long Id, string Name, string Color, string Description);
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace YKanBan.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Compares and normalizes workspace folder paths. Normalization produces
|
||||
/// the canonical absolute form: separators unified by the operating system,
|
||||
/// "." and ".." collapsed, trailing separator trimmed except on roots.
|
||||
/// Symbolic links are deliberately NOT resolved. Case handling is an
|
||||
/// injected platform rule — ignored on Windows, significant on Unix and
|
||||
/// macOS — so the Unix behavior is testable on any host.
|
||||
/// </summary>
|
||||
public sealed class PathComparer : IEqualityComparer<string>
|
||||
{
|
||||
private readonly StringComparison _comparison;
|
||||
|
||||
/// <summary>Creates a comparer with an explicit case-sensitivity rule.</summary>
|
||||
/// <param name="ignoreCase">True for Windows-style path semantics.</param>
|
||||
public PathComparer(bool ignoreCase)
|
||||
{
|
||||
_comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
|
||||
/// <summary>The comparer matching the current operating system's path semantics.</summary>
|
||||
public static PathComparer ForCurrentPlatform() => new(OperatingSystem.IsWindows());
|
||||
|
||||
/// <summary>Returns the canonical absolute form used for storage and display.</summary>
|
||||
public string Normalize(string path)
|
||||
{
|
||||
// GetFullPath makes the path absolute, collapses relative segments and
|
||||
// unifies separators; it preserves a trailing separator, so trim it to
|
||||
// keep the canonical form unique ("D:\repo" and "D:\repo\" are one path).
|
||||
string full = Path.GetFullPath(path);
|
||||
return TrimTrailingSeparator(full);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether two paths denote the same location under the configured platform rules.</summary>
|
||||
public bool Equals(string? x, string? y)
|
||||
{
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return ReferenceEquals(x, y);
|
||||
}
|
||||
return string.Equals(Normalize(x), Normalize(y), _comparison);
|
||||
}
|
||||
|
||||
/// <summary>Returns a hash code consistent with <see cref="Equals(string?, string?)"/>.</summary>
|
||||
public int GetHashCode(string path) => Normalize(path).GetHashCode(_comparison);
|
||||
|
||||
/// <summary>Removes trailing separators while keeping root forms intact ("C:\", "/").</summary>
|
||||
private static string TrimTrailingSeparator(string fullPath)
|
||||
{
|
||||
bool isDriveRoot = fullPath.Length == 3
|
||||
&& char.IsAsciiLetter(fullPath[0])
|
||||
&& fullPath[1] == ':'
|
||||
&& IsSeparator(fullPath[2]);
|
||||
if (isDriveRoot || fullPath == "/")
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
return fullPath.TrimEnd('/', '\\');
|
||||
}
|
||||
|
||||
/// <summary>Returns true for both supported separator characters.</summary>
|
||||
private static bool IsSeparator(char character) => character is '/' or '\\';
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using YKanBan.Platform;
|
||||
using YKanBan.Storage.AppData;
|
||||
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-capacity LRU pool of open workspace sessions (exclusive lock plus
|
||||
/// repository per workspace). Opening a workspace also registers it in the
|
||||
/// global app.db sidebar registry. Eviction — by capacity or explicitly —
|
||||
/// only releases resources: the sidebar entry survives, and the workspace
|
||||
/// can be reopened at any time (a lock held elsewhere then surfaces as a
|
||||
/// lock conflict).
|
||||
/// </summary>
|
||||
public sealed class WorkspaceConnectionPool : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly int _capacity;
|
||||
private readonly AppDatabase _appDatabase;
|
||||
private readonly PathComparer _pathComparer;
|
||||
private readonly Dictionary<string, LinkedListNode<WorkspaceSession>> _sessions;
|
||||
private readonly LinkedList<WorkspaceSession> _recency = new();
|
||||
|
||||
/// <summary>Creates a pool.</summary>
|
||||
/// <param name="capacity">Maximum number of simultaneously open workspaces.</param>
|
||||
/// <param name="appDatabase">Global registry for sidebar entries and last_edited_at.</param>
|
||||
/// <param name="pathComparer">Platform path rules used to identify workspaces.</param>
|
||||
public WorkspaceConnectionPool(int capacity, AppDatabase appDatabase, PathComparer pathComparer)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_capacity = capacity;
|
||||
_appDatabase = appDatabase;
|
||||
_pathComparer = pathComparer;
|
||||
_sessions = new Dictionary<string, LinkedListNode<WorkspaceSession>>(pathComparer);
|
||||
}
|
||||
|
||||
/// <summary>Number of currently open workspaces.</summary>
|
||||
public int OpenCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _sessions.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the open session and marks it most recently used, or null when the workspace is not open.</summary>
|
||||
public WorkspaceSession? Find(string folderPath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessions.TryGetValue(_pathComparer.Normalize(folderPath), out LinkedListNode<WorkspaceSession>? node))
|
||||
{
|
||||
Promote(node);
|
||||
return node.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records user activity (click/switch) for an open workspace; no-op when it is not open.</summary>
|
||||
public void Touch(string folderPath) => Find(folderPath);
|
||||
|
||||
/// <summary>Opens the workspace session, or returns the existing one; evicts LRU entries beyond capacity.</summary>
|
||||
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist.</exception>
|
||||
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban.</exception>
|
||||
/// <exception cref="WorkspaceLockException">Another instance holds the lock.</exception>
|
||||
public WorkspaceSession Open(string folderPath)
|
||||
{
|
||||
string canonical = _pathComparer.Normalize(folderPath);
|
||||
|
||||
// Fast path: already open → promote and return.
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessions.TryGetValue(canonical, out LinkedListNode<WorkspaceSession>? open))
|
||||
{
|
||||
Promote(open);
|
||||
return open.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the folder state up front so callers get precise state errors.
|
||||
if (!Directory.Exists(canonical))
|
||||
{
|
||||
throw new WorkspaceDirectoryMissingException(canonical);
|
||||
}
|
||||
if (!WorkspaceInitializer.IsWorkspace(canonical))
|
||||
{
|
||||
throw new WorkspaceNotInitializedException(canonical);
|
||||
}
|
||||
|
||||
// Acquire the exclusive lock before touching the database.
|
||||
WorkspaceLock workspaceLock = WorkspaceLock.Acquire(canonical);
|
||||
WorkspaceSession session;
|
||||
try
|
||||
{
|
||||
var repository = new WorkspaceRepository(canonical, _appDatabase);
|
||||
session = new WorkspaceSession
|
||||
{
|
||||
FolderPath = canonical,
|
||||
Lock = workspaceLock,
|
||||
Repository = repository,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
workspaceLock.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
// Re-check: another open may have won the race; the existing session wins.
|
||||
if (_sessions.TryGetValue(canonical, out LinkedListNode<WorkspaceSession>? winner))
|
||||
{
|
||||
session.Dispose();
|
||||
Promote(winner);
|
||||
return winner.Value;
|
||||
}
|
||||
|
||||
_sessions.Add(canonical, _recency.AddFirst(session));
|
||||
|
||||
// Evict least recently used entries beyond capacity; releases resources only.
|
||||
while (_sessions.Count > _capacity)
|
||||
{
|
||||
LinkedListNode<WorkspaceSession> lru = _recency.Last!;
|
||||
_recency.RemoveLast();
|
||||
_sessions.Remove(lru.Value.FolderPath);
|
||||
lru.Value.Dispose();
|
||||
}
|
||||
|
||||
// Opening registers the workspace in the persistent sidebar registry.
|
||||
_appDatabase.UpsertWorkspace(canonical);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases a workspace's resources; the sidebar entry is kept. Returns true when it was open.</summary>
|
||||
public bool Close(string folderPath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessions.Remove(_pathComparer.Normalize(folderPath), out LinkedListNode<WorkspaceSession>? node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_recency.Remove(node);
|
||||
node.Value.Dispose();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the workspace's resources and removes its sidebar entry. Returns true when it was open.</summary>
|
||||
public bool Remove(string folderPath)
|
||||
{
|
||||
string canonical = _pathComparer.Normalize(folderPath);
|
||||
bool wasOpen = Close(canonical);
|
||||
_appDatabase.RemoveWorkspace(canonical);
|
||||
return wasOpen;
|
||||
}
|
||||
|
||||
/// <summary>Releases all sessions.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (WorkspaceSession session in _recency)
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
_recency.Clear();
|
||||
_sessions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves a session node to the most-recently-used position.</summary>
|
||||
private void Promote(LinkedListNode<WorkspaceSession> node)
|
||||
{
|
||||
_recency.Remove(node);
|
||||
_recency.AddFirst(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.AppData;
|
||||
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// Data access for one open workspace database: CRUD for columns, cards and
|
||||
/// tags, card moves and tag assignment, plus the write-through that
|
||||
/// refreshes the workspace's last_edited_at in the global app.db after
|
||||
/// every successful data write.
|
||||
///
|
||||
/// 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 string _folderPath;
|
||||
private readonly AppDatabase _appDatabase;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
/// <summary>Opens the repository over an initialized workspace database.</summary>
|
||||
/// <param name="folderPath">Workspace folder; the exact value is used as the app.db registry key.</param>
|
||||
/// <param name="appDatabase">Global registry refreshed after every successful write.</param>
|
||||
public WorkspaceRepository(string folderPath, AppDatabase appDatabase)
|
||||
{
|
||||
_folderPath = folderPath;
|
||||
_appDatabase = appDatabase;
|
||||
_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.</summary>
|
||||
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, or null.</summary>
|
||||
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 = updated_at.</summary>
|
||||
/// <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();
|
||||
|
||||
TouchEdited();
|
||||
return new ColumnModel(id, title, description, now, now);
|
||||
}
|
||||
|
||||
/// <summary>Rewrites title and description; a no-change call writes nothing.</summary>
|
||||
/// <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 timestamps.
|
||||
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();
|
||||
|
||||
TouchEdited();
|
||||
return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a column; the DDL cascade removes its cards and their tag links. Returns true when a row was deleted.</summary>
|
||||
public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cards
|
||||
|
||||
/// <summary>Returns all cards with their tags loaded, ordered by id.</summary>
|
||||
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, 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(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/// <summary>Returns the card with the given id (tags loaded), or null.</summary>
|
||||
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();
|
||||
|
||||
CardModel? card = null;
|
||||
var tags = new List<TagModel>();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (card is null)
|
||||
{
|
||||
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(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
|
||||
}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>Creates a card with its initial tag assignment (created_at = updated_at).</summary>
|
||||
/// <exception cref="SqliteException">The column does not exist, 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();
|
||||
|
||||
TouchEdited();
|
||||
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>
|
||||
/// <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 change in title, content or tag set: no write, no timestamps.
|
||||
bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id));
|
||||
if (existing.Title == title && existing.Content == content && !tagsChanged)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
long now = UnixTime.Now;
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
// The confirm write refreshes updated_at even when only the tag set changed.
|
||||
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();
|
||||
|
||||
TouchEdited();
|
||||
return new CardModel(cardId, existing.ColumnId, title, content, existing.CreatedAt, now, LoadTags(cardId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a card to another column. This is a data write (refreshes the
|
||||
/// workspace last_edited_at) but deliberately does NOT refresh the
|
||||
/// card's updated_at; moving to the current column is a no-write no-op.
|
||||
/// Returns true when the card moved.
|
||||
/// </summary>
|
||||
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);
|
||||
bool moved = command.ExecuteNonQuery() > 0;
|
||||
if (moved)
|
||||
{
|
||||
TouchEdited();
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a card; the DDL cascade removes its tag links. Returns true when a row was deleted.</summary>
|
||||
public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tags
|
||||
|
||||
/// <summary>Returns all tags ordered by id; unreferenced tags are included.</summary>
|
||||
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));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>Returns every tag together with the number of cards using it, ordered by id.</summary>
|
||||
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), reader.GetInt64(4)));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>Creates a tag.</summary>
|
||||
/// <exception cref="SqliteException">The name is empty/not unique, or the color is not #RRGGBB.</exception>
|
||||
public TagModel AddTag(string name, string 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), ("$description", description));
|
||||
transaction.Commit();
|
||||
|
||||
TouchEdited();
|
||||
return new TagModel(id, name, color, description);
|
||||
}
|
||||
|
||||
/// <summary>Rewrites name, color and description; a no-change call writes nothing.</summary>
|
||||
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
|
||||
/// <exception cref="SqliteException">The new name is empty/not unique, or the color is not #RRGGBB.</exception>
|
||||
public TagModel UpdateTag(long tagId, string name, string 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);
|
||||
command.Parameters.AddWithValue("$description", description);
|
||||
command.Parameters.AddWithValue("$id", tagId);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
TouchEdited();
|
||||
return new TagModel(tagId, name, color, description);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag; the DDL cascade removes its card links while the cards survive. Returns true when a row was deleted.</summary>
|
||||
public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>Returns the tag with the given id, or null.</summary>
|
||||
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) : null;
|
||||
}
|
||||
|
||||
/// <summary>Returns the tags currently assigned to a card, ordered by id.</summary>
|
||||
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));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>Reads a columns row from the reader cursor.</summary>
|
||||
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.</summary>
|
||||
private static TagModel ReadTag(SqliteDataReader reader) =>
|
||||
new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3));
|
||||
|
||||
/// <summary>Creates a command bound to this connection.</summary>
|
||||
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>
|
||||
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>
|
||||
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 by id; refreshes last_edited_at only when a row was actually removed.</summary>
|
||||
private bool ExecuteDelete(string sql, long id)
|
||||
{
|
||||
using SqliteCommand command = CreateCommand(sql);
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
if (command.ExecuteNonQuery() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
TouchEdited();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors a successful data write into the global registry.</summary>
|
||||
private void TouchEdited() => _appDatabase.TouchLastEdited(_folderPath);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
/// <summary>One open workspace: the exclusive lock plus its repository.</summary>
|
||||
public sealed class WorkspaceSession : IDisposable
|
||||
{
|
||||
/// <summary>Canonical workspace folder path.</summary>
|
||||
public required string FolderPath { get; init; }
|
||||
|
||||
/// <summary>The exclusive lock held while this session is open.</summary>
|
||||
public required WorkspaceLock Lock { get; init; }
|
||||
|
||||
/// <summary>The data access object for this workspace.</summary>
|
||||
public required WorkspaceRepository Repository { get; init; }
|
||||
|
||||
/// <summary>Disposes the repository, then releases the lock.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
Lock.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user