From 632339ea4297d0043d7aa7940b64a1da0d51c197 Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 02:44:46 -0400 Subject: [PATCH] feat: make column title and tag name uniqueness case-insensitive --- .../Storage/Workspace/WorkspaceSchemaTests.cs | 53 +++++++++++++++++-- YKanBan/Storage/Workspace/WorkspaceSchema.cs | 15 ++++-- docs/PLAN.md | 10 ++-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs index 7e2d5b1..42f677e 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs @@ -65,15 +65,44 @@ public class WorkspaceSchemaTests } [TestMethod] - public void ColumnTitlesCompareCaseSensitively() + public void ColumnTitlesDifferingOnlyInAsciiCaseAreRejected() { using var db = new FreshDatabase(); - // BINARY collation: 'Foo' and 'foo' are distinct titles. + // NOCASE uniqueness: 'Foo' and 'foo' cannot coexist. SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); - SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);"); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);")); + } + + [TestMethod] + public void ColumnTitlesDifferingOnlyInNonAsciiCaseAreAccepted() + { + using var db = new FreshDatabase(); + // NOCASE folds ASCII only: 'Ä' and 'ä' remain distinct titles. + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Ä', 1, 1);"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('ä', 1, 1);"); Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;")); } + [TestMethod] + public void ColumnTitleCaseOnlyRenameOfSameRowIsAccepted() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); + int affected = SqliteTestHelper.Exec(db.Connection, "UPDATE columns SET title = 'foo' WHERE id = 1;"); + Assert.AreEqual(1, affected); + } + + [TestMethod] + public void ColumnTitlesStillOrderByCodePoint() + { + using var db = new FreshDatabase(); + // Uniqueness is NOCASE, but the column collation stays BINARY: 'B' (0x42) sorts before 'a' (0x61). + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('a', 1, 1);"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('B', 1, 1);"); + Assert.AreEqual("B", SqliteTestHelper.ScalarString(db.Connection, "SELECT title FROM columns ORDER BY title LIMIT 1;")); + } + [TestMethod] public void EmptyTagNameIsRejected() { @@ -103,6 +132,24 @@ public class WorkspaceSchemaTests SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#445566');")); } + [TestMethod] + public void TagNamesDifferingOnlyInAsciiCaseAreRejected() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');"); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('BUG', '#445566');")); + } + + [TestMethod] + public void TagNamesDifferingOnlyInNonAsciiCaseAreAccepted() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('Ä', '#112233');"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('ä', '#445566');"); + Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); + } + [TestMethod] [DataRow("#000000")] [DataRow("#FFFFFF")] diff --git a/YKanBan/Storage/Workspace/WorkspaceSchema.cs b/YKanBan/Storage/Workspace/WorkspaceSchema.cs index 4e91e22..e7dde7d 100644 --- a/YKanBan/Storage/Workspace/WorkspaceSchema.cs +++ b/YKanBan/Storage/Workspace/WorkspaceSchema.cs @@ -6,6 +6,11 @@ namespace YKanBan.Storage.Workspace; /// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN /// KEY) so every stored row is valid by construction, and cascading deletes /// are covered by the same DDL. +/// +/// Title and name uniqueness is NOCASE (ASCII case folding) while the columns +/// themselves keep the default BINARY collation, so ordering by title stays +/// code-point based. Until the first release the v1 script may be edited in +/// place; after it, every schema change must be appended as a new migration. /// public static class WorkspaceSchema { @@ -22,10 +27,11 @@ public static class WorkspaceSchema new SchemaMigration(CurrentVersion, "initial schema", """ CREATE TABLE columns ( id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL UNIQUE CHECK (length(title) > 0), + title TEXT NOT NULL CHECK (length(title) > 0), description TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + UNIQUE (title COLLATE NOCASE) ); CREATE TABLE cards ( @@ -39,9 +45,10 @@ public static class WorkspaceSchema CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE CHECK (length(name) > 0), + name TEXT NOT NULL CHECK (length(name) > 0), color TEXT NOT NULL CHECK (color GLOB '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'), - description TEXT NOT NULL DEFAULT '' + description TEXT NOT NULL DEFAULT '', + UNIQUE (name COLLATE NOCASE) ); CREATE TABLE card_tags ( diff --git a/docs/PLAN.md b/docs/PLAN.md index 9bdf081..1815b14 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -57,18 +57,20 @@ ``` columns: id INTEGER PK AUTOINCREMENT, - title TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(title) > 0), + title TEXT NOT NULL CHECK (length(title) > 0), description TEXT NOT NULL DEFAULT '', - created_at INT NOT NULL, updated_at INT NOT NULL + created_at INT NOT NULL, updated_at INT NOT NULL, + UNIQUE (title COLLATE NOCASE) ← 仅唯一性 NOCASE,列本身保持 BINARY(排序按码点) cards: id INTEGER PK AUTOINCREMENT, column_id INT NOT NULL → columns.id ON DELETE CASCADE, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', created_at INT NOT NULL, updated_at INT NOT NULL tags: id INTEGER PK AUTOINCREMENT, - name TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(name) > 0), + name TEXT NOT NULL CHECK (length(name) > 0), color TEXT NOT NULL CHECK (GLOB '#'+六位十六进制,即 #RRGGBB 形态), - description TEXT NOT NULL DEFAULT '' + description TEXT NOT NULL DEFAULT '', + UNIQUE (name COLLATE NOCASE) card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE ```