diff --git a/README.md b/README.md index a7def8d..0f1f7d7 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,15 @@ ## Overview -This *Query Pattern* is something I got from listening to a [.NET Rocks](https://www.dotnetrocks.com/) episode #1494 +This *query-object pattern* is something I got from listening to a [.NET Rocks](https://www.dotnetrocks.com/) episode #1494 [Developer Tips and Design Patterns with Steve Smith](https://www.dotnetrocks.com/details/1494). [Steve Smith](https://ardalis.com/) talks about the *Specification Pattern* at 49:50 in the podcast, and this is what inspired this. +Each query object owns a use-case-specific read operation, including its criteria +and projection. Unlike a classic specification, it describes the whole query +rather than just reusable selection criteria. Existing package and API names +such as `AddQueryPattern` remain unchanged. + What Steve describes is a problem we saw in our projects. Developers would keep adding more methods to the repository or keep adding JOINs to a result because they need this one other column added to an already returning list. This will start to overly complicate the original list. For example, you already have a screen that shows the list of users, and if they are active or not. Now, some other screen needs the list of users and wants the security @@ -18,6 +23,18 @@ So, where you needed a simple quick list of users is now doing way more than it This pattern also gives you the ability to easily test your queries. As Steve points out in the podcast what might work fine in compile time, or even within a unit test using mocked lists as your data, could give you a runtime error when running against EntityFramework. +The sample and tests use SQLite in-memory databases, which execute actual SQL. +EF Core's InMemory provider does not validate SQL translation. SQLite coverage +does not guarantee compatibility with another provider: also test application +queries against the database engine used in production. + +Contexts are injected and scoped, with one typed query-context/repository pair +per `DbContext`; multiple databases remain independent. List and scalar execution +support cancellation without removing the existing overloads. Automatic discovery +still scans all loaded assemblies by default, with optional explicit assembly +selection. See the [usage documentation](./src/myNOC.EntityFramework.Query/README.md) +for configuration, multiple-context examples, and cancellation compatibility. + ## Installation and supported frameworks ```shell @@ -162,5 +179,19 @@ build props, GitVersion configuration and validation scripts. ## Documentation & Usage -The documentation for the library can be found [here](./src/myNOC.EntityFramework.Query/README.md) -A very simple sample project [here](./sample/QuerySample/). +See the [library usage documentation](./src/myNOC.EntityFramework.Query/README.md) +for scoped context registration, multiple databases, automatic discovery, query +objects, and cancellation-aware execution. + +The [sample application](./sample/QuerySample/) creates and seeds a temporary +SQLite in-memory database, then runs list and scalar queries. No external database +server is required. Run it from the repository root for either supported target: + +```shell +dotnet run --project sample/QuerySample --framework net8.0 +dotnet run --project sample/QuerySample --framework net10.0 +``` + +The [tests](./tests/myNOC.Tests.EntityFramework.Query/) cover SQL translation and +results, cancellation, assembly discovery, and isolation/disposal of multiple +database contexts. Run both targets with `dotnet test QueryPattern.sln`. diff --git a/sample/QuerySample/Data/AddressBookContext.cs b/sample/QuerySample/Data/AddressBookContext.cs index e7e953e..a6f28b1 100644 --- a/sample/QuerySample/Data/AddressBookContext.cs +++ b/sample/QuerySample/Data/AddressBookContext.cs @@ -5,12 +5,15 @@ namespace QuerySample.Data { public class AddressBookContext : QueryContext, IAddressBookContext { - private DbContext? _dbContext = null; - public override DbContext GetContext() + private readonly AddressBookDbContext _dbContext; + + public AddressBookContext(AddressBookDbContext dbContext) { - if (_dbContext == null) - _dbContext = new AddressBookDbContext(); + _dbContext = dbContext; + } + public override DbContext GetContext() + { return _dbContext; } } diff --git a/sample/QuerySample/Data/AddressBookDbContext.cs b/sample/QuerySample/Data/AddressBookDbContext.cs index ac75b96..40ed758 100644 --- a/sample/QuerySample/Data/AddressBookDbContext.cs +++ b/sample/QuerySample/Data/AddressBookDbContext.cs @@ -7,15 +7,8 @@ public class AddressBookDbContext : DbContext { public DbSet Contacts { get; set; } = default!; - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + public AddressBookDbContext(DbContextOptions options) : base(options) { - optionsBuilder.UseInMemoryDatabase("AddressBook"); - base.OnConfiguring(optionsBuilder); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); } } } diff --git a/sample/QuerySample/Program.cs b/sample/QuerySample/Program.cs index 48647a4..f71f553 100644 --- a/sample/QuerySample/Program.cs +++ b/sample/QuerySample/Program.cs @@ -1,18 +1,27 @@ -// See https://aka.ms/new-console-template for more information +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using myNOC.EntityFramework.Query.Extensions; using QuerySample.Data; using QuerySample.Entities; using QuerySample.Queries; -await SeedSampleData(); - IServiceCollection services = new ServiceCollection(); +services.AddSingleton(_ => +{ + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; +}); +services.AddDbContext((provider, options) => + options.UseSqlite(provider.GetRequiredService())); services.AddQueryPattern(); -var provider = services.BuildServiceProvider(); +using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); +using var scope = provider.CreateScope(); +await SeedSampleData(scope.ServiceProvider.GetRequiredService()); -var queryRepo = provider.GetRequiredService(); +var queryRepo = scope.ServiceProvider.GetRequiredService(); var result = await queryRepo.Query(new ContactGetAll()); DisplayResults("Return All Contacts", result); @@ -26,9 +35,9 @@ var id = await queryRepo.Query(new ContactGetIdByName("Bob")); Console.WriteLine($"Bob's Id is: {id}"); -static async Task SeedSampleData() +static async Task SeedSampleData(AddressBookDbContext addressBook) { - var addressBook = new AddressBookDbContext(); + await addressBook.Database.EnsureCreatedAsync(); addressBook.Add(new ContactEntity { Id = 1, Name = "Abby" }); addressBook.Add(new ContactEntity { Id = 2, Name = "Bob" }); addressBook.Add(new ContactEntity { Id = 3, Name = "Charlie" }); diff --git a/sample/QuerySample/Queries/ContactGetIdByName.cs b/sample/QuerySample/Queries/ContactGetIdByName.cs index 3d16c95..7df8568 100644 --- a/sample/QuerySample/Queries/ContactGetIdByName.cs +++ b/sample/QuerySample/Queries/ContactGetIdByName.cs @@ -4,23 +4,28 @@ namespace QuerySample.Queries { - internal class ContactGetIdByName : IQueryScalar + public class ContactGetIdByName : IQueryScalar { private readonly string _name; public ContactGetIdByName(string name) { - _name = name; + _name = name.ToUpperInvariant(); } - public async Task GetScalar(IQueryContext context) + public Task GetScalar(IQueryContext context) + { + return GetScalar(context, CancellationToken.None); + } + + public async Task GetScalar(IQueryContext context, CancellationToken cancellationToken) { var persons = context.Set(); var query = from p in persons - where p.Name.Contains(_name, StringComparison.InvariantCultureIgnoreCase) + where p.Name.ToUpper().Contains(_name) select p.Id; - return await query.FirstOrDefaultAsync(); + return await query.FirstOrDefaultAsync(cancellationToken); } } } diff --git a/sample/QuerySample/Queries/ContactNameContains.cs b/sample/QuerySample/Queries/ContactNameContains.cs index 4fb7811..0b3a800 100644 --- a/sample/QuerySample/Queries/ContactNameContains.cs +++ b/sample/QuerySample/Queries/ContactNameContains.cs @@ -10,14 +10,14 @@ public class ContactNameContains : IQueryList public ContactNameContains(string namePart) { - _namePart = namePart; + _namePart = namePart.ToUpperInvariant(); } public IQueryable Query(IQueryContext context) { var persons = context.Set(); var query = from p in persons - where p.Name.Contains(_namePart, StringComparison.InvariantCultureIgnoreCase) + where p.Name.ToUpper().Contains(_namePart) select new ContactModel { Id = p.Id, diff --git a/sample/QuerySample/QuerySample.csproj b/sample/QuerySample/QuerySample.csproj index 3a1286f..28f2a61 100644 --- a/sample/QuerySample/QuerySample.csproj +++ b/sample/QuerySample/QuerySample.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/myNOC.EntityFramework.Query/Extensions/IServiceCollectionExtensions.cs b/src/myNOC.EntityFramework.Query/Extensions/IServiceCollectionExtensions.cs index 62474a7..4648599 100644 --- a/src/myNOC.EntityFramework.Query/Extensions/IServiceCollectionExtensions.cs +++ b/src/myNOC.EntityFramework.Query/Extensions/IServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Reflection; namespace myNOC.EntityFramework.Query.Extensions { @@ -6,12 +8,21 @@ public static class IServiceCollectionExtensions { public static IServiceCollection AddQueryPattern(this IServiceCollection services) { - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - var contextTypes = assemblies.SelectMany(x => x.GetTypes()).CanImplement(typeof(IQueryContext)); - var repositoryTypes = assemblies.SelectMany(x => x.GetTypes()).CanImplement(typeof(IQueryRepository)); + return services.AddQueryPattern(AppDomain.CurrentDomain.GetAssemblies()); + } + + public static IServiceCollection AddQueryPattern(this IServiceCollection services, params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(assemblies); + + var types = assemblies.Distinct().SelectMany(assembly => assembly.GetTypes()).ToArray(); + var implementations = types.CanImplement(typeof(IQueryContext)) + .Concat(types.CanImplement(typeof(IQueryRepository))); - contextTypes.Apply(c => c.Interfaces.Apply(i => services.AddScoped(i, c.Type))); - repositoryTypes.Apply(c => c.Interfaces.Apply(i => services.AddScoped(i, c.Type))); + foreach (var implementation in implementations) + foreach (var serviceInterface in implementation.Interfaces) + services.TryAddEnumerable(ServiceDescriptor.Scoped(serviceInterface, implementation.Type)); return services; } diff --git a/src/myNOC.EntityFramework.Query/Extensions/TypeExtensions.cs b/src/myNOC.EntityFramework.Query/Extensions/TypeExtensions.cs index f40a5ec..2ca581b 100644 --- a/src/myNOC.EntityFramework.Query/Extensions/TypeExtensions.cs +++ b/src/myNOC.EntityFramework.Query/Extensions/TypeExtensions.cs @@ -6,6 +6,7 @@ public static List CanImplement(this IEnumerable types, Ty { var typeInterfaces = types.Where(x => !x.IsAbstract && !x.IsInterface + && !x.ContainsGenericParameters && x.IsAssignableTo(interfaceType)) .Select(x => new TypeInterfaces(x)).ToList(); @@ -34,9 +35,6 @@ private static IEnumerable GetServiceInterfaces(Type type) yield return it; } - if (type.BaseType != null && type.BaseType != typeof(System.Object)) - foreach(var it in GetServiceInterfaces(type.BaseType)) - yield return it; } } } diff --git a/src/myNOC.EntityFramework.Query/QueryRepository.cs b/src/myNOC.EntityFramework.Query/QueryRepository.cs index 7aa0306..c2197ee 100644 --- a/src/myNOC.EntityFramework.Query/QueryRepository.cs +++ b/src/myNOC.EntityFramework.Query/QueryRepository.cs @@ -11,16 +11,27 @@ public QueryRepository(IQueryContext context) _context = context; } - public async Task> Query(IQueryList query) where TModel : class + public Task> Query(IQueryList query) where TModel : class { + return Query(query, CancellationToken.None); + } + + public async Task> Query(IQueryList query, CancellationToken cancellationToken) where TModel : class + { + cancellationToken.ThrowIfCancellationRequested(); var result = query.Query(_context); - return await result.ToListAsync(); + return await result.ToListAsync(cancellationToken); + } + + public Task Query(IQueryScalar query) + { + return Query(query, CancellationToken.None); } - public async Task Query(IQueryScalar query) + public Task Query(IQueryScalar query, CancellationToken cancellationToken) { - var result = await query.GetScalar(_context); - return result; + cancellationToken.ThrowIfCancellationRequested(); + return query.GetScalar(_context, cancellationToken); } } } diff --git a/src/myNOC.EntityFramework.Query/README.md b/src/myNOC.EntityFramework.Query/README.md index ff78bfd..b5e99a7 100644 --- a/src/myNOC.EntityFramework.Query/README.md +++ b/src/myNOC.EntityFramework.Query/README.md @@ -6,6 +6,10 @@ dotnet add package myNOC.EntityFramework.Query ``` +Register the EF Core provider used by your application separately. To follow the +SQLite examples, also install `Microsoft.EntityFrameworkCore.Sqlite`, using an +EF Core version matching your target framework and other EF packages. + Supports **.NET 8.0 and .NET 10.0** (LTS), using EF Core 8 and EF Core 10 respectively. Match your application's Entity Framework packages to its target. See the [repository build instructions](https://github.com/erenken/queryPattern#local-build-and-validation) @@ -34,13 +38,24 @@ after NuGet finishes indexing the symbol package. ## Overview -A library used to create EntityFramework queries. +A small implementation of the **query-object pattern** for Entity Framework Core. -The concept is to create a concrete class that contains query written for a specific job. +Each concrete query object owns a read operation for a specific use case, including +its criteria and projection. This is not a classic specification containing only +reusable selection criteria. Package names and APIs such as `AddQueryPattern` +remain unchanged. -This helps you keep your repositories focused on one entity. You no longer need to think if you are violating the SOLID pattern by having a function `SecurityRoles` in your `Employee` repository that is using an `Employee` and `Security` entity. +This keeps repositories focused on executing read operations rather than growing +a method for every screen or combining unrelated data requirements. A query +object can join multiple entities in its selected database context; for example, +an employee-security-roles query can own that specific projection without +expanding an existing employee-list query. -The other big advantage is the ability to write unit test against your queries. When I have written queries and written unit tests against them they would fully pass the unit tests, but them I would get runtime errors when it failed to convert to SQL. Using this method you can easily use EntityFramework's `InMemoryDatabase` and mock up test data. You can verify your complex queries will do what you expect them to do. +Query objects are also easy to test independently. Use a relational provider to +verify SQL translation as well as results. The sample and tests use SQLite +in-memory databases; EF Core's InMemory provider cannot validate SQL translation. +SQLite is supplementary coverage, not a substitute for testing against your +production database provider. Instead you would have a `EmployeeSecurityRoles` concrete class that inherits from `IQueryList<>`. @@ -56,11 +71,35 @@ To use `myNOC.EntityFramework.Query` you will need to add it to your `IServiceCo services.AddQueryPattern(); ``` -This will register any classes in the `AppDomain` that implements `IQueryContext` or `IQueryRepository` +The default scans **all assemblies currently loaded in the `AppDomain`** and +registers concrete, closed classes implementing `IQueryContext` or +`IQueryRepository`, including inherited/custom query interfaces, as scoped services. +It is not restricted to the calling assembly or a single database. Repeated calls +do not duplicate the same service-interface/implementation pair, and multiple +implementations of an interface are retained for `IEnumerable` resolution. + +For explicit discovery boundaries, pass any number of assemblies: + +```csharp +services.AddQueryPattern(typeof(AddressBookContext).Assembly, + typeof(SecurityContext).Assembly); +``` + +Assemblies are inspected once per call. Unloaded assemblies are not discovered +by the default overload; load plugins before calling it, or call the explicit +overload after loading each plugin. Reflection/type-load errors are not silently +ignored, so a missing dependency cannot quietly hide registrations. + +This registers contexts and repositories, **not** `IQueryList` or +`IQueryScalar` objects: their constructor arguments are runtime query criteria, +so callers create them when executing a query. Neither overload registers a +`DbContext`; register each concrete database context separately using `AddDbContext`. ## Setting Up `QueryContext` -You will need to setup a `QueryContext` for any `DbContext` you want the query pattern to use. The query pattern needs the `DbContext` because your queries may need access to multiple entities. +Set up a `QueryContext` for each `DbContext` used by the query-object pattern. +The context exposes entities for composing queries, while DI owns and disposes +the underlying scoped database context. I recommend you first create an interface for your context. @@ -73,18 +112,43 @@ Then you need to create your context `AddressBookContext` that inherits from the ```csharp public class AddressBookContext : QueryContext, IAddressBookContext { - private DbContext? _dbContext = null; - public override DbContext GetContext() + private readonly AddressBookDbContext _dbContext; + + public AddressBookContext(AddressBookDbContext dbContext) { - if (_dbContext == null) - _dbContext = new AddressBookDbContext(); + _dbContext = dbContext; + } + public override DbContext GetContext() + { return _dbContext; } } ``` -This allows you to use any `DbContext` you want. You could inject a `DbContext` you are already using in your application and return it in `GetContext()`. +Inject the concrete context type, not a shared `DbContext` registration. Define +its typed options constructor like this: + +```csharp +public class AddressBookDbContext : DbContext +{ + public AddressBookDbContext(DbContextOptions options) + : base(options) { } + + public DbSet Contacts { get; set; } = default!; +} +``` + +Register it with the provider appropriate to your app: + +```csharp +services.AddDbContext(options => + options.UseSqlite("Data Source=addressbook.db")); +services.AddQueryPattern(); +``` + +Do not manually create or dispose this context inside `QueryContext`. Resolve +repositories inside a request scope, or an explicit scope in a console app. ## Setting Up `QueryRepository` @@ -103,6 +167,49 @@ public class AddressBookContextRepository : QueryRepository, IAddressBookContext } ``` +## Multiple database contexts + +Keep a distinct custom context interface and repository interface for each +database, and inject that custom interface into its repository. For example: + +```csharp +public interface ISecurityContext : IQueryContext { } +public interface ISecurityRepository : IQueryRepository { } + +public class SecurityContext : QueryContext, ISecurityContext +{ + private readonly SecurityDbContext _context; + + public SecurityContext(SecurityDbContext context) + { + _context = context; + } + + public override DbContext GetContext() => _context; +} + +public class SecurityRepository : QueryRepository, ISecurityRepository +{ + public SecurityRepository(ISecurityContext context) : base(context) { } +} + +services.AddDbContext(options => + options.UseSqlite("Data Source=addressbook.db")); +services.AddDbContext(options => + options.UseSqlite("Data Source=security.db")); +services.AddQueryPattern(); +``` + +`IAddressBookContextRepository` uses `AddressBookDbContext`; `ISecurityRepository` +uses `SecurityDbContext`, even in the same scope. `SecurityDbContext` should accept +`DbContextOptions`, just as the address-book context accepts its +own typed options. DI disposes both contexts at scope end. + +When multiple implementations exist, do not resolve bare `IQueryContext` or +`IQueryRepository` to choose a database: normal DI resolution selects the last +registration. Use the custom interfaces instead. Query objects execute against +the selected repository's context; one query does not perform a cross-database join. + ## Create Queries There are two types of queries. @@ -121,14 +228,14 @@ public class ContactNameContains : IQueryList public ContactNameContains(string namePart) { - _namePart = namePart; + _namePart = namePart.ToUpperInvariant(); } public IQueryable Query(IQueryContext context) { var persons = context.Set(); var query = from p in persons - where p.Name.Contains(_namePart, StringComparison.InvariantCultureIgnoreCase) + where p.Name.ToUpper().Contains(_namePart) select new ContactModel { Id = p.Id, @@ -158,29 +265,34 @@ You can then use `persons` in your query. You could just use the `context.Set` ```csharp -internal class ContactGetIdByName : IQueryScalar +public class ContactGetIdByName : IQueryScalar { private readonly string _name; public ContactGetIdByName(string name) { - _name = name; + _name = name.ToUpperInvariant(); + } + + public Task GetScalar(IQueryContext context) + { + return GetScalar(context, CancellationToken.None); } - public async Task GetScalar(IQueryContext context) + public async Task GetScalar(IQueryContext context, CancellationToken cancellationToken) { var persons = context.Set(); var query = from p in persons - where p.Name.Contains(_name, StringComparison.InvariantCultureIgnoreCase) + where p.Name.ToUpper().Contains(_name) select p.Id; - return await query.FirstOrDefaultAsync(); + return await query.FirstOrDefaultAsync(cancellationToken); } } ``` @@ -191,13 +303,26 @@ Just like `IQueryList<>` you pass in your criteria to the constructor. ```csharp IServiceCollection services = new ServiceCollection(); +services.AddDbContext(options => + options.UseSqlite("Data Source=addressbook.db")); services.AddQueryPattern(); -var provider = services.BuildServiceProvider(); +using var provider = services.BuildServiceProvider(new ServiceProviderOptions +{ + ValidateScopes = true +}); +using var scope = provider.CreateScope(); -var queryRepo = provider.GetRequiredService(); +var queryRepo = scope.ServiceProvider.GetRequiredService(); ``` +This example uses a SQLite file that must already have its schema and data +initialized. The sample's in-memory configuration and seeding are shown in the +testing section. Include `Microsoft.EntityFrameworkCore`, +`Microsoft.Extensions.DependencyInjection`, and +`myNOC.EntityFramework.Query.Extensions` namespaces for setup; the in-memory +example also uses `Microsoft.Data.Sqlite`. + You need to get an instance of your query repository `IAddressBookContextRepository`. Once you have your `queryRepo` you can execute the query. @@ -218,32 +343,27 @@ var id = await queryRepo.Query(new ContactGetIdByName("Bob")); ## Testing -For testing in the sample application I used an `InMemoryDatabase`. +The sample uses a SQLite in-memory database, with an open connection keeping the +database alive. DI creates and disposes the connection and scoped context: ```csharp -public class AddressBookDbContext : DbContext +services.AddSingleton(_ => { - public DbSet Contacts { get; set; } = default!; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseInMemoryDatabase("AddressBook"); - base.OnConfiguring(optionsBuilder); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - } -} + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; +}); +services.AddDbContext((provider, options) => + options.UseSqlite(provider.GetRequiredService())); +services.AddQueryPattern(); ``` and seeded my data like so. ```csharp -static async Task SeedSampleData() +static async Task SeedSampleData(AddressBookDbContext addressBook) { - var addressBook = new AddressBookDbContext(); + await addressBook.Database.EnsureCreatedAsync(); addressBook.Add(new ContactEntity { Id = 1, Name = "Abby" }); addressBook.Add(new ContactEntity { Id = 2, Name = "Bob" }); addressBook.Add(new ContactEntity { Id = 3, Name = "Charlie" }); @@ -251,3 +371,37 @@ static async Task SeedSampleData() await addressBook.SaveChangesAsync(); } ``` + +Resolve the context from the scope and pass it into `SeedSampleData` before running +queries. Each test uses its own open SQLite connection to isolate database state. +Tests cover sample query translation/results, cancellation forwarding, and two +independent typed contexts with scope-owned disposal. + +The sample normalizes search text with `ToUpperInvariant` and uses SQL `UPPER` +with `Contains`, avoiding the unsupported `StringComparison` overload. This +demonstrates case-insensitive ASCII matching in SQLite, not identical .NET +invariant-culture or Unicode behavior across database engines. For production, +choose collation/normalization appropriate to your provider and test it there; +applying `UPPER` to a column can also affect index usage. + +## Cancellation and compatibility + +Existing `Query(query)` and `GetScalar(context)` signatures remain available. +Pass a token to either repository overload for cancellation: + +```csharp +var contacts = await queryRepo.Query(new ContactNameContains("a"), cancellationToken); +var id = await queryRepo.Query(new ContactGetIdByName("Bob"), cancellationToken); +``` + +`QueryRepository` rejects an already-canceled token before constructing or +executing a query. List execution forwards the token to `ToListAsync`; scalar +execution forwards it to `GetScalar(context, cancellationToken)`. Scalar query +implementations should override that overload and pass the token to every async +EF operation, as shown above. + +Default interface implementations preserve compatibility for existing scalar +queries and custom `IQueryRepository` implementations. Those legacy fallbacks +only check cancellation before execution; they cannot cancel an in-flight +operation unless you implement the new overload and forward its token. Actual +in-flight cancellation support also depends on the database provider. diff --git a/src/myNOC.EntityFramework.Query/_IQueryRepository.cs b/src/myNOC.EntityFramework.Query/_IQueryRepository.cs index bffac03..67470d3 100644 --- a/src/myNOC.EntityFramework.Query/_IQueryRepository.cs +++ b/src/myNOC.EntityFramework.Query/_IQueryRepository.cs @@ -6,5 +6,17 @@ public interface IQueryRepository { public Task> Query(IQueryList query) where TModel : class; public Task Query(IQueryScalar query); + + public Task> Query(IQueryList query, CancellationToken cancellationToken) where TModel : class + { + cancellationToken.ThrowIfCancellationRequested(); + return Query(query); + } + + public Task Query(IQueryScalar query, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Query(query); + } } } diff --git a/src/myNOC.EntityFramework.Query/_IQueryScalar.cs b/src/myNOC.EntityFramework.Query/_IQueryScalar.cs index cd713f8..4263771 100644 --- a/src/myNOC.EntityFramework.Query/_IQueryScalar.cs +++ b/src/myNOC.EntityFramework.Query/_IQueryScalar.cs @@ -2,6 +2,12 @@ namespace myNOC.EntityFramework.Query { public interface IQueryScalar { - Task GetScalar(IQueryContext context); + Task GetScalar(IQueryContext context); + + Task GetScalar(IQueryContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return GetScalar(context); + } } } diff --git a/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj b/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj index e80a7e8..80fe1ff 100644 --- a/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj +++ b/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj @@ -9,7 +9,7 @@ False myNOC.EntityFramework.Query myNOC LLC - Implements a Query Pattern to be used with EntityFramework + Implements the query-object pattern for Entity Framework Core README.md LICENSE.txt Copyright © myNOC LLC 2025 diff --git a/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs b/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs index ce9baad..e68a0fc 100644 --- a/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs +++ b/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using myNOC.EntityFramework.Query; using myNOC.EntityFramework.Query.Extensions; +using QuerySample.Data; namespace myNOC.Tests.EntityFramework.Query.Extensions { @@ -29,6 +30,95 @@ public void AddQueryPattern_ScanAppDomainForClassesThatInheritFromIQueryContextO && x.Lifetime == ServiceLifetime.Scoped)); } + [TestMethod] + public void AddQueryPattern_ExplicitAssembly_DiscoversInheritedInterfacesOnlyInSelectedAssembly() + { + var services = new ServiceCollection(); + + services.AddQueryPattern(typeof(IServiceCollectionExtensionsTests).Assembly); + + Assert.IsTrue(services.Any(service => service.ServiceType == typeof(IDerivedQueryContext) + && service.ImplementationType == typeof(DerivedQueryContext))); + Assert.IsFalse(services.Any(service => service.ImplementationType == typeof(AddressBookContext))); + } + + [TestMethod] + public void AddQueryPattern_Default_DiscoversImplementationsAcrossLoadedAssemblies() + { + _ = typeof(AddressBookContext).Assembly; + var services = new ServiceCollection(); + + services.AddQueryPattern(); + + Assert.IsTrue(services.Any(service => service.ImplementationType == typeof(AddressBookContext))); + Assert.IsTrue(services.Any(service => service.ImplementationType == typeof(TestQueryContext))); + } + + [TestMethod] + public void AddQueryPattern_RepeatedCallsAndAssemblies_DoNotDuplicateRegistrations() + { + var services = new ServiceCollection(); + var assembly = typeof(IServiceCollectionExtensionsTests).Assembly; + + services.AddQueryPattern(assembly, assembly); + var count = services.Count; + services.AddQueryPattern(assembly); + + Assert.AreEqual(count, services.Count); + Assert.AreEqual(1, services.Count(service => service.ServiceType == typeof(IQueryContext) + && service.ImplementationType == typeof(DerivedQueryContext))); + } + + [TestMethod] + public void AddQueryPattern_OpenGenericImplementations_AreNotRegistered() + { + var services = new ServiceCollection(); + + services.AddQueryPattern(typeof(IServiceCollectionExtensionsTests).Assembly); + + Assert.IsFalse(services.Any(service => service.ImplementationType == typeof(GenericQueryContext<>))); + } + + [TestMethod] + public async Task LegacyRepository_DefaultCancellationOverloads_DelegateToExistingMethods() + { + IQueryRepository repository = new LegacyRepository(); + using var cancellation = new CancellationTokenSource(); + + var scalar = await repository.Query(null!, cancellation.Token); + var list = await repository.Query((IQueryList)null!, cancellation.Token); + + Assert.AreEqual(42, scalar); + Assert.AreEqual(0, list.Count()); + } + + [TestMethod] + public async Task LegacyRepository_DefaultCancellationOverloads_RemainCompatible() + { + IQueryRepository repository = new TestQueryRepository(); + var token = new CancellationToken(true); + + await Assert.ThrowsAsync(() => repository.Query(null!, token)); + await Assert.ThrowsAsync(() => repository.Query((IQueryList)null!, token)); + } + + internal interface IDerivedQueryContext : IQueryContext { } + internal class DerivedQueryContext : TestQueryContext, IDerivedQueryContext { } + internal class GenericQueryContext : TestQueryContext { } + + internal class LegacyRepository : IQueryRepository + { + public Task> Query(IQueryList query) where TModel : class + { + return Task.FromResult>(Array.Empty()); + } + + public Task Query(IQueryScalar query) + { + return Task.FromResult((TReturn?)(object)42); + } + } + internal class TestQueryContext : IQueryContext { public IQueryable Set() where TEntity : class diff --git a/tests/myNOC.Tests.EntityFramework.Query/Extensions/TypeExtensionsTests.cs b/tests/myNOC.Tests.EntityFramework.Query/Extensions/TypeExtensionsTests.cs index 6a304de..0c01226 100644 --- a/tests/myNOC.Tests.EntityFramework.Query/Extensions/TypeExtensionsTests.cs +++ b/tests/myNOC.Tests.EntityFramework.Query/Extensions/TypeExtensionsTests.cs @@ -64,6 +64,7 @@ public void CanImplement_Types_ImplementsInterface_NestedClassesRecursion() Assert.AreEqual(2, results.Count()); Assert.IsNotNull(results.FirstOrDefault(x => x.Type == typeof(TestClass6))); Assert.IsNotNull(results.FirstOrDefault(x => x.Type == typeof(TestClass8))); + Assert.IsTrue(results.All(result => result.Interfaces.Count == result.Interfaces.Distinct().Count())); Assert.IsNull(results.FirstOrDefault(x => x.Type == typeof(TestClass4))); } diff --git a/tests/myNOC.Tests.EntityFramework.Query/MultipleContextTests.cs b/tests/myNOC.Tests.EntityFramework.Query/MultipleContextTests.cs new file mode 100644 index 0000000..6466e14 --- /dev/null +++ b/tests/myNOC.Tests.EntityFramework.Query/MultipleContextTests.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using myNOC.EntityFramework.Query; +using myNOC.EntityFramework.Query.Extensions; +using QuerySample.Data; +using QuerySample.Entities; +using QuerySample.Queries; + +namespace myNOC.Tests.EntityFramework.Query +{ + [TestClass] + public class MultipleContextTests + { + [TestMethod] + public async Task AddQueryPattern_MultipleDbContexts_RoutesQueriesAndDisposesBothContexts() + { + var services = new ServiceCollection(); + services.AddDbContext(options => options.UseSqlite("Data Source=:memory:")); + services.AddDbContext(options => options.UseSqlite("Data Source=:memory:")); + services.AddQueryPattern(typeof(AddressBookContext).Assembly, typeof(MultipleContextTests).Assembly); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + AddressBookDbContext firstDbContext; + SecondDbContext secondDbContext; + + using (var scope = provider.CreateScope()) + { + firstDbContext = scope.ServiceProvider.GetRequiredService(); + secondDbContext = scope.ServiceProvider.GetRequiredService(); + await Seed(firstDbContext, "First database"); + await Seed(secondDbContext, "Second database"); + var firstRepository = scope.ServiceProvider.GetRequiredService(); + var secondRepository = scope.ServiceProvider.GetRequiredService(); + + var firstResult = await firstRepository.Query(new ContactNameContains("database")); + var secondResult = await secondRepository.Query(new ContactNameContains("database")); + + Assert.AreEqual("First database", firstResult.Single().Name); + Assert.AreEqual("Second database", secondResult.Single().Name); + Assert.AreSame(firstDbContext, scope.ServiceProvider.GetRequiredService()); + Assert.AreSame(secondDbContext, scope.ServiceProvider.GetRequiredService()); + Assert.AreSame(firstRepository, scope.ServiceProvider.GetRequiredService()); + } + + Assert.Throws(() => firstDbContext.Contacts.Count()); + Assert.Throws(() => secondDbContext.Contacts.Count()); + + using var nextScope = provider.CreateScope(); + Assert.AreNotSame(firstDbContext, nextScope.ServiceProvider.GetRequiredService()); + Assert.AreNotSame(secondDbContext, nextScope.ServiceProvider.GetRequiredService()); + } + + private static async Task Seed(DbContext context, string name) + { + await context.Database.OpenConnectionAsync(); + await context.Database.EnsureCreatedAsync(); + context.Set().Add(new ContactEntity { Id = 1, Name = name }); + await context.SaveChangesAsync(); + } + + public interface ISecondContext : IQueryContext { } + public interface ISecondRepository : IQueryRepository { } + + public class SecondDbContext : DbContext + { + public SecondDbContext(DbContextOptions options) : base(options) { } + + public DbSet Contacts { get; set; } = default!; + } + + public class SecondQueryContext : QueryContext, ISecondContext + { + private readonly SecondDbContext _context; + + public SecondQueryContext(SecondDbContext context) + { + _context = context; + } + + public override DbContext GetContext() + { + return _context; + } + } + + public class SecondQueryRepository : QueryRepository, ISecondRepository + { + public SecondQueryRepository(ISecondContext context) : base(context) { } + } + } +} diff --git a/tests/myNOC.Tests.EntityFramework.Query/QueryContextTests.cs b/tests/myNOC.Tests.EntityFramework.Query/QueryContextTests.cs index 38b9555..d7b8dd8 100644 --- a/tests/myNOC.Tests.EntityFramework.Query/QueryContextTests.cs +++ b/tests/myNOC.Tests.EntityFramework.Query/QueryContextTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using myNOC.EntityFramework.Query; using NSubstitute; @@ -10,11 +11,14 @@ public class QueryContextTests { private TestContext _testDbContext = default!; private QueryContext _queryContext = Substitute.ForPartsOf(); + private SqliteConnection _connection = default!; [TestInitialize] public void Initialize() { - var options = new DbContextOptionsBuilder().UseInMemoryDatabase("testContext").Options; + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; _testDbContext = new TestContext(options); } @@ -22,8 +26,8 @@ public void Initialize() [TestCleanup] public void Cleanup() { - _testDbContext?.Database?.EnsureDeleted(); _testDbContext?.Dispose(); + _connection?.Dispose(); } [TestMethod] diff --git a/tests/myNOC.Tests.EntityFramework.Query/QueryRepositoryTests.cs b/tests/myNOC.Tests.EntityFramework.Query/QueryRepositoryTests.cs index 15979ad..b482111 100644 --- a/tests/myNOC.Tests.EntityFramework.Query/QueryRepositoryTests.cs +++ b/tests/myNOC.Tests.EntityFramework.Query/QueryRepositoryTests.cs @@ -1,7 +1,10 @@ +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using myNOC.EntityFramework.Query; using NSubstitute; using System.ComponentModel.DataAnnotations; +using System.Data.Common; namespace myNOC.Tests.EntityFramework.Query { @@ -11,12 +14,18 @@ public class QueryRepositoryTests private QueryContext _queryContext = default!; private TestContext _testDbContext = default!; private QueryRepository _queryRepository = default!; + private SqliteConnection _connection = default!; + private CancellationInterceptor _interceptor = default!; [TestInitialize] public void Initialize() { - var options = new DbContextOptionsBuilder().UseInMemoryDatabase("testRepository").Options; + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + _interceptor = new CancellationInterceptor(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).AddInterceptors(_interceptor).Options; _testDbContext = new TestContext(options); + _testDbContext.Database.EnsureCreated(); _testDbContext.TestEntities.Add(new TestEntity()); _testDbContext.TestEntities.Add(new TestEntity()); _testDbContext.TestEntities.Add(new TestEntity()); @@ -31,8 +40,8 @@ public void Initialize() [TestCleanup] public void Cleanup() { - _testDbContext?.Database?.EnsureDeleted(); _testDbContext?.Dispose(); + _connection?.Dispose(); } [TestMethod] @@ -61,7 +70,79 @@ public async Task Query_RunAIQueryScalar_ReturnsInt() Assert.AreEqual(3, result); } - public class TestModel { } + [TestMethod] + public async Task Query_List_ForwardsCancellationTokenToDatabase() + { + using var cancellation = new CancellationTokenSource(); + var result = await _queryRepository.Query(new TestEntitiesGetAll(), cancellation.Token); + + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(cancellation.Token, _interceptor.LastToken); + } + + [TestMethod] + public async Task Query_Scalar_ForwardsCancellationTokenToDatabase() + { + using var cancellation = new CancellationTokenSource(); + var result = await _queryRepository.Query(new TestEntitiesCount(), cancellation.Token); + + Assert.AreEqual(3, result); + Assert.AreEqual(cancellation.Token, _interceptor.LastToken); + } + + [TestMethod] + public async Task Query_List_PreCanceledToken_DoesNotBuildQuery() + { + var query = Substitute.For>(); + var token = new CancellationToken(true); + + await Assert.ThrowsAsync(() => _queryRepository.Query(query, token)); + query.DidNotReceive().Query(Arg.Any()); + } + + [TestMethod] + public async Task Query_Scalar_PreCanceledToken_DoesNotExecuteQuery() + { + var query = Substitute.For>(); + var token = new CancellationToken(true); + + await Assert.ThrowsAsync(() => _queryRepository.Query(query, token)); + await query.DidNotReceive().GetScalar(Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task Query_List_CanceledDuringExecution_StopsDatabaseCommand() + { + using var cancellation = new CancellationTokenSource(); + _interceptor.CancelOnRead = cancellation; + + await Assert.ThrowsAsync(() => + _queryRepository.Query(new TestEntitiesGetAll(), cancellation.Token)); + Assert.AreEqual(cancellation.Token, _interceptor.LastToken); + } + + [TestMethod] + public async Task Query_Scalar_CanceledDuringExecution_StopsDatabaseCommand() + { + using var cancellation = new CancellationTokenSource(); + _interceptor.CancelOnRead = cancellation; + + await Assert.ThrowsAsync(() => + _queryRepository.Query(new TestEntitiesCount(), cancellation.Token)); + Assert.AreEqual(cancellation.Token, _interceptor.LastToken); + } + + [TestMethod] + public async Task Query_LegacyScalar_StillExecutesWithCancellationOverload() + { + using var cancellation = new CancellationTokenSource(); + + var result = await _queryRepository.Query(new LegacyEntitiesCount(), cancellation.Token); + + Assert.AreEqual(3, result); + } + + public class TestModel { public int Id { get; set; } } public class TestEntity { @@ -81,15 +162,44 @@ internal class TestEntitiesGetAll : IQueryList public IQueryable Query(IQueryContext context) { return from te in context.Set() - select new TestModel(); + select new TestModel { Id = te.Id }; } } internal class TestEntitiesCount : IQueryScalar { - public async Task GetScalar(IQueryContext context) + public Task GetScalar(IQueryContext context) + { + return GetScalar(context, CancellationToken.None); + } + + public Task GetScalar(IQueryContext context, CancellationToken cancellationToken) + { + return context.Set().CountAsync(cancellationToken); + } + } + + internal class LegacyEntitiesCount : IQueryScalar + { + public Task GetScalar(IQueryContext context) + { + return context.Set().CountAsync(); + } + } + + private class CancellationInterceptor : DbCommandInterceptor + { + public CancellationToken LastToken { get; private set; } + public CancellationTokenSource? CancelOnRead { get; set; } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) { - return await context.Set().CountAsync(); + LastToken = cancellationToken; + CancelOnRead?.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask>(result); } } } diff --git a/tests/myNOC.Tests.EntityFramework.Query/SampleQueryTests.cs b/tests/myNOC.Tests.EntityFramework.Query/SampleQueryTests.cs new file mode 100644 index 0000000..1fedbdb --- /dev/null +++ b/tests/myNOC.Tests.EntityFramework.Query/SampleQueryTests.cs @@ -0,0 +1,90 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using myNOC.EntityFramework.Query; +using QuerySample.Data; +using QuerySample.Entities; +using QuerySample.Queries; + +namespace myNOC.Tests.EntityFramework.Query +{ + [TestClass] + public class SampleQueryTests + { + private SqliteConnection _connection = default!; + private AddressBookDbContext _dbContext = default!; + private IQueryRepository _repository = default!; + + [TestInitialize] + public async Task Initialize() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _dbContext = new AddressBookDbContext(options); + await _dbContext.Database.EnsureCreatedAsync(); + _dbContext.Contacts.AddRange( + new ContactEntity { Id = 1, Name = "Abby" }, + new ContactEntity { Id = 2, Name = "Bob" }, + new ContactEntity { Id = 3, Name = "Charlie" }, + new ContactEntity { Id = 4, Name = "David" }); + await _dbContext.SaveChangesAsync(); + _repository = new AddressBookContextRepository(new AddressBookContext(_dbContext)); + } + + [TestCleanup] + public void Cleanup() + { + _dbContext?.Dispose(); + _connection?.Dispose(); + } + + [TestMethod] + [DataRow("a")] + [DataRow("A")] + public async Task ContactNameContains_TranslatesToSqlAndMatchesIgnoringAsciiCase(string namePart) + { + var query = new ContactNameContains(namePart); + var sql = query.Query(new AddressBookContext(_dbContext)).ToQueryString(); + var result = (await _repository.Query(query)).ToList(); + + StringAssert.Contains(sql, "WHERE"); + CollectionAssert.AreEquivalent(new[] { "Abby", "Charlie", "David" }, result.Select(contact => contact.Name).ToArray()); + Assert.IsTrue(result.All(contact => contact.DisplayName == $"{contact.Id} - {contact.Name}")); + } + + [TestMethod] + public async Task ContactNameContains_StringComparisonOverload_IsRejectedBySqlite() + { + var query = _dbContext.Contacts.Where(contact => + contact.Name.Contains("a", StringComparison.InvariantCultureIgnoreCase)); + + await Assert.ThrowsAsync(() => query.ToListAsync()); + } + + [TestMethod] + public async Task ContactNameContains_NoMatches_ReturnsEmptyList() + { + var result = await _repository.Query(new ContactNameContains("missing")); + + Assert.AreEqual(0, result.Count()); + } + + [TestMethod] + public async Task ContactGetIdByName_TranslatesToSqlAndMatchesIgnoringAsciiCase() + { + using var cancellation = new CancellationTokenSource(); + + var result = await _repository.Query(new ContactGetIdByName("bOB"), cancellation.Token); + + Assert.AreEqual(2, result); + } + + [TestMethod] + public async Task ContactGetIdByName_NoMatches_ReturnsDefaultId() + { + var result = await _repository.Query(new ContactGetIdByName("missing")); + + Assert.AreEqual(0, result); + } + } +} diff --git a/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj b/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj index 0730a06..c7f5518 100644 --- a/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj +++ b/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj @@ -6,7 +6,7 @@ - + @@ -19,6 +19,7 @@ +