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]
|
[TestMethod]
|
||||||
public void ColumnTitlesCompareCaseSensitively()
|
public void ColumnTitlesDifferingOnlyInAsciiCaseAreRejected()
|
||||||
{
|
{
|
||||||
using var db = new FreshDatabase();
|
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);");
|
||||||
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;"));
|
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]
|
[TestMethod]
|
||||||
public void EmptyTagNameIsRejected()
|
public void EmptyTagNameIsRejected()
|
||||||
{
|
{
|
||||||
@@ -103,6 +132,24 @@ public class WorkspaceSchemaTests
|
|||||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#445566');"));
|
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]
|
[TestMethod]
|
||||||
[DataRow("#000000")]
|
[DataRow("#000000")]
|
||||||
[DataRow("#FFFFFF")]
|
[DataRow("#FFFFFF")]
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ namespace YKanBan.Storage.Workspace;
|
|||||||
/// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN
|
/// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN
|
||||||
/// KEY) so every stored row is valid by construction, and cascading deletes
|
/// KEY) so every stored row is valid by construction, and cascading deletes
|
||||||
/// are covered by the same DDL.
|
/// 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>
|
/// </summary>
|
||||||
public static class WorkspaceSchema
|
public static class WorkspaceSchema
|
||||||
{
|
{
|
||||||
@@ -22,10 +27,11 @@ public static class WorkspaceSchema
|
|||||||
new SchemaMigration(CurrentVersion, "initial schema", """
|
new SchemaMigration(CurrentVersion, "initial schema", """
|
||||||
CREATE TABLE columns (
|
CREATE TABLE columns (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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 '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE (title COLLATE NOCASE)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE cards (
|
CREATE TABLE cards (
|
||||||
@@ -39,9 +45,10 @@ public static class WorkspaceSchema
|
|||||||
|
|
||||||
CREATE TABLE tags (
|
CREATE TABLE tags (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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]'),
|
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 (
|
CREATE TABLE card_tags (
|
||||||
|
|||||||
+6
-4
@@ -57,18 +57,20 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
columns: id INTEGER PK AUTOINCREMENT,
|
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 '',
|
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,
|
cards: id INTEGER PK AUTOINCREMENT,
|
||||||
column_id INT NOT NULL → columns.id ON DELETE CASCADE,
|
column_id INT NOT NULL → columns.id ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL DEFAULT '',
|
title TEXT NOT NULL DEFAULT '',
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL DEFAULT '',
|
||||||
created_at INT NOT NULL, updated_at INT NOT NULL
|
created_at INT NOT NULL, updated_at INT NOT NULL
|
||||||
tags: id INTEGER PK AUTOINCREMENT,
|
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 形态),
|
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
|
card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user