|
| 1 | +using System.Collections; |
| 2 | +using System.Linq.Expressions; |
| 3 | +using Microsoft.EntityFrameworkCore; |
| 4 | + |
| 5 | +namespace Flowthru.Extensions.EFCore.Data; |
| 6 | + |
| 7 | +/// <summary> |
| 8 | +/// A deferred EF Core query handle — analogous to <c>TypedFrame<T></c> in the Spark extension. |
| 9 | +/// </summary> |
| 10 | +/// <remarks> |
| 11 | +/// <para> |
| 12 | +/// <c>DbQuery<T></c> captures all query configuration at catalog construction time but does |
| 13 | +/// <em>not</em> execute any database calls until explicitly materialized. The catalog declares |
| 14 | +/// <em>what</em> to query; steps decide <em>when</em> to materialize via |
| 15 | +/// <see cref="ToListAsync"/> or by iterating the <see cref="IEnumerable{T}"/> interface. |
| 16 | +/// </para> |
| 17 | +/// <para> |
| 18 | +/// <strong>Materialization boundaries:</strong> |
| 19 | +/// </para> |
| 20 | +/// <list type="bullet"> |
| 21 | +/// <item> |
| 22 | +/// Explicit: call <see cref="ToListAsync"/> in your step transform. |
| 23 | +/// </item> |
| 24 | +/// <item> |
| 25 | +/// Implicit: <c>DbQuery<T></c> implements <see cref="IEnumerable{T}"/>, so |
| 26 | +/// LINQ operators and <c>foreach</c> trigger synchronous materialization automatically. |
| 27 | +/// Explicit calls are preferred for readability — they make the database boundary visible. |
| 28 | +/// </item> |
| 29 | +/// </list> |
| 30 | +/// <para> |
| 31 | +/// <strong>Fluent composition:</strong> Use <see cref="Where"/>, <see cref="OrderBy{TKey}"/>, |
| 32 | +/// <see cref="Take"/>, <see cref="Skip"/> to refine the query without triggering execution. |
| 33 | +/// Each method returns a new <c>DbQuery<T></c> with the composed expression tree. |
| 34 | +/// </para> |
| 35 | +/// <para> |
| 36 | +/// <strong>Type-changing projection:</strong> Use <see cref="Project{TResult}"/> to build a |
| 37 | +/// deferred query of a different entity type on the same database and scope. This enables |
| 38 | +/// steps to construct a derived <c>DbQuery<TResult></c> that can be saved by a |
| 39 | +/// <see cref="Flowthru.Core.Data.Storage.DbQueryStorageAdapter{T}"/> using the fused |
| 40 | +/// INSERT-FROM-SELECT path. |
| 41 | +/// </para> |
| 42 | +/// <para> |
| 43 | +/// <strong>Save semantics:</strong> <c>DbQuery<T></c> values passed to |
| 44 | +/// <see cref="Flowthru.Core.Data.Storage.DbQueryStorageAdapter{T}.Save"/> trigger a |
| 45 | +/// server-side fused DELETE + INSERT-FROM-SELECT when source and destination share the |
| 46 | +/// same <see cref="Scope"/>. All other cases fall back to full materialization. |
| 47 | +/// </para> |
| 48 | +/// </remarks> |
| 49 | +/// <typeparam name="T">The entity type. Must be a class registered in the underlying DbContext.</typeparam> |
| 50 | +public sealed class DbQuery<T> : IEnumerable<T> |
| 51 | + where T : class |
| 52 | +{ |
| 53 | + internal readonly string Label; |
| 54 | + internal readonly DbScope Scope; |
| 55 | + internal readonly Func<DbContext, IQueryable<T>> BuildQuery; |
| 56 | + |
| 57 | + private readonly Func<DbContext> _contextFactory; |
| 58 | + private readonly bool _ownsContext; |
| 59 | + |
| 60 | + // Internal — use EFCoreItemFactory.Query or DbQuery<TOther>.Project<T>() to construct. |
| 61 | + internal DbQuery( |
| 62 | + string label, |
| 63 | + DbScope scope, |
| 64 | + Func<DbContext> contextFactory, |
| 65 | + bool ownsContext, |
| 66 | + Func<DbContext, IQueryable<T>> buildQuery |
| 67 | + ) |
| 68 | + { |
| 69 | + Label = label; |
| 70 | + Scope = scope; |
| 71 | + _contextFactory = contextFactory; |
| 72 | + _ownsContext = ownsContext; |
| 73 | + BuildQuery = buildQuery; |
| 74 | + } |
| 75 | + |
| 76 | + // ── Internal context access for the storage adapter ─────────────────────── |
| 77 | + |
| 78 | + internal DbContext OpenContext() => _contextFactory(); |
| 79 | + |
| 80 | + internal bool OwnsContext => _ownsContext; |
| 81 | + |
| 82 | + // ── Fluent LINQ composition ──────────────────────────────────────────────── |
| 83 | + |
| 84 | + /// <summary>Filters the query. Returns a new handle; does not execute.</summary> |
| 85 | + public DbQuery<T> Where(Expression<Func<T, bool>> predicate) => |
| 86 | + WithQuery(q => q.Where(predicate)); |
| 87 | + |
| 88 | + /// <summary>Orders ascending by <paramref name="keySelector"/>. Returns a new handle.</summary> |
| 89 | + public DbQuery<T> OrderBy<TKey>(Expression<Func<T, TKey>> keySelector) => |
| 90 | + WithQuery(q => q.OrderBy(keySelector)); |
| 91 | + |
| 92 | + /// <summary>Orders descending by <paramref name="keySelector"/>. Returns a new handle.</summary> |
| 93 | + public DbQuery<T> OrderByDescending<TKey>(Expression<Func<T, TKey>> keySelector) => |
| 94 | + WithQuery(q => q.OrderByDescending(keySelector)); |
| 95 | + |
| 96 | + /// <summary>Limits the number of rows returned. Returns a new handle.</summary> |
| 97 | + public DbQuery<T> Take(int count) => WithQuery(q => q.Take(count)); |
| 98 | + |
| 99 | + /// <summary>Skips the first <paramref name="count"/> rows. Returns a new handle.</summary> |
| 100 | + public DbQuery<T> Skip(int count) => WithQuery(q => q.Skip(count)); |
| 101 | + |
| 102 | + // ── Type-changing projection ──────────────────────────────────────────────── |
| 103 | + |
| 104 | + /// <summary> |
| 105 | + /// Builds a deferred query of a different entity type on the same database and scope. |
| 106 | + /// </summary> |
| 107 | + /// <remarks> |
| 108 | + /// <para> |
| 109 | + /// Use this in step transforms when you need to construct a derived query (e.g., a JOIN |
| 110 | + /// across multiple tables) that should be saved using the fused INSERT-FROM-SELECT path: |
| 111 | + /// </para> |
| 112 | + /// <code> |
| 113 | + /// return shuttles.Project<ModelInputSchema>(ctx => |
| 114 | + /// from s in ctx.Set<ShuttleSchema>() |
| 115 | + /// join c in ctx.Set<CompanySchema>() on s.CompanyId equals c.Id |
| 116 | + /// select new ModelInputSchema { ... }); |
| 117 | + /// </code> |
| 118 | + /// <para> |
| 119 | + /// The returned <c>DbQuery<TResult></c> inherits the <see cref="Scope"/> and context |
| 120 | + /// factory of this handle, so it will match a <c>DbQueryStorageAdapter<TResult></c> |
| 121 | + /// configured against the same database. |
| 122 | + /// </para> |
| 123 | + /// </remarks> |
| 124 | + /// <typeparam name="TResult">The target entity type.</typeparam> |
| 125 | + /// <param name="buildProjection"> |
| 126 | + /// Function that builds the <see cref="IQueryable{TResult}"/> for a given context. |
| 127 | + /// The context is the same database as this handle. |
| 128 | + /// </param> |
| 129 | + public DbQuery<TResult> Project<TResult>(Func<DbContext, IQueryable<TResult>> buildProjection) |
| 130 | + where TResult : class => new(Label, Scope, _contextFactory, _ownsContext, buildProjection); |
| 131 | + |
| 132 | + // ── Materialization ──────────────────────────────────────────────────────── |
| 133 | + |
| 134 | + /// <summary> |
| 135 | + /// Executes the query and returns all matching rows as a list. |
| 136 | + /// Applies <c>AsNoTracking()</c> automatically. |
| 137 | + /// </summary> |
| 138 | + public async Task<List<T>> ToListAsync(CancellationToken cancellationToken = default) |
| 139 | + { |
| 140 | + var context = _contextFactory(); |
| 141 | + try |
| 142 | + { |
| 143 | + return await BuildQuery(context).AsNoTracking().ToListAsync(cancellationToken); |
| 144 | + } |
| 145 | + finally |
| 146 | + { |
| 147 | + if (_ownsContext) |
| 148 | + await context.DisposeAsync(); |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + // ── IEnumerable<T> sync bridge ───────────────────────────────────────────── |
| 153 | + |
| 154 | + /// <inheritdoc/> |
| 155 | + /// <remarks> |
| 156 | + /// Triggers synchronous materialization. Prefer <see cref="ToListAsync"/> in async step |
| 157 | + /// transforms to avoid blocking a thread during database I/O. |
| 158 | + /// </remarks> |
| 159 | + public IEnumerator<T> GetEnumerator() => ToListAsync().GetAwaiter().GetResult().GetEnumerator(); |
| 160 | + |
| 161 | + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); |
| 162 | + |
| 163 | + // ── Private helpers ──────────────────────────────────────────────────────── |
| 164 | + |
| 165 | + private DbQuery<T> WithQuery(Func<IQueryable<T>, IQueryable<T>> compose) |
| 166 | + { |
| 167 | + var innerBuild = BuildQuery; |
| 168 | + return new DbQuery<T>( |
| 169 | + Label, |
| 170 | + Scope, |
| 171 | + _contextFactory, |
| 172 | + _ownsContext, |
| 173 | + ctx => compose(innerBuild(ctx)) |
| 174 | + ); |
| 175 | + } |
| 176 | +} |
0 commit comments