diff --git a/USAGE.md b/USAGE.md index 12bef41..f48f9e6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -226,6 +226,7 @@ These are invalid: |---|---| | Unbalanced parentheses | `(tag:bug`, `tag:bug)` | | Parentheses nested more than 64 levels deep | `((((…a…))))` with 65 `(` | +| More than 256 search terms (words, phrases and qualifiers) | 257 words | | An operator with a missing side | `bug AND`, `OR bug` | | Consecutive operators | `a AND OR b` | | A qualifier without a value | `tag:` | diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs index c28b77e..6ccd7c1 100644 --- a/YKanBan.Tests/Search/SearchQueryParserTests.cs +++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs @@ -248,6 +248,28 @@ public class SearchQueryParserTests Assert.IsNotNull(SearchQueryParser.Parse(groups)); } + [TestMethod] + public void AtomCountAtTheLimitParses() + { + Assert.IsNotNull(SearchQueryParser.Parse(string.Join(" ", Enumerable.Repeat("a", SearchQueryParser.MaxAtomCount)))); + } + + [TestMethod] + [DataRow(" ")] + [DataRow(" OR ")] + public void AtomCountBeyondTheLimitIsAnError(string separator) + { + AssertSyntaxError(string.Join(separator, Enumerable.Repeat("a", SearchQueryParser.MaxAtomCount + 1))); + } + + [TestMethod] + public void QualifiersAndPhrasesCountTowardsTheAtomLimit() + { + int half = SearchQueryParser.MaxAtomCount / 2; + string text = string.Join(" ", Enumerable.Repeat("tag:\"a b\"", half).Concat(Enumerable.Repeat("\"c\"", half + 1))); + AssertSyntaxError(text); + } + [TestMethod] public void VeryDeepNestingIsAnErrorRatherThanACrash() { diff --git a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs index 9a81a1d..f643517 100644 --- a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs +++ b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs @@ -190,4 +190,20 @@ public class SearchSqlCompilerTests CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("tag:äpfel")); CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("column:érable")); } + + [TestMethod] + public void LargestAcceptedQueriesRunWithinTheSqliteExpressionDepth() + { + using var fixture = new SearchFixture(); + int count = SearchQueryParser.MaxAtomCount; + int depth = SearchQueryParser.MaxNestingDepth; + string nested = string.Concat(Enumerable.Repeat("(tag:x OR ", depth)) + + string.Join(" ", Enumerable.Repeat("apple", count - depth)) + + new string(')', depth); + + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" ", Enumerable.Repeat("apple", count)))); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" OR ", Enumerable.Repeat("apple", count)))); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" ", Enumerable.Repeat("title:apple", count)))); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(nested)); + } } diff --git a/YKanBan/Search/SearchQueryParser.cs b/YKanBan/Search/SearchQueryParser.cs index f1880c2..8d66453 100644 --- a/YKanBan/Search/SearchQueryParser.cs +++ b/YKanBan/Search/SearchQueryParser.cs @@ -9,8 +9,8 @@ namespace YKanBan.Search; /// phrases, invalid escapes, a backslash in a bare word, unknown qualifier /// keys, qualifiers without a value, unquoted values containing ':' or equal /// to AND/OR, id values that are not an unquoted positive integer with an -/// optional '#', and parentheses nested deeper than ) -/// raises . +/// optional '#', parentheses nested deeper than , +/// and more than atoms) raises . /// /// Only uppercase AND/OR are operators (lowercase ones are plain words); AND — /// explicit or by adjacency — binds tighter than OR; parentheses group. Blank @@ -25,6 +25,14 @@ public static class SearchQueryParser /// public const int MaxNestingDepth = 64; + /// + /// Most atoms (words, phrases and qualifiers) accepted in one expression. + /// The compiled SQL chains every atom into one expression, and SQLite + /// rejects expressions nested deeper than 1000 levels; together with + /// this keeps every valid query well below that. + /// + public const int MaxAtomCount = 256; + /// The recognized qualifier keys; add new entries here to extend the grammar. private static readonly (string Key, QualifierKind Kind)[] QualifierTable = [ @@ -122,7 +130,9 @@ public static class SearchQueryParser { List tokens = SearchLexer.Tokenize(text).ToList(); EnsureNestingWithinLimit(tokens); - return Query.ParseOrThrow(tokens); + SearchNode query = Query.ParseOrThrow(tokens); + EnsureAtomCountWithinLimit(query); + return query; } catch (ParseException exception) { @@ -152,6 +162,31 @@ public static class SearchQueryParser } } + /// + /// Rejects expressions with more than atoms. + /// + /// The parsed query. + /// The expression has too many atoms. + private static void EnsureAtomCountWithinLimit(SearchNode query) + { + if (CountAtoms(query) > MaxAtomCount) + { + throw new SearchSyntaxException($"The expression has more than {MaxAtomCount} search terms."); + } + } + + /// + /// Counts the atoms (leaves) of a parsed query. + /// + /// The node to count. + /// The number of atoms under the node. + private static int CountAtoms(SearchNode node) => node switch + { + SearchNode.And and => and.Terms.Sum(CountAtoms), + SearchNode.Or or => or.Terms.Sum(CountAtoms), + _ => 1, + }; + #region Atom construction /// diff --git a/docs/PLAN.md b/docs/PLAN.md index 92ced0d..7a273d5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,7 +16,7 @@ - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) -- 实现回补:搜索括号嵌套上限 64 层,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4) +- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) ## 1. 项目概述 @@ -295,6 +295,7 @@ phrase := '"' 一个或多个字符 '"' (内部支持转义:\" → 字面 - **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合: - 括号不配对 - 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度) + - 原子(裸词、短语、限定符)超过 256 个(编译出的 SQL 把全部原子串成一个表达式,SQLite 表达式深度上限为 1000) - 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`) - 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`) - 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`