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.