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"`)、裸词中出现 `\`