feat: make column title and tag name uniqueness case-insensitive

This commit is contained in:
doyaGu committed 2026-10-03 02:44:46 -04:00
1 parent 33757f6cbc
commit 632339ea42
3 files changed
+67 -11

No files matched your search

@@ -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<SqliteException>(() =>
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<SqliteException>(() =>
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")]
+11 -4
View File
@@ -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.
/// </summary>
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 (
+6 -4
View File
@@ -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
```