diff --git a/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveDbSet.cs b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveDbSet.cs index af9022bf..ddd2ba34 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveDbSet.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveDbSet.cs @@ -17,7 +17,9 @@ public class ExpressiveDbSet : DbSet, IExpressiveQueryable _inner; private readonly IQueryable _queryable; - public ExpressiveDbSet(DbSet inner) + // Constructed only via AsExpressiveDbSet() (through InternalExpressiveDbSet) so every + // instance is the async-capable runtime subclass. See InternalExpressiveDbSet. + private protected ExpressiveDbSet(DbSet inner) { _inner = inner; _queryable = inner; diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbSetExtensions.cs b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbSetExtensions.cs index 83f98915..94efffe3 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbSetExtensions.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbSetExtensions.cs @@ -1,4 +1,5 @@ using ExpressiveSharp.EntityFrameworkCore; +using ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; // ReSharper disable once CheckNamespace — intentionally in Microsoft.EntityFrameworkCore for discoverability namespace Microsoft.EntityFrameworkCore; @@ -7,5 +8,5 @@ public static class DbSetExtensions { public static ExpressiveDbSet AsExpressiveDbSet(this DbSet dbSet) where TEntity : class - => new(dbSet); + => new InternalExpressiveDbSet(dbSet); } diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/IncludableExpressiveQueryableWrapper.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/IncludableExpressiveQueryableWrapper.cs index 18ae26bc..7d1de9b3 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/IncludableExpressiveQueryableWrapper.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/IncludableExpressiveQueryableWrapper.cs @@ -5,7 +5,7 @@ namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure; internal sealed class IncludableExpressiveQueryableWrapper - : IIncludableExpressiveQueryable + : IIncludableExpressiveQueryable, IAsyncEnumerable where TEntity : class { private readonly IIncludableQueryable _inner; @@ -19,4 +19,14 @@ public IncludableExpressiveQueryableWrapper(IIncludableQueryable IEnumerable.GetEnumerator() => _inner.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_inner).GetEnumerator(); + + IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken) + { + if (_inner is IAsyncEnumerable asyncEnumerable) + return asyncEnumerable.GetAsyncEnumerator(cancellationToken); + + throw new InvalidOperationException( + $"The source IQueryable<{typeof(TEntity).Name}> does not implement IAsyncEnumerable<{typeof(TEntity).Name}>. " + + "Async operations require an async-capable provider such as Entity Framework Core."); + } } diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/InternalExpressiveDbSet.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/InternalExpressiveDbSet.cs new file mode 100644 index 00000000..8f9cda1f --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/InternalExpressiveDbSet.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; + +/// +/// The concrete runtime instance produced by AsExpressiveDbSet(). It implements +/// — satisfied by the inherited +/// — so EF Core's streaming async +/// terminals (ToListAsync/ToArrayAsync/...), which runtime-cast the source to +/// , work directly on the set. +/// +/// The interface lives here rather than on the public so +/// callers never hold a static type that is both and +/// — which on .NET 10 makes those terminals ambiguous between +/// System.Linq.AsyncEnumerable and EF Core's extensions. This mirrors EF Core's own +/// public DbSet<T> / internal InternalDbSet<T> split. +/// +/// +internal sealed class InternalExpressiveDbSet : ExpressiveDbSet, IAsyncEnumerable + where TEntity : class +{ + public InternalExpressiveDbSet(DbSet inner) : base(inner) + { + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/AsyncQueryableTestBase.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/AsyncQueryableTestBase.cs index 2fd4ddc5..107890ae 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/AsyncQueryableTestBase.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/AsyncQueryableTestBase.cs @@ -7,7 +7,11 @@ namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; public abstract class AsyncQueryableTestBase : EFCoreRelationalTestBase { [TestInitialize] - public Task SeedStoreData() => Context.SeedStoreAsync(); + public async Task SeedStoreData() + { + await Context.SeedStoreAsync(); + Context.ChangeTracker.Clear(); + } [TestMethod] public async Task AnyAsync_WithPredicate_Executes() @@ -117,6 +121,61 @@ public async Task Include_Where_ToListAsync_LoadsNavigation() Assert.IsTrue(results.All(o => o.Customer != null)); } + [TestMethod] + public async Task ExpressiveDbSet_ToListAsync_DirectTerminal_Executes() + { + // Streaming terminal directly on the ExpressiveDbSet (no intervening operator to + // re-wrap it). ToListAsync casts the source to IAsyncEnumerable. + var results = await Context.ExpressiveOrders.ToListAsync(); + + Assert.AreEqual(4, results.Count); + } + + [TestMethod] + public async Task ExpressiveDbSet_ToArrayAsync_DirectTerminal_Executes() + { + var results = await Context.ExpressiveOrders.ToArrayAsync(); + + Assert.AreEqual(4, results.Length); + } + + [TestMethod] + public async Task Include_ToListAsync_DirectTerminal_LoadsNavigation() + { + var results = await Context.ExpressiveOrders + .Include(o => o.Customer) + .ToListAsync(); + + Assert.AreEqual(4, results.Count); + Assert.AreEqual(3, results.Count(o => o.Customer != null)); + } + + [TestMethod] + public async Task Include_ToArrayAsync_DirectTerminal_LoadsNavigation() + { + var results = await Context.ExpressiveOrders + .Include(o => o.Customer) + .ToArrayAsync(); + + Assert.AreEqual(4, results.Length); + Assert.AreEqual(3, results.Count(o => o.Customer != null)); + } + + [TestMethod] + public async Task Include_ThenInclude_ToListAsync_DirectTerminal_LoadsTwoLevelNavigation() + { + var results = await Context.ExpressiveOrders + .Include(o => o.Customer) + .ThenInclude(c => c!.Address) + .ToListAsync(); + + Assert.AreEqual(4, results.Count); + var aliceOrder = results.Single(o => o.CustomerId == 1); + Assert.IsNotNull(aliceOrder.Customer); + Assert.IsNotNull(aliceOrder.Customer!.Address); + Assert.AreEqual("New York", aliceOrder.Customer.Address!.City); + } + [TestMethod] public async Task TagWith_Where_FirstAsync_ExecutesCorrectly() { diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/IncludeTestBase.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/IncludeTestBase.cs index e4ed9cd8..1233f40c 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/IncludeTestBase.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/IncludeTestBase.cs @@ -7,7 +7,11 @@ namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; public abstract class IncludeTestBase : EFCoreRelationalTestBase { [TestInitialize] - public Task SeedStoreData() => Context.SeedStoreAsync(); + public async Task SeedStoreData() + { + await Context.SeedStoreAsync(); + Context.ChangeTracker.Clear(); + } [TestMethod] public async Task Include_LoadsRelatedCustomer()