From 7f10d4589800ccf99ac214df499c71f885176216 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 13:49:09 +0200 Subject: [PATCH] perf(ui): push task-list filtering into SQL LoadForListAsync loaded the entire tasks table (all lists, two Include joins) then filtered to the selected list in C#, never using idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes MatchExpression, an Expression> that EF Core can translate to SQL; a new TaskListFilterBase compiles it on demand for the existing in-memory Matches(). The primary match query now runs as db.Tasks.Where(filter.MatchExpression), with two small follow-up queries (scoped by parent-id IN-lists) for contextual planning-parent rows and pulled-in children, followed by an explicit re-sort to restore the global SortOrder/CreatedAt ordering Regroup() depends on. --- .../Filtering/Filters/ReviewFilter.cs | 11 ++-- .../Filtering/Filters/SmartFlagFilter.cs | 12 ++-- .../Filtering/Filters/StatusFilter.cs | 11 ++-- .../Filtering/Filters/TaskListFilterBase.cs | 27 +++++++++ .../Filtering/Filters/UserListFilter.cs | 10 ++-- .../Filtering/Interfaces/ITaskListFilter.cs | 4 ++ .../Islands/TasksIslandViewModel.cs | 55 +++++++++++++----- .../Filtering/MatchExpressionSqlTests.cs | 57 +++++++++++++++++++ 8 files changed, 152 insertions(+), 35 deletions(-) create mode 100644 src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs diff --git a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs index 50c479c4..43c23dfe 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs @@ -1,13 +1,12 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Filtering.Filters; -public sealed class ReviewFilter : ITaskListFilter +public sealed class ReviewFilter : TaskListFilterBase { - public string Id => "virtual:review"; - public bool Matches(TaskEntity t) => - t.Status == TaskStatus.WaitingForReview; - public bool ShouldCount(TaskEntity t) => Matches(t); - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + public override string Id => "virtual:review"; + protected override Expression> MatchExpr => t => t.Status == TaskStatus.WaitingForReview; + public override bool ShouldCount(TaskEntity t) => Matches(t); } diff --git a/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs index 636fbd4e..a054a43b 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Filter for a smart list keyed off a boolean/nullable task flag /// (My Day, Important, Planned). Counts only non-done matches. /// -public sealed class SmartFlagFilter(string id, Func flag) : ITaskListFilter +public sealed class SmartFlagFilter(string id, Expression> flag) : TaskListFilterBase { - public string Id => id; - public bool Matches(TaskEntity t) => flag(t); - public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + private readonly Func _flag = flag.Compile(); + + public override string Id => id; + protected override Expression> MatchExpr => flag; + public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done; } diff --git a/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs index 93aa2746..abafd8f4 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Virtual list filter matching tasks by a single status (Queued, Running). /// Planning parents appear contextually when they host a matching child. /// -public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter +public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase { - public string Id => id; - public bool Matches(TaskEntity t) => t.Status == status; - public bool ShouldCount(TaskEntity t) => t.Status == status; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => + public override string Id => id; + protected override Expression> MatchExpr => t => t.Status == status; + public override bool ShouldCount(TaskEntity t) => t.Status == status; + public override bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => PlanningRules.IsPlanningParent(t) && PlanningRules.HasMatchingChild(t, all, c => c.Status == status); } diff --git a/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs b/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs new file mode 100644 index 00000000..be0db003 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Filtering.Filters; + +/// +/// Base for implementations: subclasses express their +/// primary-match condition once as an expression tree (), which +/// doubles as a SQL-translatable predicate () and, compiled +/// on first use, as the in-memory predicate. +/// +public abstract class TaskListFilterBase : ITaskListFilter +{ + private Func? _compiled; + + public abstract string Id { get; } + + protected abstract Expression> MatchExpr { get; } + + public Expression> MatchExpression => MatchExpr; + + public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t); + + public abstract bool ShouldCount(TaskEntity t); + + public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs index 3373dbf8..d340dcea 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Filter for any user-defined list. Constructed on demand from the list id — /// one instance per list. /// -public sealed class UserListFilter : ITaskListFilter +public sealed class UserListFilter : TaskListFilterBase { private readonly string _listId; @@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter Id = $"user:{listId}"; } - public string Id { get; } - public bool Matches(TaskEntity t) => t.ListId == _listId; - public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + public override string Id { get; } + protected override Expression> MatchExpr => t => t.ListId == _listId; + public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done; } diff --git a/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs b/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs index 79b13435..0782df3d 100644 --- a/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; namespace ClaudeDo.Data.Filtering; @@ -15,6 +16,9 @@ public interface ITaskListFilter /// True if is a primary citizen of this list — appears as a row. bool Matches(TaskEntity t); + /// The primary-match predicate as an expression tree, so EF Core can push it into SQL. + Expression> MatchExpression { get; } + /// True if should be counted in this list's badge. bool ShouldCount(TaskEntity t); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 6ccd42f9..c83cbc76 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -336,26 +336,53 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable try { await using var db = await _dbFactory.CreateDbContextAsync(ct); - var all = await db.Tasks - .Include(t => t.List) - .Include(t => t.Worktree) - .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) - .ToListAsync(ct); + var filter = _filters.Resolve(list.Id); + var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree); + + var filteredList = filter is null + ? new List() + : await baseQuery.Where(filter.MatchExpression).ToListAsync(ct); ct.ThrowIfCancellationRequested(); - var filter = _filters.Resolve(list.Id); - var filteredList = filter is null - ? new List() - : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList(); - var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); - var existingIds = filteredList.Select(t => t.Id).ToHashSet(); - foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!))) + if (filter is not null) { - if (existingIds.Add(c.Id)) - filteredList.Add(c); + // Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running + // child): only fetch candidates that could plausibly qualify — parents of tasks we already + // matched — instead of scanning the whole table. + var candidateParentIds = filteredList + .Where(t => t.ParentTaskId != null) + .Select(t => t.ParentTaskId!) + .Distinct() + .ToList(); + if (candidateParentIds.Count > 0) + { + var existingParentIds = filteredList.Select(t => t.Id).ToHashSet(); + var parentCandidates = await baseQuery + .Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id)) + .ToListAsync(ct); + foreach (var p in parentCandidates) + if (filter.MatchesAsContext(p, filteredList)) + filteredList.Add(p); + } } + ct.ThrowIfCancellationRequested(); + + // Pull in every child of an already-matched top-level row, regardless of whether the child + // itself matches the filter, so subtasks render nested under their parent. + var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); + if (topIds.Count > 0) + { + var existingIds = filteredList.Select(t => t.Id).ToHashSet(); + var extraChildren = await baseQuery + .Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id)) + .ToListAsync(ct); + filteredList.AddRange(extraChildren); + } + + filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList(); + var showListChip = list.Kind == ListKind.Virtual; foreach (var t in filteredList) { diff --git a/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs new file mode 100644 index 00000000..7120c10b --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs @@ -0,0 +1,57 @@ +using ClaudeDo.Data.Filtering.Filters; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +/// +/// Proves that is a real +/// expression tree that EF Core can translate into SQL, not just an in-memory delegate — the +/// whole point of splitting it out from Matches. +/// +public sealed class MatchExpressionSqlTests : IDisposable +{ + private readonly string _dbPath; + private readonly DbContextOptions _options; + + public MatchExpressionSqlTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_matchexpr_{Guid.NewGuid():N}.db"); + _options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + + using var ctx = new ClaudeDoDbContext(_options); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + [Fact] + public void StatusFilter_MatchExpression_translates_to_a_SQL_WHERE_on_status() + { + var filter = new StatusFilter("virtual:queued", TaskStatus.Queued); + + using var ctx = new ClaudeDoDbContext(_options); + var sql = ctx.Tasks.Where(filter.MatchExpression).ToQueryString(); + + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("status", sql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UserListFilter_MatchExpression_translates_to_a_SQL_WHERE_on_list_id() + { + var filter = new UserListFilter("abc"); + + using var ctx = new ClaudeDoDbContext(_options); + var sql = ctx.Tasks.Where(filter.MatchExpression).ToQueryString(); + + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("list_id", sql, StringComparison.OrdinalIgnoreCase); + } +}