Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`.
11 changes: 7 additions & 4 deletions sample/QuerySample/Data/AddressBookContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
9 changes: 1 addition & 8 deletions sample/QuerySample/Data/AddressBookDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,8 @@ public class AddressBookDbContext : DbContext
{
public DbSet<ContactEntity> Contacts { get; set; } = default!;

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
public AddressBookDbContext(DbContextOptions<AddressBookDbContext> options) : base(options)
{
optionsBuilder.UseInMemoryDatabase("AddressBook");
base.OnConfiguring(optionsBuilder);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
}
23 changes: 16 additions & 7 deletions sample/QuerySample/Program.cs
Original file line number Diff line number Diff line change
@@ -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<AddressBookDbContext>((provider, options) =>
options.UseSqlite(provider.GetRequiredService<SqliteConnection>()));
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<AddressBookDbContext>());

var queryRepo = provider.GetRequiredService<IAddressBookContextRepository>();
var queryRepo = scope.ServiceProvider.GetRequiredService<IAddressBookContextRepository>();
var result = await queryRepo.Query(new ContactGetAll());
DisplayResults("Return All Contacts", result);

Expand All @@ -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" });
Expand Down
15 changes: 10 additions & 5 deletions sample/QuerySample/Queries/ContactGetIdByName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,28 @@

namespace QuerySample.Queries
{
internal class ContactGetIdByName : IQueryScalar<int>
public class ContactGetIdByName : IQueryScalar<int>
{
private readonly string _name;

public ContactGetIdByName(string name)
{
_name = name;
_name = name.ToUpperInvariant();
}

public async Task<int> GetScalar(IQueryContext context)
public Task<int> GetScalar(IQueryContext context)
{
return GetScalar(context, CancellationToken.None);
}

public async Task<int> GetScalar(IQueryContext context, CancellationToken cancellationToken)
{
var persons = context.Set<ContactEntity>();
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);
}
}
}
4 changes: 2 additions & 2 deletions sample/QuerySample/Queries/ContactNameContains.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ public class ContactNameContains : IQueryList<ContactModel>

public ContactNameContains(string namePart)
{
_namePart = namePart;
_namePart = namePart.ToUpperInvariant();
}

public IQueryable<ContactModel> Query(IQueryContext context)
{
var persons = context.Set<ContactEntity>();
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,
Expand Down
2 changes: 1 addition & 1 deletion sample/QuerySample/QuerySample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EntityFrameworkVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(EntityFrameworkVersion)" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(DependencyInjectionVersion)" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System.Reflection;

namespace myNOC.EntityFramework.Query.Extensions
{
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;
}
Expand Down
4 changes: 1 addition & 3 deletions src/myNOC.EntityFramework.Query/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public static List<TypeInterfaces> CanImplement(this IEnumerable<Type> types, Ty
{
var typeInterfaces = types.Where(x => !x.IsAbstract
&& !x.IsInterface
&& !x.ContainsGenericParameters
&& x.IsAssignableTo(interfaceType))
.Select(x => new TypeInterfaces(x)).ToList();

Expand Down Expand Up @@ -34,9 +35,6 @@ private static IEnumerable<Type> 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;
}
}
}
21 changes: 16 additions & 5 deletions src/myNOC.EntityFramework.Query/QueryRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,27 @@ public QueryRepository(IQueryContext context)
_context = context;
}

public async Task<IEnumerable<TModel>> Query<TModel>(IQueryList<TModel> query) where TModel : class
public Task<IEnumerable<TModel>> Query<TModel>(IQueryList<TModel> query) where TModel : class
{
return Query(query, CancellationToken.None);
}

public async Task<IEnumerable<TModel>> Query<TModel>(IQueryList<TModel> query, CancellationToken cancellationToken) where TModel : class
{
cancellationToken.ThrowIfCancellationRequested();
var result = query.Query(_context);
return await result.ToListAsync();
return await result.ToListAsync(cancellationToken);
}

public Task<TReturn?> Query<TReturn>(IQueryScalar<TReturn> query)
{
return Query(query, CancellationToken.None);
}

public async Task<TReturn?> Query<TReturn>(IQueryScalar<TReturn> query)
public Task<TReturn?> Query<TReturn>(IQueryScalar<TReturn> query, CancellationToken cancellationToken)
{
var result = await query.GetScalar(_context);
return result;
cancellationToken.ThrowIfCancellationRequested();
return query.GetScalar(_context, cancellationToken);
}
}
}
Loading
Loading