feat: make column title and tag name uniqueness case-insensitive
This commit is contained in:
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")]
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user