Skip to content

Commit 11e848a

Browse files
committed
fix: increase performance of shallow inspection on GQL+EFCore
1 parent f0657a0 commit 11e848a

6 files changed

Lines changed: 162 additions & 2 deletions

File tree

src/core/Flowthru.Core/Data/Item.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ public FlowIO<int> GetCountAsync()
159159

160160
private FlowIO<int> LoadAndCount()
161161
{
162+
// If the adapter can count server-side (e.g. SQL COUNT(*)), use it —
163+
// avoids materializing the full dataset just for diagnostic logging.
164+
if (_storage is IHasEfficientCount efficient)
165+
return efficient.GetCountAsync();
166+
162167
return Load()
163168
.Map(data =>
164169
{
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Flowthru.Core.Effects;
2+
3+
namespace Flowthru.Core.Data.Storage;
4+
5+
/// <summary>
6+
/// Optional interface for storage adapters that can return a row count without
7+
/// materializing the full dataset.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// By default, <see cref="Item{T}.GetCountAsync"/> counts by calling <see cref="IStorageAdapter{T}.Load"/>
12+
/// and enumerating the result. For I/O-bound adapters (databases, APIs) this materializes the
13+
/// entire dataset just to count rows — wasteful when the count is only needed for diagnostic
14+
/// logging around step execution.
15+
/// </para>
16+
/// <para>
17+
/// Implement this interface on a storage adapter to provide a cheap server-side count
18+
/// (e.g. <c>COUNT(*)</c> SQL) that <see cref="Item{T}.GetCountAsync"/> will use instead.
19+
/// </para>
20+
/// <para>
21+
/// Existing adapters that do not implement this interface continue to work without change.
22+
/// </para>
23+
/// </remarks>
24+
public interface IHasEfficientCount
25+
{
26+
/// <summary>
27+
/// Returns the number of items in the backing store without materializing them.
28+
/// </summary>
29+
FlowIO<int> GetCountAsync();
30+
}

src/extensions/Flowthru.Extensions.EFCore/Data/Storage/DbQueryStorageAdapter.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ namespace Flowthru.Core.Data.Storage;
4545
/// </para>
4646
/// </remarks>
4747
/// <typeparam name="T">Entity type. Must be a class registered in the underlying DbContext.</typeparam>
48-
public sealed class DbQueryStorageAdapter<T> : IStorageAdapter<IEnumerable<T>>
48+
public sealed class DbQueryStorageAdapter<T> : IStorageAdapter<IEnumerable<T>>, IHasEfficientCount
4949
where T : class
5050
{
5151
private readonly DbQuery<T> _handle;
@@ -122,6 +122,28 @@ public DbQueryStorageAdapter(
122122
/// <remarks>Returns the deferred query handle — no database I/O.</remarks>
123123
public FlowIO<IEnumerable<T>> Load() => FlowIO.Lift<IEnumerable<T>>(() => _handle);
124124

125+
/// <inheritdoc/>
126+
/// <remarks>
127+
/// Executes <c>SELECT COUNT(*)</c> against the query's predicate — no rows are transferred
128+
/// to the application host.
129+
/// </remarks>
130+
FlowIO<int> IHasEfficientCount.GetCountAsync()
131+
{
132+
return FlowIO.LiftAsync(async ct =>
133+
{
134+
var ctx = _handle.OpenContext();
135+
try
136+
{
137+
return await _handle.BuildQuery(ctx).CountAsync(ct);
138+
}
139+
finally
140+
{
141+
if (_handle.OwnsContext)
142+
await ctx.DisposeAsync();
143+
}
144+
});
145+
}
146+
125147
/// <inheritdoc/>
126148
/// <remarks>
127149
/// Fused path when <paramref name="data"/> is a <see cref="DbQuery{T}"/> with a matching scope;

src/extensions/Flowthru.Extensions.EFCore/Data/Storage/EFCoreStorageAdapter.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ namespace Flowthru.Core.Data.Storage;
8282
/// var adapter = new EFCoreStorageAdapter&lt;Company&gt;(dbContext, allowEmptyData: true);
8383
/// </code>
8484
/// </example>
85-
public sealed class EFCoreStorageAdapter<T> : IStorageAdapter<IEnumerable<T>>
85+
public sealed class EFCoreStorageAdapter<T> : IStorageAdapter<IEnumerable<T>>, IHasEfficientCount
8686
where T : class
8787
{
8888
private readonly DbContext? _injectedContext;
@@ -463,6 +463,24 @@ public static async Task DefaultSave(DbContext context, IEnumerable<T> data, Can
463463
await context.SaveChangesAsync(ct);
464464
}
465465

466+
/// <inheritdoc/>
467+
FlowIO<int> IHasEfficientCount.GetCountAsync()
468+
{
469+
return FlowIO.LiftAsync(async ct =>
470+
{
471+
var context = GetContext();
472+
try
473+
{
474+
return await context.Set<T>().CountAsync(ct);
475+
}
476+
finally
477+
{
478+
if (_ownsContext && context != null)
479+
await context.DisposeAsync();
480+
}
481+
});
482+
}
483+
466484
private DbContext GetContext()
467485
{
468486
if (_injectedContext != null)

tests/Flowthru.Extensions.EFCore.Tests/DbQueryStorageAdapterTests.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,53 @@ await sourceEntry
358358
Assert.That(rows.Select(r => r.Name), Is.EquivalentTo(new[] { "Alpha", "Beta" }));
359359
}
360360

361+
// ── IHasEfficientCount ──────────────────────────────────────
362+
363+
[Test]
364+
public async Task GetCountAsync_UsesCountStar_NotFullLoad()
365+
{
366+
var seed = EFCoreItemFactory.Enumerable.EFCore<TestEntity>("seed", _factory);
367+
await seed.Save(
368+
new[]
369+
{
370+
new TestEntity { Id = 1, Name = "A" },
371+
new TestEntity { Id = 2, Name = "B" },
372+
new TestEntity { Id = 3, Name = "C" },
373+
}
374+
)
375+
.Run();
376+
377+
var entry = EFCoreItemFactory.Query.EFCore<TestEntity>("test", _factory);
378+
379+
// Should use IHasEfficientCount → SELECT COUNT(*) rather than materializing
380+
var count = await entry.GetCountAsync().Run();
381+
382+
Assert.That(count, Is.EqualTo(3));
383+
}
384+
385+
[Test]
386+
public async Task GetCountAsync_WithWhereClause_CountsMatchingRows()
387+
{
388+
var seed = EFCoreItemFactory.Enumerable.EFCore<TestEntity>("seed", _factory);
389+
await seed.Save(Enumerable.Range(1, 10).Select(i => new TestEntity { Id = i, Name = $"E{i}" }))
390+
.Run();
391+
392+
// Load the deferred handle, add a predicate, then count — should not pull all 10 rows
393+
var entry = EFCoreItemFactory.Query.EFCore<TestEntity>("test", _factory);
394+
IEnumerable<TestEntity> handle = await entry.Load().Run();
395+
var filtered = ((DbQuery<TestEntity>)handle).Where(e => e.Id <= 4);
396+
397+
// Wrap back into an Item so GetCountAsync goes through IHasEfficientCount
398+
var filteredEntry = EFCoreItemFactory.Query.EFCore<TestEntity>(
399+
"filtered",
400+
_factory,
401+
queryCustomizer: q => q.Where(e => e.Id <= 4)
402+
);
403+
var count = await filteredEntry.GetCountAsync().Run();
404+
405+
Assert.That(count, Is.EqualTo(4));
406+
}
407+
361408
// ── Project<TResult> ─────────────────────────────────────────────────────
362409

363410
[Test]

tests/Flowthru.Extensions.EFCore.Tests/EFCoreStorageAdapterTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,42 @@ public void ArrayKeyEntity_ThrowsInvalidOperationException_OnConstruction()
176176
)
177177
);
178178
}
179+
180+
// ── IHasEfficientCount ──────────────────────────────────────────────────
181+
182+
[Test]
183+
public async Task GetCountAsync_UsesCountStar_NotFullLoad()
184+
{
185+
var testData = new[]
186+
{
187+
new TestEntity { Id = 1, Name = "Alice" },
188+
new TestEntity { Id = 2, Name = "Bob" },
189+
new TestEntity { Id = 3, Name = "Carol" },
190+
};
191+
192+
var entry = EFCoreItemFactory.Enumerable.EFCore<TestEntity>(
193+
"test",
194+
() => new TestDbContext(_options)
195+
);
196+
await entry.Save(testData).Run();
197+
198+
// GetCountAsync must hit IHasEfficientCount, not Load()+enumerate
199+
var count = await entry.GetCountAsync().Run();
200+
201+
Assert.That(count, Is.EqualTo(3));
202+
}
203+
204+
[Test]
205+
public async Task GetCountAsync_EmptyTable_ReturnsZero()
206+
{
207+
var entry = EFCoreItemFactory.Enumerable.EFCore<TestEntity>(
208+
"test",
209+
() => new TestDbContext(_options)
210+
);
211+
212+
// Empty table: Exists() returns false → GetCountAsync() short-circuits to 0
213+
var count = await entry.GetCountAsync().Run();
214+
215+
Assert.That(count, Is.EqualTo(0));
216+
}
179217
}

0 commit comments

Comments
 (0)