diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs
index fae748f..997614d 100644
--- a/YKanBan.Tests/Search/SearchQueryParserTests.cs
+++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs
@@ -130,8 +130,9 @@ public class SearchQueryParserTests {
}
[TestMethod]
- public void QualifierValueMayContainColons() {
- AssertParsesTo(new SearchNode.TagName("a:b"), "tag:a:b");
+ public void QuotedQualifierValueMayContainColonsAndOperators() {
+ AssertParsesTo(new SearchNode.TagName("a:b"), "tag:\"a:b\"");
+ AssertParsesTo(new SearchNode.TagName("AND"), "tag:\"AND\"");
}
[TestMethod]
@@ -148,12 +149,18 @@ public class SearchQueryParserTests {
[TestMethod]
public void IdQualifierParsesNumber() {
AssertParsesTo(new SearchNode.CardId(42), "id:42");
- AssertParsesTo(new SearchNode.CardId(12), "id:\"12\"");
+ AssertParsesTo(new SearchNode.CardId(61), "id:#61");
}
[TestMethod]
- public void ColonWithoutKeyPrefixIsFreeText() {
- AssertParsesTo(new SearchNode.FreeText(":foo"), ":foo");
+ public void QuotedTextWithColonIsFreeText() {
+ AssertParsesTo(new SearchNode.FreeText("http://x"), "\"http://x\"");
+ }
+
+ [TestMethod]
+ public void WordMayContainHashAndPunctuation() {
+ AssertParsesTo(new SearchNode.FreeText("#61"), "#61");
+ AssertParsesTo(new SearchNode.FreeText("c#/.net"), "c#/.net");
}
#endregion
@@ -227,8 +234,84 @@ public class SearchQueryParserTests {
}
[TestMethod]
- public void NonNumericIdIsAnError() {
- AssertSyntaxError("id:12x");
+ [DataRow("tag: \"x\"")]
+ [DataRow("title: \"a b\"")]
+ public void SpacedQualifierPhraseValueIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ [DataRow("a\"b\"")]
+ [DataRow("\"a\"b")]
+ [DataRow("\"a\"\"b\"")]
+ [DataRow("tag:\"x\"y")]
+ [DataRow("tag:\"x\"\"y\"")]
+ public void PhraseGluedToOtherTextIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ [DataRow("\"\"")]
+ [DataRow("a \"\"")]
+ [DataRow("tag:\"\"")]
+ public void EmptyPhraseIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ public void PhraseMayTouchParentheses() {
+ AssertParsesTo(
+ new SearchNode.And([new SearchNode.FreeText("a b"), new SearchNode.FreeText("c")]),
+ "(\"a b\")(c)");
+ }
+
+ [TestMethod]
+ [DataRow("id:12x")]
+ [DataRow("id:abc")]
+ [DataRow("id:0")]
+ [DataRow("id:-1")]
+ [DataRow("id:+1")]
+ [DataRow("id:#")]
+ [DataRow("id:##1")]
+ [DataRow("id:1.5")]
+ [DataRow("id:٣")]
+ [DataRow("id:99999999999999999999")]
+ public void InvalidIdValueIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ public void QuotedIdValueIsAnError() {
+ AssertSyntaxError("id:\"61\"");
+ }
+
+ [TestMethod]
+ [DataRow("tag:AND")]
+ [DataRow("tag:OR")]
+ [DataRow("title:AND")]
+ public void BareOperatorAsQualifierValueIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ public void UnquotedQualifierValueWithColonIsAnError() {
+ AssertSyntaxError("tag:a:b");
+ }
+
+ [TestMethod]
+ [DataRow(":foo")]
+ [DataRow("http://x")]
+ [DataRow("a:")]
+ public void ColonAfterNonKeyIsAnError(string text) {
+ AssertSyntaxError(text);
+ }
+
+ [TestMethod]
+ [DataRow("a\\b")]
+ [DataRow("\\")]
+ [DataRow("tag:a\\b")]
+ public void BackslashInBareWordIsAnError(string text) {
+ AssertSyntaxError(text);
}
[TestMethod]
diff --git a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs
index 7c8fc04..3002521 100644
--- a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs
+++ b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs
@@ -26,12 +26,14 @@ public class SearchSqlCompilerTests {
// Columns: 1 "To Do", 2 "In Progress".
SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('To Do', '', 1, 1);");
SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('In Progress', '', 1, 1);");
+ SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Archive', '', 1, 1);");
// Cards: 1 titled, 2 title-or-content matches, 3 untitled, 4 wildcard chars.
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'Fix login', 'Cannot sign in', 1, 1);");
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, 'Green apple', 'the apple pie', 1, 1);");
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, '', 'banana split', 1, 1);");
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, '100% done', 'a_b test', 1, 1);");
+ SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (3, 'ÄÖÜ Straße', '', 1, 1);");
// Tags: mixed case names plus one containing spaces.
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('Bug', '#ff0000', '');");
@@ -143,20 +145,27 @@ public class SearchSqlCompilerTests {
}
[TestMethod]
- public void LikeWildcardsInUserTextMatchLiterally() {
+ public void PercentAndUnderscoreInUserTextMatchLiterally() {
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("100%"));
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("a_b"));
}
+ [TestMethod]
+ public void UnicodeCaseFoldingMatchesNonAscii() {
+ using var fixture = new SearchFixture();
+
+ // SQLite's LIKE would only fold ASCII; the ykb_fold function folds these too.
+ CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("äöü"));
+ CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("straße"));
+ }
+
[TestMethod]
public void CompiledPredicateUsesNamedParameters() {
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x"));
StringAssert.Contains(compiled.Predicate, "$p0");
- StringAssert.Contains(compiled.Predicate, "$p1");
- Assert.AreEqual(2, compiled.Parameters.Count);
- Assert.AreEqual("%x%", compiled.Parameters[0].Value);
- Assert.AreEqual("%x%", compiled.Parameters[1].Value);
+ Assert.AreEqual(1, compiled.Parameters.Count);
+ Assert.AreEqual("x", compiled.Parameters[0].Value);
}
}
diff --git a/YKanBan/Search/SearchLexer.cs b/YKanBan/Search/SearchLexer.cs
index a80d576..c1b1c56 100644
--- a/YKanBan/Search/SearchLexer.cs
+++ b/YKanBan/Search/SearchLexer.cs
@@ -34,21 +34,20 @@ public abstract record SearchToken {
/// quoted phrases and parentheses; whitespace only separates tokens.
///
/// The lexer is strict: inside a phrase only \" and \\ are valid
-/// escapes, a bare backslash or an unknown escape is rejected, and an
-/// unterminated phrase is rejected. Any input not covered by the token rules
-/// is rejected rather than silently ignored.
+/// escapes, a bare backslash or an unknown escape is rejected, a backslash in a
+/// bare word is rejected, and an unterminated phrase is rejected. A phrase
+/// must not be empty and must be separated from neighbouring words and
+/// phrases by whitespace or a parenthesis, except as a qualifier value glued to
+/// its key (tag:"a b"). Any input not covered by the token rules is
+/// rejected rather than silently ignored.
///
public static class SearchLexer {
- ///
- /// A valid phrase escape sequence: \" or \\.
- ///
+ /// A valid phrase escape sequence: \" or \\.
private static readonly Parser PhraseEscape =
Parser.Try(Parser.String("\\\"").Select(_ => '"'))
.Or(Parser.String("\\\\").Select(_ => '\\'));
- ///
- /// One phrase character: an escape or any character except a quote or backslash.
- ///
+ /// One phrase character: an escape or any character except a quote or backslash.
private static readonly Parser PhraseCharacter =
PhraseEscape.Or(Parser.Token(character => character != '"' && character != '\\'));
@@ -64,39 +63,99 @@ public static class SearchLexer {
private static readonly Parser CloseParenParser =
Parser.Char(')').Select(_ => (SearchToken)new SearchToken.CloseParen());
+ // ':' stays inside the word token (the parser splits qualifiers); a backslash
+ // is not a word character, so a bare one is left over and rejected by End.
private static readonly Parser WordParser =
Parser.Token(character =>
- !char.IsWhiteSpace(character) && character != '(' && character != ')' && character != '"')
+ !char.IsWhiteSpace(character) && character != '(' && character != ')' && character != '"' && character != '\\')
.AtLeastOnce()
.Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray())));
- private static readonly Parser TokenParser =
+ // Each token with its source span, so adjacency can be checked afterwards.
+ private static readonly Parser TokenParser =
Parser.SkipWhitespaces.Then(
- PhraseParser
+ from start in Parser.CurrentOffset
+ from token in PhraseParser
.Or(OpenParenParser)
.Or(CloseParenParser)
- .Or(WordParser));
+ .Or(WordParser)
+ from end in Parser.CurrentOffset
+ select new PositionedToken(token, start, end));
// Try(...) keeps a failed token non-consuming so Many stops gracefully; End
// then rejects any leftover input, which turns dangling quotes or invalid
// escapes into hard failures instead of silently dropped text.
- private static readonly Parser> Lexer =
+ private static readonly Parser> Lexer =
Parser.Try(TokenParser).Many()
.Before(Parser.SkipWhitespaces)
.Before(Parser.End)
- .Select(tokens => (IReadOnlyList)tokens.ToList());
+ .Select(tokens => (IReadOnlyList)tokens.ToList());
///
/// Tokenizes the search text.
///
/// The raw search text.
/// The token stream.
- /// The text contains an invalid escape or an unterminated phrase.
+ ///
+ /// The text contains an invalid escape, an unterminated or empty phrase, a
+ /// phrase glued to other text, or whitespace between a qualifier and its phrase value.
+ ///
public static IReadOnlyList Tokenize(string input) {
+ IReadOnlyList tokens;
try {
- return Lexer.ParseOrThrow(input);
+ tokens = Lexer.ParseOrThrow(input);
} catch (ParseException exception) {
throw new SearchSyntaxException(exception.Message, exception);
}
+
+ EnsurePhrasesAreDelimited(tokens);
+ return tokens.Select(positioned => positioned.Token).ToList();
}
+
+ ///
+ /// Rejects the phrase placements the grammar does not allow but the token
+ /// rules alone would accept: an empty phrase, a phrase touching a word or
+ /// another phrase (a"b", "a"b, "a""b"), and whitespace
+ /// between key: and its phrase value (tag: "x"). The only
+ /// legal contact is key:"value".
+ ///
+ /// The positioned token stream.
+ /// A phrase is misplaced.
+ private static void EnsurePhrasesAreDelimited(IReadOnlyList tokens) {
+ for (int index = 0; index < tokens.Count; index++) {
+ if (tokens[index].Token is SearchToken.Phrase { Text.Length: 0 }) {
+ throw new SearchSyntaxException("Empty phrase \"\" is not allowed.");
+ }
+
+ if (index == 0) {
+ continue;
+ }
+
+ PositionedToken previous = tokens[index - 1];
+ PositionedToken current = tokens[index];
+ bool qualifierValue = previous.Token is SearchToken.Word word && word.Text.EndsWith(':')
+ && current.Token is SearchToken.Phrase;
+ bool touching = previous.End == current.Start;
+
+ if (qualifierValue && !touching) {
+ throw new SearchSyntaxException(
+ $"Qualifier '{((SearchToken.Word)previous.Token).Text}' must be followed directly by its value, without whitespace.");
+ }
+
+ bool phraseInvolved = previous.Token is SearchToken.Phrase || current.Token is SearchToken.Phrase;
+ bool parenInvolved = previous.Token is SearchToken.OpenParen or SearchToken.CloseParen
+ || current.Token is SearchToken.OpenParen or SearchToken.CloseParen;
+ if (touching && phraseInvolved && !parenInvolved && !qualifierValue) {
+ throw new SearchSyntaxException("A phrase must be separated from adjacent text by whitespace.");
+ }
+ }
+ }
+
+ ///
+ /// A token with its source span.
+ ///
+ /// The token.
+ /// Offset of the first character.
+ /// Offset just past the last character.
+ private sealed record PositionedToken(SearchToken Token, int Start, int End);
}
diff --git a/YKanBan/Search/SearchQueryParser.cs b/YKanBan/Search/SearchQueryParser.cs
index d9efbc7..82c915d 100644
--- a/YKanBan/Search/SearchQueryParser.cs
+++ b/YKanBan/Search/SearchQueryParser.cs
@@ -7,8 +7,10 @@ namespace YKanBan.Search;
///
/// Parses the search grammar with Pidgin, strictly: any malformed input
/// (unbalanced parentheses, dangling or consecutive operators, unterminated
-/// phrases, invalid escapes, qualifier keys without a value or with an
-/// unknown/non-numeric id value) raises .
+/// phrases, invalid escapes, a backslash in a bare word, unknown qualifier
+/// keys, qualifiers without a value, unquoted values containing ':' or equal
+/// to AND/OR, and id values that are not an unquoted positive integer with an
+/// optional '#') raises .
///
/// Only uppercase AND/OR are operators (lowercase ones are plain words); AND —
/// explicit or by adjacency — binds tighter than OR; parentheses group. Blank
@@ -16,9 +18,7 @@ namespace YKanBan.Search;
/// qualifier keys are fixed English grammar and are never localized.
///
public static class SearchQueryParser {
- ///
- /// The recognized qualifier keys; add new entries here to extend the grammar.
- ///
+ /// The recognized qualifier keys; add new entries here to extend the grammar.
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
("tag", QualifierKind.Tag),
@@ -42,16 +42,12 @@ public static class SearchQueryParser {
private static readonly Parser CloseParen =
Parser.Token(token => token is SearchToken.CloseParen);
- ///
- /// The text of a phrase token.
- ///
+ /// The text of a phrase token.
private static readonly Parser PhraseText =
Parser.Token(token => token is SearchToken.Phrase)
.Select(token => ((SearchToken.Phrase)token).Text);
- ///
- /// The text of a word token, operator keywords excluded (they never start an operand).
- ///
+ /// The text of a word token, operator keywords excluded (they never start an operand).
private static readonly Parser NonKeywordWord =
Parser.Token(token => token is SearchToken.Word word && word.Text != "AND" && word.Text != "OR")
.Select(token => ((SearchToken.Word)token).Text);
@@ -96,9 +92,7 @@ public static class SearchQueryParser {
private static readonly Parser Query =
OrExpr.Before(Parser.End);
- ///
- /// Defers the OrExpr field read to parse time (recursion cycle breaker).
- ///
+ /// Defers the OrExpr field read to parse time (recursion cycle breaker).
/// The OR-expression parser.
private static Parser OrExprDeferred() => OrExpr;
@@ -115,6 +109,11 @@ public static class SearchQueryParser {
return null;
}
+ // YYC MARK: the expression is deliberately not capped by parenthesis
+ // depth or atom count. The app is local-only with no untrusted search
+ // input, so there is no attack surface; a pathologically deep or long
+ // expression can still overflow the parser stack or exceed SQLite's
+ // expression nesting limit, which is accepted here.
try {
List tokens = SearchLexer.Tokenize(text).ToList();
return Query.ParseOrThrow(tokens);
@@ -133,52 +132,77 @@ public static class SearchQueryParser {
/// The parser producing the operand node.
private static Parser QualifierFrom(string word) {
int colonIndex = word.IndexOf(':');
-
- // Only a leading run of letters/digits/underscores before ':' can be a qualifier key.
- if (colonIndex <= 0 || !IsQualifierKeyCandidate(word.AsSpan(0, colonIndex))) {
+ if (colonIndex < 0) {
return Parser.Return(new SearchNode.FreeText(word));
}
+ // Any ':' in a bare word makes it a qualifier, so the text before it must be a known key.
string key = word[..colonIndex];
string value = word[(colonIndex + 1)..];
if (!TryLookupQualifier(key, out QualifierKind kind)) {
- return Parser.Fail($"Unknown qualifier key '{key}'.");
+ return Parser.Fail($"Unknown qualifier key '{key}'; quote the text to search for it literally.");
}
// "key:" without an inline value takes the value from the following phrase, e.g. tag:"a b".
- return value.Length == 0
- ? PhraseText.Bind(text => AtomParser(kind, text))
- : AtomParser(kind, value);
+ if (value.Length == 0) {
+ return kind == QualifierKind.Id
+ ? Parser.Fail("The id qualifier expects an unquoted positive integer.")
+ : PhraseText.Select(phrase => TextAtom(kind, phrase));
+ }
+
+ if (value.Contains(':')) {
+ return Parser.Fail($"Unquoted value '{value}' of qualifier '{key}' contains ':'; quote the value.");
+ }
+
+ if (value is "AND" or "OR") {
+ return Parser.Fail($"Operator {value} cannot be a qualifier value; quote it to search for it literally.");
+ }
+
+ if (kind == QualifierKind.Id) {
+ return TryParseCardId(value, out long id)
+ ? Parser.Return(new SearchNode.CardId(id))
+ : Parser.Fail($"The id qualifier expects a positive integer, got '{value}'.");
+ }
+
+ return Parser.Return(TextAtom(kind, value));
}
///
- /// Builds the atom parser for a qualifier value, failing strictly on a non-numeric id.
+ /// Builds the atom for a textual qualifier value.
///
- /// The qualifier kind.
+ /// The qualifier kind; never .
/// The qualifier value.
- /// The parser producing the atom node.
- private static Parser AtomParser(QualifierKind kind, string value) =>
- AtomFrom(kind, value) is { } atom
- ? Parser.Return(atom)
- : Parser.Fail($"The id qualifier expects an integer, got '{value}'.");
-
- ///
- /// Builds the atom for a qualifier value; null when an id value is not an integer.
- ///
- /// The qualifier kind.
- /// The qualifier value.
- /// The atom node, or for an invalid id value.
- private static SearchNode? AtomFrom(QualifierKind kind, string value) => kind switch {
+ /// The atom node.
+ private static SearchNode TextAtom(QualifierKind kind, string value) => kind switch {
QualifierKind.Tag => new SearchNode.TagName(value),
QualifierKind.Title => new SearchNode.FieldText(SearchField.Title, value),
QualifierKind.Content => new SearchNode.FieldText(SearchField.Content, value),
QualifierKind.Column => new SearchNode.ColumnTitle(value),
- QualifierKind.Id when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long id) =>
- new SearchNode.CardId(id),
- _ => null,
+ _ => throw new InvalidOperationException($"Qualifier kind {kind} has no textual value."),
};
+ ///
+ /// Parses an id qualifier value: decimal digits with an optional leading
+ /// #, denoting a positive integer.
+ ///
+ /// The inline qualifier value.
+ /// The parsed card id.
+ /// when the value is a valid positive card id.
+ private static bool TryParseCardId(string value, out long id) {
+ ReadOnlySpan digits = value.AsSpan();
+ if (digits.StartsWith("#")) {
+ digits = digits[1..];
+ }
+
+ // Digits only: the span check rules out signs, whitespace and other NumberStyles leniency.
+ id = 0;
+ return digits.Length > 0
+ && !digits.ContainsAnyExceptInRange('0', '9')
+ && long.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out id)
+ && id > 0;
+ }
+
///
/// Looks up a qualifier key in the extension table with ordinal (case-sensitive) matching.
///
@@ -197,22 +221,6 @@ public static class SearchQueryParser {
return false;
}
- ///
- /// Returns whether the text looks like a qualifier key: a non-empty run of
- /// letters, digits or underscores.
- ///
- /// The candidate key text.
- /// when every character is a word character.
- private static bool IsQualifierKeyCandidate(ReadOnlySpan text) {
- foreach (char character in text) {
- if (!(char.IsLetterOrDigit(character) || character == '_')) {
- return false;
- }
- }
-
- return true;
- }
-
///
/// Collapses an AND chain into a single node.
///
@@ -236,9 +244,7 @@ public static class SearchQueryParser {
#endregion
- ///
- /// The recognized qualifier kinds.
- ///
+ /// The recognized qualifier kinds.
private enum QualifierKind {
Tag,
Title,
diff --git a/YKanBan/Search/SearchSqlCompiler.cs b/YKanBan/Search/SearchSqlCompiler.cs
index a9692cb..53ec95d 100644
--- a/YKanBan/Search/SearchSqlCompiler.cs
+++ b/YKanBan/Search/SearchSqlCompiler.cs
@@ -1,3 +1,5 @@
+using YKanBan.Storage;
+
namespace YKanBan.Search;
///
@@ -10,10 +12,11 @@ public sealed record CompiledSearch(string Predicate, IReadOnlyList<(string Name
///
/// Compiles a parsed search AST into a parameterized SQL predicate. Matching is
-/// case-insensitive with SQLite's built-in semantics: LIKE's default case
-/// folding for substring matches plus COLLATE NOCASE for exact matches. LIKE
-/// wildcards and the escape character in user text are escaped so they match
-/// literally.
+/// Unicode case-insensitive: both sides are folded with
+/// (the column side through the per-connection
+/// function) and then compared
+/// ordinally — instr for substrings, = for exact matches. LIKE is
+/// deliberately not used, so % and _ carry no wildcard meaning.
///
public static class SearchSqlCompiler {
///
@@ -43,26 +46,25 @@ public static class SearchSqlCompiler {
case SearchNode.FreeText freeText: {
// A bare word matches a substring of the title OR the content.
- string titleParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%");
- string contentParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%");
- return $"(c.title LIKE {titleParam} ESCAPE '\\' OR c.content LIKE {contentParam} ESCAPE '\\')";
+ string param = AddParameter(parameters, SqliteDatabase.Fold(freeText.Text));
+ return $"({Contains("c.title", param)} OR {Contains("c.content", param)})";
}
case SearchNode.FieldText field: {
string column = field.Field == SearchField.Title ? "c.title" : "c.content";
- string param = AddParameter(parameters, "%" + EscapeLike(field.Text) + "%");
- return $"{column} LIKE {param} ESCAPE '\\'";
+ string param = AddParameter(parameters, SqliteDatabase.Fold(field.Text));
+ return Contains(column, param);
}
case SearchNode.TagName tag: {
- string param = AddParameter(parameters, tag.Name);
+ string param = AddParameter(parameters, SqliteDatabase.Fold(tag.Name));
return "EXISTS (SELECT 1 FROM card_tags ct JOIN tags t ON t.id = ct.tag_id " +
- $"WHERE ct.card_id = c.id AND t.name COLLATE NOCASE = {param})";
+ $"WHERE ct.card_id = c.id AND {Folded("t.name")} = {param})";
}
case SearchNode.ColumnTitle column: {
- string param = AddParameter(parameters, column.Title);
- return $"c.column_id IN (SELECT id FROM columns WHERE title COLLATE NOCASE = {param})";
+ string param = AddParameter(parameters, SqliteDatabase.Fold(column.Title));
+ return $"c.column_id IN (SELECT id FROM columns WHERE {Folded("title")} = {param})";
}
case SearchNode.CardId id: {
@@ -88,10 +90,17 @@ public static class SearchSqlCompiler {
}
///
- /// Escapes LIKE wildcards and the escape character inside user text.
+ /// Wraps a column expression in the case-folding SQL function.
///
- /// The raw user text.
- /// The text with \, % and _ escaped.
- private static string EscapeLike(string text) =>
- text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
+ /// The column expression.
+ /// The folded column expression.
+ private static string Folded(string column) => $"{SqliteDatabase.FoldFunctionName}({column})";
+
+ ///
+ /// Builds an ordinal substring test of a folded column against an already folded parameter.
+ ///
+ /// The column expression.
+ /// The placeholder of the folded search text.
+ /// The SQL boolean expression.
+ private static string Contains(string column, string param) => $"instr({Folded(column)}, {param}) > 0";
}
diff --git a/YKanBan/Search/SearchSyntaxException.cs b/YKanBan/Search/SearchSyntaxException.cs
index e27591c..f953d15 100644
--- a/YKanBan/Search/SearchSyntaxException.cs
+++ b/YKanBan/Search/SearchSyntaxException.cs
@@ -7,6 +7,14 @@ namespace YKanBan.Search;
/// localized.
///
public sealed class SearchSyntaxException : Exception {
+ ///
+ /// Initializes the exception with a descriptive English message.
+ ///
+ /// The English diagnostic message.
+ public SearchSyntaxException(string message)
+ : base(message) {
+ }
+
///
/// Initializes the exception with a descriptive English message.
///
diff --git a/YKanBan/Storage/SqliteDatabase.cs b/YKanBan/Storage/SqliteDatabase.cs
index 7b2b9a9..41e2bbd 100644
--- a/YKanBan/Storage/SqliteDatabase.cs
+++ b/YKanBan/Storage/SqliteDatabase.cs
@@ -8,6 +8,20 @@ namespace YKanBan.Storage;
/// foreign keys on) and applies incremental user_version migrations.
///
public static class SqliteDatabase {
+ ///
+ /// Name of the per-connection SQL function that applies ;
+ /// search uses it for Unicode case-insensitive matching.
+ ///
+ public const string FoldFunctionName = "ykb_fold";
+
+ ///
+ /// Unicode case folding shared by the SQL function and the search
+ /// parameters it is compared against; comparisons after folding are ordinal.
+ ///
+ /// The text to fold.
+ /// The invariant lowercase form.
+ public static string Fold(string text) => text.ToLowerInvariant();
+
///
/// Opens (creating if needed) a database file, applies the mandatory
/// PRAGMAs and runs any pending migrations.
@@ -27,6 +41,7 @@ public static class SqliteDatabase {
connection.Open();
try {
ApplyPragmas(connection);
+ RegisterFunctions(connection);
ApplyMigrations(connection, migrations);
return connection;
} catch {
@@ -59,6 +74,17 @@ public static class SqliteDatabase {
Execute(connection, "PRAGMA foreign_keys=ON;");
}
+ ///
+ /// Registers the custom SQL functions; like the PRAGMAs they live on the
+ /// connection and must be registered on every one.
+ ///
+ /// An open database connection.
+ internal static void RegisterFunctions(SqliteConnection connection) {
+ // NULL never reaches it from the NOT NULL text columns, but stays NULL-safe regardless.
+ connection.CreateFunction(
+ FoldFunctionName, text => text is null ? null : Fold(text), isDeterministic: true);
+ }
+
///
/// Runs every migration newer than the stored user_version, each in
/// its own transaction together with its version bump.