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<Func<TaskEntity,bool>> 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.
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering.Filters;
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
public sealed class ReviewFilter : ITaskListFilter
|
public sealed class ReviewFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => "virtual:review";
|
public override string Id => "virtual:review";
|
||||||
public bool Matches(TaskEntity t) =>
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
|
||||||
t.Status == TaskStatus.WaitingForReview;
|
public override bool ShouldCount(TaskEntity t) => Matches(t);
|
||||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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
|
/// Filter for a smart list keyed off a boolean/nullable task flag
|
||||||
/// (My Day, Important, Planned). Counts only non-done matches.
|
/// (My Day, Important, Planned). Counts only non-done matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
|
public sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
|
||||||
public bool Matches(TaskEntity t) => flag(t);
|
|
||||||
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
|
public override string Id => id;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
|
||||||
|
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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).
|
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
||||||
/// Planning parents appear contextually when they host a matching child.
|
/// Planning parents appear contextually when they host a matching child.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
|
public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
public override string Id => id;
|
||||||
public bool Matches(TaskEntity t) => t.Status == status;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
|
||||||
public bool ShouldCount(TaskEntity t) => t.Status == status;
|
public override bool ShouldCount(TaskEntity t) => t.Status == status;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||||
PlanningRules.IsPlanningParent(t) &&
|
PlanningRules.IsPlanningParent(t) &&
|
||||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
|
||||||
|
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
|
||||||
|
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
|
||||||
|
/// on first use, as the in-memory <see cref="Matches"/> predicate.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TaskListFilterBase : ITaskListFilter
|
||||||
|
{
|
||||||
|
private Func<TaskEntity, bool>? _compiled;
|
||||||
|
|
||||||
|
public abstract string Id { get; }
|
||||||
|
|
||||||
|
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
|
||||||
|
|
||||||
|
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
|
||||||
|
|
||||||
|
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
|
||||||
|
|
||||||
|
public abstract bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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 —
|
/// Filter for any user-defined list. Constructed on demand from the list id —
|
||||||
/// one instance per list.
|
/// one instance per list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UserListFilter : ITaskListFilter
|
public sealed class UserListFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
private readonly string _listId;
|
private readonly string _listId;
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
|
|||||||
Id = $"user:{listId}";
|
Id = $"user:{listId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id { get; }
|
public override string Id { get; }
|
||||||
public bool Matches(TaskEntity t) => t.ListId == _listId;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
|
||||||
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering;
|
namespace ClaudeDo.Data.Filtering;
|
||||||
@@ -15,6 +16,9 @@ public interface ITaskListFilter
|
|||||||
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
||||||
bool Matches(TaskEntity t);
|
bool Matches(TaskEntity t);
|
||||||
|
|
||||||
|
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
|
||||||
|
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
|
||||||
|
|
||||||
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
||||||
bool ShouldCount(TaskEntity t);
|
bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
|||||||
@@ -336,25 +336,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var all = await db.Tasks
|
var filter = _filters.Resolve(list.Id);
|
||||||
.Include(t => t.List)
|
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
|
||||||
.Include(t => t.Worktree)
|
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
var filteredList = filter is null
|
||||||
.ToListAsync(ct);
|
? new List<TaskEntity>()
|
||||||
|
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var filter = _filters.Resolve(list.Id);
|
if (filter is not null)
|
||||||
var filteredList = filter is null
|
|
||||||
? new List<TaskEntity>()
|
|
||||||
: 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 (existingIds.Add(c.Id))
|
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
|
||||||
filteredList.Add(c);
|
// 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;
|
var showListChip = list.Kind == ListKind.Virtual;
|
||||||
foreach (var t in filteredList)
|
foreach (var t in filteredList)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves that <see cref="ClaudeDo.Data.Filtering.ITaskListFilter.MatchExpression"/> 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 <c>Matches</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MatchExpressionSqlTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly DbContextOptions<ClaudeDoDbContext> _options;
|
||||||
|
|
||||||
|
public MatchExpressionSqlTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_matchexpr_{Guid.NewGuid():N}.db");
|
||||||
|
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user