Skip to content

Commit f0657a0

Browse files
committed
feat: better deferred loading on EFCore
1 parent c993ab3 commit f0657a0

8 files changed

Lines changed: 1270 additions & 2 deletions

File tree

examples/starter/SpaceflightsEFCore/Data/_02_Intermediate/Catalog.Intermediate.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ public partial class Catalog
99
{
1010
/// <summary>
1111
/// Preprocessed company data with validated and strongly-typed fields.
12+
/// Backed by a deferred <see cref="Flowthru.Extensions.EFCore.Data.DbQuery{T}"/> handle:
13+
/// no rows are fetched until a step iterates the value.
1214
/// </summary>
1315
public IItem<IEnumerable<PreprocessedCompanySchema>> PreprocessedCompanies =>
1416
CreateItem(
1517
() =>
1618
EFCoreItemFactory
17-
.Enumerable.EFCore<PreprocessedCompanySchema, SpaceflightsDbContext>(
19+
.Query.EFCore<PreprocessedCompanySchema, SpaceflightsDbContext>(
1820
label: "PreprocessedCompanies",
1921
contextFactory: _contextFactory
2022
)
@@ -23,12 +25,14 @@ public partial class Catalog
2325

2426
/// <summary>
2527
/// Preprocessed shuttle data with validated and strongly-typed fields.
28+
/// Backed by a deferred <see cref="Flowthru.Extensions.EFCore.Data.DbQuery{T}"/> handle:
29+
/// no rows are fetched until a step iterates the value.
2630
/// </summary>
2731
public IItem<IEnumerable<PreprocessedShuttleSchema>> PreprocessedShuttles =>
2832
CreateItem(
2933
() =>
3034
EFCoreItemFactory
31-
.Enumerable.EFCore<PreprocessedShuttleSchema, SpaceflightsDbContext>(
35+
.Query.EFCore<PreprocessedShuttleSchema, SpaceflightsDbContext>(
3236
label: "PreprocessedShuttles",
3337
contextFactory: _contextFactory
3438
)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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&lt;T&gt;</c> in the Spark extension.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// <c>DbQuery&lt;T&gt;</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&lt;T&gt;</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&lt;T&gt;</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&lt;TResult&gt;</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&lt;T&gt;</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&lt;ModelInputSchema&gt;(ctx =>
114+
/// from s in ctx.Set&lt;ShuttleSchema&gt;()
115+
/// join c in ctx.Set&lt;CompanySchema&gt;() on s.CompanyId equals c.Id
116+
/// select new ModelInputSchema { ... });
117+
/// </code>
118+
/// <para>
119+
/// The returned <c>DbQuery&lt;TResult&gt;</c> inherits the <see cref="Scope"/> and context
120+
/// factory of this handle, so it will match a <c>DbQueryStorageAdapter&lt;TResult&gt;</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+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Runtime.CompilerServices;
2+
3+
namespace Flowthru.Extensions.EFCore.Data;
4+
5+
/// <summary>
6+
/// Identifies which database instance a <see cref="DbQuery{T}"/> or
7+
/// <see cref="Flowthru.Core.Data.Storage.DbQueryStorageAdapter{T}"/> is associated with,
8+
/// enabling the fused INSERT-FROM-SELECT save path when source and destination share the same DB.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// Two scopes are considered the same database when they are equal under their equality rule:
13+
/// </para>
14+
/// <list type="bullet">
15+
/// <item>
16+
/// <see cref="Inferred"/> — reference equality on the factory object.
17+
/// The default for catalog entries created via <c>EFCoreItemFactory.Query</c>.
18+
/// </item>
19+
/// <item>
20+
/// <see cref="Explicit"/> — case-sensitive string equality on the scope name.
21+
/// Use this when two catalog entries point to the same logical database but use
22+
/// different factory instances (e.g., two separate DI-injected factory objects).
23+
/// </item>
24+
/// </list>
25+
/// </remarks>
26+
public abstract class DbScope
27+
{
28+
/// <summary>
29+
/// Creates a scope inferred from factory object identity.
30+
/// Two entries sharing the exact same factory reference are considered the same database.
31+
/// </summary>
32+
/// <param name="factory">The factory object whose reference identity keys this scope.</param>
33+
public static DbScope Inferred(object factory) => new InferredDbScope(factory);
34+
35+
/// <summary>
36+
/// Creates a named scope.
37+
/// Two entries with the same <paramref name="name"/> are considered the same database
38+
/// regardless of factory instance identity.
39+
/// </summary>
40+
/// <param name="name">Case-sensitive scope name.</param>
41+
public static DbScope Explicit(string name) => new ExplicitDbScope(name);
42+
43+
/// <summary>
44+
/// Returns <see langword="true"/> if this scope refers to the same database as <paramref name="other"/>.
45+
/// </summary>
46+
/// <remarks>
47+
/// Virtual hook for future subclasses that match by connection string or other criteria.
48+
/// </remarks>
49+
internal virtual bool IsSameDatabase(DbScope other) => Equals(other);
50+
51+
private sealed class InferredDbScope : DbScope
52+
{
53+
private readonly object _factory;
54+
55+
internal InferredDbScope(object factory) => _factory = factory;
56+
57+
internal override bool IsSameDatabase(DbScope other) =>
58+
other is InferredDbScope o && ReferenceEquals(_factory, o._factory);
59+
60+
public override bool Equals(object? obj) =>
61+
obj is InferredDbScope o && ReferenceEquals(_factory, o._factory);
62+
63+
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_factory);
64+
}
65+
66+
private sealed class ExplicitDbScope : DbScope
67+
{
68+
private readonly string _name;
69+
70+
internal ExplicitDbScope(string name) => _name = name;
71+
72+
internal override bool IsSameDatabase(DbScope other) =>
73+
other is ExplicitDbScope o && string.Equals(_name, o._name, StringComparison.Ordinal);
74+
75+
public override bool Equals(object? obj) =>
76+
obj is ExplicitDbScope o && string.Equals(_name, o._name, StringComparison.Ordinal);
77+
78+
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_name);
79+
}
80+
}

0 commit comments

Comments
 (0)