feat: add search parser, sql compiler and markdown export

This commit is contained in:
yyc12345 committed 2026-10-02 22:32:33 +08:00
1 parent 941e22c7ff
commit 22a0f61cb5
12 files changed
+1510

No files matched your search

@@ -0,0 +1,69 @@
using YKanBan.Search;
namespace YKanBan.Tests.Search;
/// <summary>
/// Structural comparer for search AST nodes, used by assertions only. The AST
/// records compare by reference for their Terms collections by design (see the
/// YYC MARK note in SearchNodes.cs), so tests provide equality explicitly.
/// </summary>
public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
{
/// <summary>
/// Gets the shared comparer instance.
/// </summary>
public static SearchNodeComparer Instance { get; } = new();
/// <summary>
/// Determines structural equality of two nodes.
/// </summary>
/// <param name="x">The first node.</param>
/// <param name="y">The second node.</param>
/// <returns><see langword="true"/> when the nodes are structurally equal.</returns>
public bool Equals(SearchNode? x, SearchNode? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null || x.GetType() != y.GetType())
{
return false;
}
return (x, y) switch
{
(SearchNode.Or a, SearchNode.Or b) => SequencesEqual(a.Terms, b.Terms),
(SearchNode.And a, SearchNode.And b) => SequencesEqual(a.Terms, b.Terms),
(SearchNode.FreeText a, SearchNode.FreeText b) => a.Text == b.Text,
(SearchNode.FieldText a, SearchNode.FieldText b) => a.Field == b.Field && a.Text == b.Text,
(SearchNode.TagName a, SearchNode.TagName b) => a.Name == b.Name,
(SearchNode.ColumnTitle a, SearchNode.ColumnTitle b) => a.Title == b.Title,
(SearchNode.CardId a, SearchNode.CardId b) => a.Id == b.Id,
_ => false,
};
}
/// <summary>
/// Not supported: the comparer exists for assertions, not hashing.
/// </summary>
/// <param name="obj">The node.</param>
/// <returns>Never returns.</returns>
public int GetHashCode(SearchNode obj) =>
throw new NotSupportedException("Structural comparison for assertions only; hashing is not supported.");
/// <summary>
/// Order-sensitive element-wise comparison.
/// </summary>
/// <param name="x">The first sequence.</param>
/// <param name="y">The second sequence.</param>
/// <returns><see langword="true"/> when both sequences match element-wise.</returns>
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y)
{
if (x.Count != y.Count)
{
return false;
}
return x.Zip(y).All(pair => Instance.Equals(pair.First, pair.Second));
}
}
@@ -0,0 +1,292 @@
using YKanBan.Search;
namespace YKanBan.Tests.Search;
/// <summary>
/// Tests for the search parser: the full grammar branches plus the strict
/// failure behavior (malformed input always throws
/// <see cref="SearchSyntaxException"/>).
/// </summary>
[TestClass]
public class SearchQueryParserTests
{
/// <summary>
/// Parses and asserts structural equality against the expected AST.
/// </summary>
/// <param name="expected">The expected AST.</param>
/// <param name="text">The search text.</param>
private static void AssertParsesTo(SearchNode expected, string text)
{
SearchNode? parsed = SearchQueryParser.Parse(text);
Assert.IsNotNull(parsed, $"Blank-looking input: '{text}'.");
Assert.AreEqual(expected, parsed, SearchNodeComparer.Instance);
}
/// <summary>
/// Asserts that the input violates the grammar.
/// </summary>
/// <param name="text">The search text.</param>
private static void AssertSyntaxError(string text)
{
Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse(text));
}
#region Successful parses
[TestMethod]
public void BlankInputReturnsNull()
{
Assert.IsNull(SearchQueryParser.Parse(string.Empty));
Assert.IsNull(SearchQueryParser.Parse(" "));
}
[TestMethod]
public void SingleWordIsFreeText()
{
AssertParsesTo(new SearchNode.FreeText("login"), "login");
}
[TestMethod]
public void AdjacentWordsAreImplicitAnd()
{
AssertParsesTo(
new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]),
"fix login");
}
[TestMethod]
public void ExplicitAndKeyword()
{
AssertParsesTo(
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
"a AND b");
}
[TestMethod]
public void OrKeyword()
{
AssertParsesTo(
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
"a OR b");
}
[TestMethod]
public void ImplicitAndBindsTighterThanOr()
{
AssertParsesTo(
new SearchNode.Or([
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
new SearchNode.FreeText("c")]),
"a b OR c");
}
[TestMethod]
public void ParenthesesForceGrouping()
{
AssertParsesTo(
new SearchNode.And([
new SearchNode.FreeText("a"),
new SearchNode.Or([new SearchNode.FreeText("b"), new SearchNode.FreeText("c")])]),
"a (b OR c)");
}
[TestMethod]
public void NestedParenthesesCollapse()
{
AssertParsesTo(
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
"((a OR b))");
}
[TestMethod]
public void LowercaseKeywordsArePlainWords()
{
AssertParsesTo(
new SearchNode.And([
new SearchNode.FreeText("a"),
new SearchNode.FreeText("and"),
new SearchNode.FreeText("or"),
new SearchNode.FreeText("b")]),
"a and or b");
}
[TestMethod]
public void PhraseIsFreeText()
{
AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\"");
}
[TestMethod]
public void QuotedOperatorKeywordIsFreeText()
{
// A quoted AND/OR is literal text, not an operator.
AssertParsesTo(new SearchNode.FreeText("AND"), "\"AND\"");
AssertParsesTo(
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("OR")]),
"a \"OR\"");
}
[TestMethod]
public void PhraseSupportsEscapes()
{
// Escaped quote and escaped backslash.
AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\"");
AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\"");
}
[TestMethod]
public void TagQualifier()
{
AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug");
}
[TestMethod]
public void TagQualifierAcceptsPhraseValue()
{
AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\"");
}
[TestMethod]
public void QualifierValueMayContainColons()
{
AssertParsesTo(new SearchNode.TagName("a:b"), "tag:a:b");
}
[TestMethod]
public void TitleAndContentQualifiers()
{
AssertParsesTo(new SearchNode.FieldText(SearchField.Title, "foo"), "title:foo");
AssertParsesTo(new SearchNode.FieldText(SearchField.Content, "foo"), "content:foo");
}
[TestMethod]
public void ColumnQualifierAcceptsPhraseValue()
{
AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\"");
}
[TestMethod]
public void IdQualifierParsesNumber()
{
AssertParsesTo(new SearchNode.CardId(42), "id:42");
AssertParsesTo(new SearchNode.CardId(12), "id:\"12\"");
}
[TestMethod]
public void ColonWithoutKeyPrefixIsFreeText()
{
AssertParsesTo(new SearchNode.FreeText(":foo"), ":foo");
}
#endregion
#region Strict failures
[TestMethod]
public void UnterminatedPhraseIsAnError()
{
AssertSyntaxError("\"abc def");
}
[TestMethod]
public void TrailingUnterminatedPhraseIsAnError()
{
AssertSyntaxError("a \"abc");
}
[TestMethod]
public void InvalidEscapeInPhraseIsAnError()
{
// A bare backslash (not followed by a quote or another backslash) is invalid.
AssertSyntaxError("\"a\\b\"");
}
[TestMethod]
public void InvalidEscapeInQualifierPhraseIsAnError()
{
AssertSyntaxError("tag:\"a\\x\"");
}
[TestMethod]
public void UnbalancedOpenParenIsAnError()
{
AssertSyntaxError("(a");
}
[TestMethod]
public void StrayCloseParenIsAnError()
{
AssertSyntaxError("a)");
}
[TestMethod]
public void EmptyParensAreAnError()
{
AssertSyntaxError("()");
}
[TestMethod]
public void DanglingOrIsAnError()
{
AssertSyntaxError("a OR");
}
[TestMethod]
public void DanglingAndIsAnError()
{
AssertSyntaxError("a AND");
}
[TestMethod]
public void LeadingAndIsAnError()
{
AssertSyntaxError("AND a");
}
[TestMethod]
public void OperatorAtOperandPositionIsAnError()
{
AssertSyntaxError("a AND OR b");
}
[TestMethod]
public void QualifierWithoutValueIsAnError()
{
AssertSyntaxError("tag:");
}
[TestMethod]
public void SpacedQualifierValueIsAnError()
{
AssertSyntaxError("tag: bug");
}
[TestMethod]
public void NonNumericIdIsAnError()
{
AssertSyntaxError("id:12x");
}
[TestMethod]
public void UnknownQualifierKeyIsAnError()
{
AssertSyntaxError("foo:bar");
}
[TestMethod]
public void QualifierKeysAreCaseSensitive()
{
// "Tag" is a key-like prefix but not the known lowercase "tag", so it is unknown.
AssertSyntaxError("Tag:x");
}
[TestMethod]
public void SyntaxErrorCarriesDescriptiveMessage()
{
SearchSyntaxException exception = Assert.ThrowsExactly<SearchSyntaxException>(
() => SearchQueryParser.Parse("a AND"));
Assert.IsFalse(string.IsNullOrEmpty(exception.Message));
}
#endregion
}
@@ -0,0 +1,178 @@
using Microsoft.Data.Sqlite;
using YKanBan.Search;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Search;
/// <summary>
/// Tests for the query-to-SQL compiler, executed end-to-end against a fixture
/// workspace database.
/// </summary>
[TestClass]
public class SearchSqlCompilerTests
{
/// <summary>
/// Owns a workspace with a fixed card/tag fixture.
/// </summary>
private sealed class SearchFixture : IDisposable
{
public TempDirectory Directory { get; } = new();
public SqliteConnection Connection { get; }
public SearchFixture()
{
WorkspaceInitializer.Initialize(Directory.FullPath);
using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath))
{
// 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);");
// 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);");
// Tags: mixed case names plus one containing spaces.
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('Bug', '#ff0000', '');");
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('UI', '#00ff00', '');");
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('release notes', '#0000ff', '');");
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (2, 2);");
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (3, 2);");
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (4, 3);");
}
Connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath);
}
/// <summary>
/// Runs a query and returns the ids of the matching cards.
/// </summary>
/// <param name="queryText">The search text.</param>
/// <returns>The matching card ids in id order.</returns>
public List<long> Search(string queryText)
{
SearchNode? query = SearchQueryParser.Parse(queryText);
Assert.IsNotNull(query, "Blank fixture queries are not expected here.");
CompiledSearch compiled = SearchSqlCompiler.Compile(query);
using var command = Connection.CreateCommand();
command.CommandText = $"SELECT c.id FROM cards c WHERE {compiled.Predicate} ORDER BY c.id ASC;";
foreach ((string name, object value) in compiled.Parameters)
{
command.Parameters.AddWithValue(name, value);
}
using var reader = command.ExecuteReader();
var ids = new List<long>();
while (reader.Read())
{
ids.Add(reader.GetInt64(0));
}
return ids;
}
public void Dispose()
{
Connection.Dispose();
Directory.Dispose();
}
}
[TestMethod]
public void FreeTextMatchesTitleOrContentCaseInsensitively()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple"));
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login"));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("APPLE"));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("pie"));
}
[TestMethod]
public void FieldQualifiersScopeToTheirField()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple"));
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana"));
Assert.AreEqual(0, fixture.Search("title:pie").Count);
}
[TestMethod]
public void TagQualifierMatchesExactlyAndCaseInsensitively()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("tag:bug"));
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("tag:ui"));
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("tag:\"release notes\""));
// Exact match: prefixes and substrings do not hit.
Assert.AreEqual(0, fixture.Search("tag:bu").Count);
Assert.AreEqual(0, fixture.Search("tag:release").Count);
}
[TestMethod]
public void ColumnQualifierMatchesColumnTitleExactly()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 1L, 4L }, fixture.Search("column:\"to do\""));
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("column:\"in progress\""));
// Not a prefix or substring match.
Assert.AreEqual(0, fixture.Search("column:To").Count);
Assert.AreEqual(0, fixture.Search("column:progress").Count);
}
[TestMethod]
public void IdQualifierMatchesExactly()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("id:3"));
Assert.AreEqual(0, fixture.Search("id:99").Count);
}
[TestMethod]
public void AndOrCombination()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("apple OR banana"));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple AND id:2"));
Assert.AreEqual(0, fixture.Search("apple AND id:1").Count);
}
[TestMethod]
public void ParenthesizedGrouping()
{
using var fixture = new SearchFixture();
// (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3).
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("(apple OR banana) AND column:\"In Progress\""));
// ...while column "To Do" holds cards 1,4 — the intersection is empty.
Assert.AreEqual(0, fixture.Search("(apple OR banana) AND column:\"To Do\"").Count);
}
[TestMethod]
public void LikeWildcardsInUserTextMatchLiterally()
{
using var fixture = new SearchFixture();
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("100%"));
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("a_b"));
}
[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);
}
}