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,206 @@
using System.Globalization;
using System.Text;
using YKanBan.Export;
using YKanBan.Models;
namespace YKanBan.Tests.Export;
/// <summary>
/// Tests for the Markdown exporter. Mutates the global
/// <see cref="Resources.Culture"/>, therefore marked do-not-parallelize.
/// Expected documents are assembled from the same ResX template keys as the
/// exporter (plus fixed fixture values), so no localized text is hard-coded
/// here.
/// </summary>
[TestClass]
[DoNotParallelize]
public class MarkdownExporterTests
{
private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero);
/// <summary>
/// Builds a local wall-clock moment, matching the exporter's conversion path.
/// </summary>
/// <param name="year">The year.</param>
/// <param name="month">The month.</param>
/// <param name="day">The day.</param>
/// <param name="hour">The hour.</param>
/// <param name="minute">The minute.</param>
/// <returns>The local time.</returns>
private static DateTimeOffset Local(int year, int month, int day, int hour, int minute) =>
new DateTimeOffset(new DateTime(year, month, day, hour, minute, 0)).ToLocalTime();
/// <summary>
/// Converts a local wall-clock time to Unix seconds.
/// </summary>
/// <param name="year">The year.</param>
/// <param name="month">The month.</param>
/// <param name="day">The day.</param>
/// <param name="hour">The hour.</param>
/// <param name="minute">The minute.</param>
/// <returns>The Unix timestamp.</returns>
private static long UnixTimeOf(int year, int month, int day, int hour, int minute) =>
Local(year, month, day, hour, minute).ToUnixTimeSeconds();
/// <summary>
/// Builds the board fixture: one working column with two cards, one empty column and two tags.
/// </summary>
/// <returns>The columns, cards and tag/usage tuples.</returns>
private static (IReadOnlyList<ColumnModel> Columns, IReadOnlyList<CardModel> Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard()
{
long created = UnixTimeOf(2026, 9, 1, 10, 0);
long updated = UnixTimeOf(2026, 9, 28, 18, 30);
var columns = new List<ColumnModel>
{
new(1, "In Progress", "The work column", 100, 100),
new(2, "Empty", string.Empty, 100, 100),
};
var bug = new TagModel(1, "bug", RgbColor.Parse("#ff0000"), string.Empty);
var ui = new TagModel(2, "ui", RgbColor.Parse("#00ff00"), "Interface work");
var cards = new List<CardModel>
{
new(61, 1, "Fix login", "Body text", created, updated, [bug, ui]),
new(62, 1, string.Empty, string.Empty, created, updated, []),
};
var tags = new List<(TagModel, long)> { (bug, 1), (ui, 1) };
return (columns, cards, tags);
}
/// <summary>
/// Renders the fixture board with the exporter under the current culture.
/// </summary>
/// <returns>The produced Markdown document.</returns>
private static string ExportBoard()
{
(IReadOnlyList<ColumnModel> columns, IReadOnlyList<CardModel> cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard();
return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt);
}
/// <summary>
/// Assembles the expected document from the ResX template keys under the current culture.
/// </summary>
/// <param name="culture">The UI culture.</param>
/// <returns>The expected Markdown document.</returns>
private static string BuildExpected(CultureInfo culture)
{
string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture);
string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture);
var builder = new StringBuilder();
// Header: workspace name and the export timestamp.
builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, "MyRepo"));
builder.AppendLine(string.Format(
culture, Resources.Export_ExportedAtLine, ExportedAt.ToString(Resources.Export_TimePattern, culture)));
builder.AppendLine();
// First column with its two cards.
builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, "In Progress", 2));
builder.AppendLine("The work column");
builder.AppendLine();
builder.AppendLine("### #61 Fix login");
builder.AppendLine("Body text");
builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, "`bug` `ui`"));
builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
builder.AppendLine();
builder.AppendLine("### #62");
builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
builder.AppendLine();
// Empty column is kept.
builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, "Empty", 0));
builder.AppendLine();
// Tag summary table.
builder.AppendLine(Resources.Export_TagsSummaryHeading);
builder.AppendLine(Resources.Export_TagsTableHeader);
builder.AppendLine("|---|---|---|---|");
builder.AppendLine("| bug | #ff0000 | | 1 |");
builder.AppendLine("| ui | #00ff00 | Interface work | 1 |");
return builder.ToString();
}
/// <summary>
/// Normalizes CRLF line endings so expected and actual compare consistently.
/// </summary>
/// <param name="text">The text to normalize.</param>
/// <returns>The text with normalized line endings.</returns>
private static string Normalize(string text) => text.Replace("\r\n", "\n");
/// <summary>
/// Asserts that the exporter output matches the resource-built expected document.
/// </summary>
/// <param name="culture">The UI culture to test.</param>
private static void AssertOutputMatches(CultureInfo culture)
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = culture;
Assert.AreEqual(Normalize(BuildExpected(culture)), Normalize(ExportBoard()));
}
finally
{
Resources.Culture = original;
}
}
[TestMethod]
public void EnglishOutputMatchesTemplate()
{
AssertOutputMatches(new CultureInfo("en"));
}
[TestMethod]
public void ChineseOutputMatchesTemplate()
{
AssertOutputMatches(new CultureInfo("zh-Hans"));
}
[TestMethod]
public void FileNameFollowsLanguage()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("en");
Assert.AreEqual(
string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt),
MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
Resources.Culture = new CultureInfo("zh-Hans");
Assert.AreEqual(
string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt),
MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
}
finally
{
Resources.Culture = original;
}
}
[TestMethod]
public void TagTableCellsEscapePipesAndLineBreaks()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("en");
var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond");
string markdown = MarkdownExporter.Export(
"W",
[new ColumnModel(1, "C", string.Empty, 1, 1)],
[],
[(tag, 0L)],
ExportedAt);
StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |");
}
finally
{
Resources.Culture = original;
}
}
}
@@ -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);
}
}
+30
View File
@@ -25,4 +25,34 @@
<data name="PresetColumn_Done" xml:space="preserve">
<value>Done</value>
</data>
<data name="Export_FileNamePattern" xml:space="preserve">
<value>{0}-export-{1:yyyyMMdd-HHmmss}.md</value>
</data>
<data name="Export_TimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm:ss</value>
</data>
<data name="Export_CardTimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm</value>
</data>
<data name="Export_WorkspaceHeading" xml:space="preserve">
<value># {0}</value>
</data>
<data name="Export_ExportedAtLine" xml:space="preserve">
<value>&gt; Exported at: {0}</value>
</data>
<data name="Export_ColumnHeading" xml:space="preserve">
<value>## Column: {0} ({1})</value>
</data>
<data name="Export_TagsLine" xml:space="preserve">
<value>- Tags: {0}</value>
</data>
<data name="Export_CreatedModifiedLine" xml:space="preserve">
<value>- Created: {0} Modified: {1}</value>
</data>
<data name="Export_TagsSummaryHeading" xml:space="preserve">
<value>## Tags</value>
</data>
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| Name | Color | Description | Usage |</value>
</data>
</root>
@@ -25,4 +25,34 @@
<data name="PresetColumn_Done" xml:space="preserve">
<value>已完成</value>
</data>
<data name="Export_FileNamePattern" xml:space="preserve">
<value>{0}-导出-{1:yyyyMMdd-HHmmss}.md</value>
</data>
<data name="Export_TimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="Export_CardTimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="Export_WorkspaceHeading" xml:space="preserve">
<value># {0}</value>
</data>
<data name="Export_ExportedAtLine" xml:space="preserve">
<value>&gt; 导出时间:{0}</value>
</data>
<data name="Export_ColumnHeading" xml:space="preserve">
<value>## 列:{0}({1})</value>
</data>
<data name="Export_TagsLine" xml:space="preserve">
<value>- 标签:{0}</value>
</data>
<data name="Export_CreatedModifiedLine" xml:space="preserve">
<value>- 创建:{0} 修改:{1}</value>
</data>
<data name="Export_TagsSummaryHeading" xml:space="preserve">
<value>## 标签总表</value>
</data>
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
</data>
</root>
+148
View File
@@ -0,0 +1,148 @@
using System.Globalization;
using System.Text;
using YKanBan.Models;
namespace YKanBan.Export;
/// <summary>
/// Generates the human-readable Markdown snapshot of a whole board. All
/// template text and time formats come from the ResX resources in the current
/// UI language; the export timestamp uses the per-language pattern, card
/// timestamps use the shared minute-precision pattern. The output is meant for
/// reading only — there is no re-import.
/// </summary>
public static class MarkdownExporter
{
/// <summary>
/// Renders the whole board to Markdown.
/// </summary>
/// <param name="workspaceName">Workspace display name (the folder name).</param>
/// <param name="columns">All columns, in id order; empty columns are kept.</param>
/// <param name="cards">All cards; grouped by column, id order within a column.</param>
/// <param name="tags">All tags with usage counts, in id order.</param>
/// <param name="exportedAt">Export moment in local time.</param>
/// <returns>The Markdown document.</returns>
public static string Export(
string workspaceName,
IReadOnlyList<ColumnModel> columns,
IReadOnlyList<CardModel> cards,
IReadOnlyList<(TagModel Tag, long UsageCount)> tags,
DateTimeOffset exportedAt)
{
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
var builder = new StringBuilder();
// Header: workspace name and the export timestamp in the UI language's format.
builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, workspaceName));
builder.AppendLine(string.Format(
culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture)));
builder.AppendLine();
// Board body: every column in id order, empty ones included.
foreach (ColumnModel column in columns)
{
IReadOnlyList<CardModel> cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray();
builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count));
if (column.Description.Length > 0)
{
builder.AppendLine(SingleLine(column.Description));
}
builder.AppendLine();
foreach (CardModel card in cardsInColumn)
{
AppendCard(builder, card, culture);
}
}
AppendTagTable(builder, tags, culture);
return builder.ToString();
}
/// <summary>
/// Builds the default export file name in the UI language, for example
/// "MyRepo-export-20260930-142537.md".
/// </summary>
/// <param name="workspaceName">Workspace display name.</param>
/// <param name="exportedAt">Export moment.</param>
/// <returns>The file name.</returns>
public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt)
{
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt);
}
/// <summary>
/// Appends one card block: heading, body, tags line and timestamps line.
/// </summary>
/// <param name="builder">The output builder.</param>
/// <param name="card">The card to render.</param>
/// <param name="culture">The UI culture.</param>
private static void AppendCard(StringBuilder builder, CardModel card, CultureInfo culture)
{
// Heading: "#id title"; an untitled card shows only the id.
builder.AppendLine(card.Title.Length == 0
? $"### #{card.Id}"
: $"### #{card.Id} {SingleLine(card.Title)}");
if (card.Content.Length > 0)
{
builder.AppendLine(card.Content);
}
if (card.Tags.Count > 0)
{
string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`"));
builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names));
}
string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_CardTimePattern, culture);
string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_CardTimePattern, culture);
builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
builder.AppendLine();
}
/// <summary>
/// Appends the tag summary table with usage counts; tags with zero usage are included.
/// </summary>
/// <param name="builder">The output builder.</param>
/// <param name="tags">The tags and their usage counts.</param>
/// <param name="culture">The UI culture.</param>
private static void AppendTagTable(
StringBuilder builder,
IReadOnlyList<(TagModel Tag, long UsageCount)> tags,
CultureInfo culture)
{
builder.AppendLine(Resources.Export_TagsSummaryHeading);
builder.AppendLine(Resources.Export_TagsTableHeader);
builder.AppendLine("|---|---|---|---|");
foreach ((TagModel tag, long usage) in tags)
{
// '|' and line breaks would break the table row; neutralize them.
builder.AppendLine($"| {Cell(tag.Name)} | {tag.Color} | {Cell(tag.Description)} | {usage} |");
}
}
/// <summary>
/// Formats a table cell: single line with escaped pipe characters.
/// </summary>
/// <param name="text">The raw cell text.</param>
/// <returns>The escaped single-line text.</returns>
private static string Cell(string text) => SingleLine(text).Replace("|", "\\|");
/// <summary>
/// Collapses line breaks so the text stays on one Markdown line.
/// </summary>
/// <param name="text">The raw text.</param>
/// <returns>The text without line breaks.</returns>
private static string SingleLine(string text) => text.Replace("\r", string.Empty).Replace("\n", " ");
/// <summary>
/// Converts stored Unix seconds to a local-time <see cref="DateTimeOffset"/>.
/// </summary>
/// <param name="unixSeconds">The stored timestamp.</param>
/// <returns>The local time.</returns>
private static DateTimeOffset ToLocalTime(long unixSeconds) =>
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime();
}
+104
View File
@@ -0,0 +1,104 @@
using Pidgin;
namespace YKanBan.Search;
/// <summary>
/// Lexical tokens of the search syntax.
/// </summary>
public abstract record SearchToken
{
/// <summary>
/// A bare word; qualifier detection happens later from the text.
/// </summary>
/// <param name="Text">The word text.</param>
public sealed record Word(string Text) : SearchToken;
/// <summary>
/// A quoted phrase with its escapes already resolved.
/// </summary>
/// <param name="Text">The unescaped phrase content.</param>
public sealed record Phrase(string Text) : SearchToken;
/// <summary>
/// An opening parenthesis.
/// </summary>
public sealed record OpenParen : SearchToken;
/// <summary>
/// A closing parenthesis.
/// </summary>
public sealed record CloseParen : SearchToken;
}
/// <summary>
/// Pidgin-based lexer for the search syntax. Splits the input into words,
/// quoted phrases and parentheses; whitespace only separates tokens.
///
/// The lexer is strict: inside a phrase only <c>\"</c> and <c>\\</c> 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.
/// </summary>
public static class SearchLexer
{
/// <summary>A valid phrase escape sequence: <c>\"</c> or <c>\\</c>.</summary>
private static readonly Parser<char, char> PhraseEscape =
Parser.Try(Parser.String("\\\"").Select(_ => '"'))
.Or(Parser.String("\\\\").Select(_ => '\\'));
/// <summary>One phrase character: an escape or any character except a quote or backslash.</summary>
private static readonly Parser<char, char> PhraseCharacter =
PhraseEscape.Or(Parser<char>.Token(character => character != '"' && character != '\\'));
private static readonly Parser<char, SearchToken> PhraseParser =
from openingQuote in Parser.Char('"')
from content in PhraseCharacter.Many()
from closingQuote in Parser.Char('"')
select (SearchToken)new SearchToken.Phrase(new string(content.ToArray()));
private static readonly Parser<char, SearchToken> OpenParenParser =
Parser.Char('(').Select(_ => (SearchToken)new SearchToken.OpenParen());
private static readonly Parser<char, SearchToken> CloseParenParser =
Parser.Char(')').Select(_ => (SearchToken)new SearchToken.CloseParen());
private static readonly Parser<char, SearchToken> WordParser =
Parser<char>.Token(character =>
!char.IsWhiteSpace(character) && character != '(' && character != ')' && character != '"')
.AtLeastOnce()
.Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray())));
private static readonly Parser<char, SearchToken> TokenParser =
Parser.SkipWhitespaces.Then(
PhraseParser
.Or(OpenParenParser)
.Or(CloseParenParser)
.Or(WordParser));
// 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<char, IReadOnlyList<SearchToken>> Lexer =
Parser.Try(TokenParser).Many()
.Before(Parser.SkipWhitespaces)
.Before(Parser<char>.End)
.Select(tokens => (IReadOnlyList<SearchToken>)tokens.ToList());
/// <summary>
/// Tokenizes the search text.
/// </summary>
/// <param name="input">The raw search text.</param>
/// <returns>The token stream.</returns>
/// <exception cref="SearchSyntaxException">The text contains an invalid escape or an unterminated phrase.</exception>
public static IReadOnlyList<SearchToken> Tokenize(string input)
{
try
{
return Lexer.ParseOrThrow(input);
}
catch (ParseException exception)
{
throw new SearchSyntaxException(exception.Message, exception);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// YYC MARK: C# records synthesize equality by running EqualityComparer<T>.Default
// on each positional property. For collection properties (IReadOnlyList<>,
// ImmutableArray<>, arrays, ...) that comparer falls back to REFERENCE equality,
// so the Or/And nodes below deliberately do NOT compare structurally. The only
// structural comparison lives in the test helper (SearchNodeComparer); production
// code never compares AST nodes.
namespace YKanBan.Search;
/// <summary>
/// Root of the search query AST. Search keywords are fixed English grammar
/// and are never localized.
/// </summary>
public abstract record SearchNode
{
/// <summary>
/// Disjunction: matches when any term matches (OR).
/// </summary>
/// <param name="Terms">The alternative terms.</param>
public sealed record Or(IReadOnlyList<SearchNode> Terms) : SearchNode;
/// <summary>
/// Conjunction: matches when all terms match (explicit AND or adjacency).
/// </summary>
/// <param name="Terms">The required terms.</param>
public sealed record And(IReadOnlyList<SearchNode> Terms) : SearchNode;
/// <summary>
/// A bare word or phrase matched as a substring of the title OR content.
/// </summary>
/// <param name="Text">The search text.</param>
public sealed record FreeText(string Text) : SearchNode;
/// <summary>
/// A substring match scoped to one card field.
/// </summary>
/// <param name="Field">The card field to match against.</param>
/// <param name="Text">The search text.</param>
public sealed record FieldText(SearchField Field, string Text) : SearchNode;
/// <summary>
/// An exact, case-insensitive tag name match.
/// </summary>
/// <param name="Name">The tag name to match.</param>
public sealed record TagName(string Name) : SearchNode;
/// <summary>
/// An exact, case-insensitive column title match.
/// </summary>
/// <param name="Title">The column title to match.</param>
public sealed record ColumnTitle(string Title) : SearchNode;
/// <summary>
/// An exact card id match.
/// </summary>
/// <param name="Id">The card id to match.</param>
public sealed record CardId(long Id) : SearchNode;
}
/// <summary>
/// The card fields a <see cref="SearchNode.FieldText"/> atom can scope to.
/// </summary>
public enum SearchField
{
/// <summary>The card title.</summary>
Title,
/// <summary>The card content body.</summary>
Content,
}
+257
View File
@@ -0,0 +1,257 @@
using System.Globalization;
using Pidgin;
namespace YKanBan.Search;
/// <summary>
/// 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 <see cref="SearchSyntaxException"/>.
///
/// Only uppercase AND/OR are operators (lowercase ones are plain words); AND —
/// explicit or by adjacency — binds tighter than OR; parentheses group. Blank
/// input returns null and means "show everything". Search keywords and
/// qualifier keys are fixed English grammar and are never localized.
/// </summary>
public static class SearchQueryParser
{
/// <summary>The recognized qualifier keys; add new entries here to extend the grammar.</summary>
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
("tag", QualifierKind.Tag),
("title", QualifierKind.Title),
("content", QualifierKind.Content),
("id", QualifierKind.Id),
("column", QualifierKind.Column),
];
#region Token primitives
private static readonly Parser<SearchToken, SearchToken> AndKeyword =
Parser<SearchToken>.Token(token => token is SearchToken.Word { Text: "AND" });
private static readonly Parser<SearchToken, SearchToken> OrKeyword =
Parser<SearchToken>.Token(token => token is SearchToken.Word { Text: "OR" });
private static readonly Parser<SearchToken, SearchToken> OpenParen =
Parser<SearchToken>.Token(token => token is SearchToken.OpenParen);
private static readonly Parser<SearchToken, SearchToken> CloseParen =
Parser<SearchToken>.Token(token => token is SearchToken.CloseParen);
/// <summary>The text of a phrase token.</summary>
private static readonly Parser<SearchToken, string> PhraseText =
Parser<SearchToken>.Token(token => token is SearchToken.Phrase)
.Select(token => ((SearchToken.Phrase)token).Text);
/// <summary>The text of a word token, operator keywords excluded (they never start an operand).</summary>
private static readonly Parser<SearchToken, string> NonKeywordWord =
Parser<SearchToken>.Token(token => token is SearchToken.Word word && word.Text != "AND" && word.Text != "OR")
.Select(token => ((SearchToken.Word)token).Text);
#endregion
#region Grammar
// A bare phrase matched against title or content.
private static readonly Parser<SearchToken, SearchNode> FreePhrase =
PhraseText.Select(text => (SearchNode)new SearchNode.FreeText(text));
// A bare word: a "key:value" qualifier when the prefix matches, otherwise free text.
private static readonly Parser<SearchToken, SearchNode> WordAtom =
NonKeywordWord.Bind(QualifierFrom);
// '(' orExpr ')' — the deferred method call breaks the
// Parenthesized → OrExpr → AndExpr → Operand → Parenthesized recursion cycle.
private static readonly Parser<SearchToken, SearchNode> Parenthesized =
OpenParen.Then(_ => OrExprDeferred()).Before(CloseParen);
// operand := '(' orExpr ')' | qualifier-or-word | phrase
private static readonly Parser<SearchToken, SearchNode> Operand =
Parenthesized.Or(WordAtom).Or(FreePhrase);
// One andExpr operand together with its optional explicit AND keyword.
private static readonly Parser<SearchToken, SearchNode> AndPrefixedOperand =
AndKeyword.Optional().Then(Operand);
// andExpr := operand (AND? operand)*
private static readonly Parser<SearchToken, SearchNode> AndExpr =
Operand.Bind(first =>
AndPrefixedOperand.Many()
.Select(rest => FoldAnd(first, rest)));
// orExpr := andExpr (OR andExpr)+
private static readonly Parser<SearchToken, SearchNode> OrExpr =
AndExpr.SeparatedAtLeastOnce(OrKeyword)
.Select(FoldOr);
// The full query: orExpr consuming the whole token stream.
private static readonly Parser<SearchToken, SearchNode> Query =
OrExpr.Before(Parser<SearchToken>.End);
/// <summary>Defers the OrExpr field read to parse time (recursion cycle breaker).</summary>
/// <returns>The OR-expression parser.</returns>
private static Parser<SearchToken, SearchNode> OrExprDeferred() => OrExpr;
#endregion
/// <summary>
/// Parses a search box text.
/// </summary>
/// <param name="text">The raw search text.</param>
/// <returns>The parsed query, or <see langword="null"/> for blank input (meaning: show everything).</returns>
/// <exception cref="SearchSyntaxException">The text violates the grammar.</exception>
public static SearchNode? Parse(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
try
{
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
return Query.ParseOrThrow(tokens);
}
catch (ParseException exception)
{
throw new SearchSyntaxException(exception.Message, exception);
}
}
#region Atom construction
/// <summary>
/// Builds the parser for one word operand: a qualifier atom, an unknown-key
/// failure, or free text.
/// </summary>
/// <param name="word">The word token text.</param>
/// <returns>The parser producing the operand node.</returns>
private static Parser<SearchToken, SearchNode> 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)))
{
return Parser<SearchToken>.Return<SearchNode>(new SearchNode.FreeText(word));
}
string key = word[..colonIndex];
string value = word[(colonIndex + 1)..];
if (!TryLookupQualifier(key, out QualifierKind kind))
{
return Parser<SearchToken>.Fail<SearchNode>($"Unknown qualifier key '{key}'.");
}
// "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);
}
/// <summary>
/// Builds the atom parser for a qualifier value, failing strictly on a non-numeric id.
/// </summary>
/// <param name="kind">The qualifier kind.</param>
/// <param name="value">The qualifier value.</param>
/// <returns>The parser producing the atom node.</returns>
private static Parser<SearchToken, SearchNode> AtomParser(QualifierKind kind, string value) =>
AtomFrom(kind, value) is { } atom
? Parser<SearchToken>.Return(atom)
: Parser<SearchToken>.Fail<SearchNode>($"The id qualifier expects an integer, got '{value}'.");
/// <summary>
/// Builds the atom for a qualifier value; null when an id value is not an integer.
/// </summary>
/// <param name="kind">The qualifier kind.</param>
/// <param name="value">The qualifier value.</param>
/// <returns>The atom node, or <see langword="null"/> for an invalid id value.</returns>
private static SearchNode? AtomFrom(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,
};
/// <summary>
/// Looks up a qualifier key in the extension table with ordinal (case-sensitive) matching.
/// </summary>
/// <param name="key">The key text before the colon.</param>
/// <param name="kind">The matching qualifier kind.</param>
/// <returns><see langword="true"/> when the key is known.</returns>
private static bool TryLookupQualifier(string key, out QualifierKind kind)
{
foreach ((string candidate, QualifierKind candidateKind) in QualifierTable)
{
if (string.Equals(candidate, key, StringComparison.Ordinal))
{
kind = candidateKind;
return true;
}
}
kind = default;
return false;
}
/// <summary>
/// Returns whether the text looks like a qualifier key: a non-empty run of
/// letters, digits or underscores.
/// </summary>
/// <param name="text">The candidate key text.</param>
/// <returns><see langword="true"/> when every character is a word character.</returns>
private static bool IsQualifierKeyCandidate(ReadOnlySpan<char> text)
{
foreach (char character in text)
{
if (!(char.IsLetterOrDigit(character) || character == '_'))
{
return false;
}
}
return true;
}
/// <summary>
/// Collapses an AND chain into a single node.
/// </summary>
/// <param name="first">The first operand.</param>
/// <param name="rest">The remaining operands.</param>
/// <returns>The single operand, or an <see cref="SearchNode.And"/> node.</returns>
private static SearchNode FoldAnd(SearchNode first, IEnumerable<SearchNode> rest)
{
List<SearchNode> terms = [first, .. rest];
return terms.Count == 1 ? terms[0] : new SearchNode.And(terms);
}
/// <summary>
/// Collapses an OR chain into a single node.
/// </summary>
/// <param name="terms">The operands.</param>
/// <returns>The single operand, or a <see cref="SearchNode.Or"/> node.</returns>
private static SearchNode FoldOr(IEnumerable<SearchNode> terms)
{
List<SearchNode> list = terms.ToList();
return list.Count == 1 ? list[0] : new SearchNode.Or(list);
}
#endregion
/// <summary>The recognized qualifier kinds.</summary>
private enum QualifierKind
{
Tag,
Title,
Content,
Id,
Column,
}
}
+107
View File
@@ -0,0 +1,107 @@
namespace YKanBan.Search;
/// <summary>
/// A compiled search predicate: a SQL fragment over the "c" (cards) alias plus
/// its bound parameters.
/// </summary>
/// <param name="Predicate">SQL boolean expression, for <c>SELECT ... FROM cards c WHERE {Predicate}</c>.</param>
/// <param name="Parameters">Named parameters referenced by the predicate, in order.</param>
public sealed record CompiledSearch(string Predicate, IReadOnlyList<(string Name, object Value)> Parameters);
/// <summary>
/// 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.
/// </summary>
public static class SearchSqlCompiler
{
/// <summary>
/// Compiles the query into a SQL predicate.
/// </summary>
/// <param name="query">The parsed search AST.</param>
/// <returns>The compiled predicate and its parameters.</returns>
public static CompiledSearch Compile(SearchNode query)
{
var parameters = new List<(string Name, object Value)>();
string predicate = Build(query, parameters);
return new CompiledSearch(predicate, parameters);
}
/// <summary>
/// Recursively builds the SQL expression for one node.
/// </summary>
/// <param name="node">The AST node to compile.</param>
/// <param name="parameters">The parameter accumulator.</param>
/// <returns>The SQL boolean expression for the node.</returns>
private static string Build(SearchNode node, List<(string Name, object Value)> parameters)
{
switch (node)
{
case SearchNode.And and:
return "(" + string.Join(" AND ", and.Terms.Select(term => Build(term, parameters))) + ")";
case SearchNode.Or or:
return "(" + string.Join(" OR ", or.Terms.Select(term => Build(term, parameters))) + ")";
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 '\\')";
}
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 '\\'";
}
case SearchNode.TagName tag:
{
string param = AddParameter(parameters, 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})";
}
case SearchNode.ColumnTitle column:
{
string param = AddParameter(parameters, column.Title);
return $"c.column_id IN (SELECT id FROM columns WHERE title COLLATE NOCASE = {param})";
}
case SearchNode.CardId id:
{
string param = AddParameter(parameters, id.Id);
return $"c.id = {param}";
}
default:
throw new InvalidOperationException($"Unsupported search node: {node.GetType().Name}");
}
}
/// <summary>
/// Binds a value under a fresh name and returns the SQL placeholder.
/// </summary>
/// <param name="parameters">The parameter accumulator.</param>
/// <param name="value">The value to bind.</param>
/// <returns>The placeholder text.</returns>
private static string AddParameter(List<(string Name, object Value)> parameters, object value)
{
string name = "$p" + parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture);
parameters.Add((name, value));
return name;
}
/// <summary>
/// Escapes LIKE wildcards and the escape character inside user text.
/// </summary>
/// <param name="text">The raw user text.</param>
/// <returns>The text with <c>\</c>, <c>%</c> and <c>_</c> escaped.</returns>
private static string EscapeLike(string text) =>
text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
}
+20
View File
@@ -0,0 +1,20 @@
namespace YKanBan.Search;
/// <summary>
/// Thrown when the search text violates the search grammar. Carries the
/// underlying parser's descriptive English message; the UI surfaces it as a
/// search error instead of coupling to Pidgin types. The message is never
/// localized.
/// </summary>
public sealed class SearchSyntaxException : Exception
{
/// <summary>
/// Initializes the exception with a descriptive English message.
/// </summary>
/// <param name="message">The English diagnostic message.</param>
/// <param name="innerException">The underlying parse failure.</param>
public SearchSyntaxException(string message, Exception innerException)
: base(message, innerException)
{
}
}