Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add two methods for consuming repositories in scenarios where repositories could be longer lived (e.g. Blazor component Injections) #289

Merged
merged 4 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions Ardalis.Specification.sln
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Specification.EntityFramewo
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Specification.EntityFramework6", "Specification.EntityFramework6", "{327AEBD6-C8A6-4851-BA42-632F8014CFC5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ardalis.Specification.EntityFrameworkCore.UnitTests", "Specification.EntityFrameworkCore\tests\Ardalis.Specification.EntityFrameworkCore.UnitTests\Ardalis.Specification.EntityFrameworkCore.UnitTests.csproj", "{53E4FFB4-CAC0-482D-B714-FA657C3244C9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -86,6 +88,10 @@ Global
{4BEB4DC4-DE33-4DF1-8A2F-CE76C1D72A4A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BEB4DC4-DE33-4DF1-8A2F-CE76C1D72A4A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BEB4DC4-DE33-4DF1-8A2F-CE76C1D72A4A}.Release|Any CPU.Build.0 = Release|Any CPU
{53E4FFB4-CAC0-482D-B714-FA657C3244C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{53E4FFB4-CAC0-482D-B714-FA657C3244C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{53E4FFB4-CAC0-482D-B714-FA657C3244C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53E4FFB4-CAC0-482D-B714-FA657C3244C9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -100,6 +106,7 @@ Global
{5AFD1454-E625-451D-A615-CEB7BB09AA65} = {B19F2F64-4B22-48C2-B2F8-7672F84F758D}
{37EC09C7-702D-4539-B98D-F67B15E1E6CE} = {327AEBD6-C8A6-4851-BA42-632F8014CFC5}
{4BEB4DC4-DE33-4DF1-8A2F-CE76C1D72A4A} = {327AEBD6-C8A6-4851-BA42-632F8014CFC5}
{53E4FFB4-CAC0-482D-B714-FA657C3244C9} = {B19F2F64-4B22-48C2-B2F8-7672F84F758D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C153A625-42F7-49A7-B99A-6A78B4B866B2}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net6.0;netstandard2.1;netstandard2.0</TargetFrameworks>
<TargetFrameworks>net6.0;netstandard2.1</TargetFrameworks>
<PackageId>Ardalis.Specification.EntityFrameworkCore</PackageId>
<Title>Ardalis.Specification.EntityFrameworkCore</Title>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace Ardalis.Specification.EntityFrameworkCore
{
public abstract class ContextFactoryRepositoryBaseOfT<TEntity, TContext> : IRepositoryBase<TEntity>
where TEntity : class
where TContext : DbContext
{
private IDbContextFactory<TContext> dbContextFactory;
private ISpecificationEvaluator specificationEvaluator;

public ContextFactoryRepositoryBaseOfT(IDbContextFactory<TContext> dbContextFactory)
: this(dbContextFactory, SpecificationEvaluator.Default)
{
}

public ContextFactoryRepositoryBaseOfT(IDbContextFactory<TContext> dbContextFactory,
ISpecificationEvaluator specificationEvaluator)
{
this.dbContextFactory = dbContextFactory;
this.specificationEvaluator = specificationEvaluator;
}

/// <inheritdoc/>
public async Task<TEntity?> GetByIdAsync<TId>(TId id, CancellationToken cancellationToken = default) where TId : notnull
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await dbContext.Set<TEntity>().FindAsync(new object[] { id }, cancellationToken: cancellationToken);
}

/// <inheritdoc/>
public async Task<TEntity?> GetBySpecAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TResult?> GetBySpecAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TEntity?> FirstOrDefaultAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TResult?> FirstOrDefaultAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is using using won't it make change tracking and later updates of the fetched entity impossible? The dbContext that originally is tracking the entity will be disposed by the end of this method, and so when a savechanges is called there will be an error saying "entity is already tracked by another dbcontext" or something equivalent, right? If not, can you write another few tests demonstrating that your solution works for fetch-change-save operations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies Steve, I didn't give that fact much attention in my initial request.

Yes, I would expect such an error with this approach. I'll add a virtual protected method which invokes the TrackGraph method inside the using to solve this. The original intention was to allow for maximum flexibility but on reflection a default approach to managing the change tracker is definitely appropriate.

I just need to work through getting the error we're expecting to present itself in tests to ensure it's being handled appropriately.

I'll post another update here when the above is completed.

Copy link

@mwasson74 mwasson74 Jun 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a virtual protected method which invokes the TrackGraph method inside the using to solve this.

@jasonsummers, was this done? If so, where? Either way, can you explain this for me please?

Thanks,

Matt

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwasson74, this wasn't completed in the end, mainly because the only solutions I could come up with added too much opinion into the library.

Essentially, due to the prolonged lifecycle of Blazor (and WPF/UWP/MAUI etc) apps, the new ContextFactoryRepositoryBaseOfT class instantiates a new instance of the DBContext every time a method is invoked. This means that all of the Entity Framework Change Tracking goodness is lost.

From a DDD perspective, this actually makes sense, whether or not an Entity has changed is a subject for the Entity to manage itself and not be reliant on a 3rd party to deduce.

What this means is that you need to implement change tracking manually within your solution. I think the only method you'll have to overload is the UpdateAsync method since that's the only one which really needs to know what's changed.

Sorry this is so vague, it's been a while since I've looked at this. Reply back here if you need more info. I'll try and carve out some time to write a proper doc for this as well.

return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TEntity?> SingleOrDefaultAsync(ISingleResultSpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TResult?> SingleOrDefaultAsync<TResult>(ISingleResultSpecification<TEntity, TResult> specification,
CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<List<TEntity>> ListAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await dbContext.Set<TEntity>().ToListAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<List<TEntity>> ListAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
var queryResult = await ApplySpecification(specification, dbContext).ToListAsync(cancellationToken);

return specification.PostProcessingAction == null ? queryResult : specification.PostProcessingAction(queryResult).ToList();
}

/// <inheritdoc/>
public async Task<List<TResult>> ListAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
var queryResult = await ApplySpecification(specification, dbContext).ToListAsync(cancellationToken);

return specification.PostProcessingAction == null ? queryResult : specification.PostProcessingAction(queryResult).ToList();
}

/// <inheritdoc/>
public async Task<int> CountAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext, true).CountAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<int> CountAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await dbContext.Set<TEntity>().CountAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<bool> AnyAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await ApplySpecification(specification, dbContext, true).AnyAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<bool> AnyAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
return await dbContext.Set<TEntity>().AnyAsync(cancellationToken);
}

/// <inheritdoc/>
public async Task<TEntity> AddAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().Add(entity);

await SaveChangesAsync(dbContext, cancellationToken);

return entity;
}

/// <inheritdoc/>
public async Task<IEnumerable<TEntity>> AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().AddRange(entities);

await SaveChangesAsync(dbContext, cancellationToken);

return entities;
}

/// <inheritdoc/>
public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().Update(entity);

await SaveChangesAsync(dbContext, cancellationToken);
}

/// <inheritdoc/>
public async Task UpdateRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().UpdateRange(entities);

await SaveChangesAsync(dbContext, cancellationToken);
}

/// <inheritdoc/>
public async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().Remove(entity);

await SaveChangesAsync(dbContext, cancellationToken);
}

/// <inheritdoc/>
public async Task DeleteRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
await using var dbContext = this.dbContextFactory.CreateDbContext();
dbContext.Set<TEntity>().RemoveRange(entities);

await SaveChangesAsync(dbContext, cancellationToken);
}

/// <inheritdoc/>
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
throw new InvalidOperationException();
}

public async Task<int> SaveChangesAsync(TContext dbContext, CancellationToken cancellationToken = default)
{
return await dbContext.SaveChangesAsync(cancellationToken);
}

/// <summary>
/// Filters the entities of <typeparamref name="TEntity"/>, to those that match the encapsulated query logic of the
/// <paramref name="specification"/>.
/// </summary>
/// <param name="specification">The encapsulated query logic.</param>
/// <returns>The filtered entities as an <see cref="IQueryable{T}"/>.</returns>
protected virtual IQueryable<TEntity> ApplySpecification(ISpecification<TEntity> specification, TContext dbContext, bool evaluateCriteriaOnly = false)
{
return specificationEvaluator.GetQuery(dbContext.Set<TEntity>().AsQueryable(), specification, evaluateCriteriaOnly);
}

/// <summary>
/// Filters all entities of <typeparamref name="TEntity" />, that matches the encapsulated query logic of the
/// <paramref name="specification"/>, from the database.
/// <para>
/// Projects each entity into a new form, being <typeparamref name="TResult" />.
/// </para>
/// </summary>
/// <typeparam name="TResult">The type of the value returned by the projection.</typeparam>
/// <param name="specification">The encapsulated query logic.</param>
/// <returns>The filtered projected entities as an <see cref="IQueryable{T}"/>.</returns>
protected virtual IQueryable<TResult> ApplySpecification<TResult>(ISpecification<TEntity, TResult> specification, TContext dbContext)
{
return specificationEvaluator.GetQuery(dbContext.Set<TEntity>().AsQueryable(), specification);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore;

namespace Ardalis.Specification.EntityFrameworkCore
{
/// <summary>
///
/// </summary>
/// <typeparam name="TRepository">The Interface of the repository created by this Factory</typeparam>
/// <typeparam name="TConcreteRepository">
/// The Concrete implementation of the repository interface to create
/// </typeparam>
/// <typeparam name="TContext">The DbContext derived class to support the concrete repository</typeparam>
public class EFRepositoryFactory<TRepository, TConcreteRepository, TContext> : IRepositoryFactory<TRepository>
where TConcreteRepository : TRepository
where TContext : DbContext
{
private IDbContextFactory<TContext> dbContextFactory;

/// <summary>
/// Initialises a new instance of the EFRepositoryFactory
/// </summary>
/// <param name="dbContextFactory">The IDbContextFactory to use to generate the TContext</param>
public EFRepositoryFactory(IDbContextFactory<TContext> dbContextFactory)
{
this.dbContextFactory = dbContextFactory;
}

/// <inheritdoc />
public TRepository CreateRepository()
{
var args = new object[] { dbContextFactory.CreateDbContext() };
return (TRepository)Activator.CreateInstance(typeof(TConcreteRepository), args);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Ardalis.Specification.EntityFrameworkCore
{
/// <summary>
/// Generates new instances of <typeparamref name="TRepository"/> to encapsulate the 'Unit of Work' pattern
/// in scenarios where injected types may be long-lived (e.g. Blazor)
/// </summary>
/// <typeparam name="TRepository">
/// The Interface of the Repository to be generated.
/// </typeparam>
public interface IRepositoryFactory<TRepository>
{
/// <summary>
/// Generates a new repository instance
/// </summary>
/// <returns>The generated repository instance</returns>
public TRepository CreateRepository();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\sample\Ardalis.SampleApp.Infrastructure\Ardalis.SampleApp.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Ardalis.Specification.EntityFrameworkCore\Ardalis.Specification.EntityFrameworkCore.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Ardalis.SampleApp.Core.Entities.CustomerAggregate;
using Ardalis.SampleApp.Core.Interfaces;
using Ardalis.SampleApp.Infrastructure.Data;
using Ardalis.SampleApp.Infrastructure.DataAccess;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;

namespace Ardalis.Specification.EntityFrameworkCore.UnitTests;

public class UnitTest1
{
[Fact]
public void CorrectlyInstantiatesRepository()
{
var mockContextFactory = new Mock<IDbContextFactory<SampleDbContext>>();
mockContextFactory.Setup(x => x.CreateDbContext())
.Returns(() => new SampleDbContext(new DbContextOptions<SampleDbContext>()));

var repositoryFactory =
new EFRepositoryFactory<IRepository<Customer>, MyRepository<Customer>, SampleDbContext>(mockContextFactory
.Object);

var repository = repositoryFactory.CreateRepository();
Assert.IsType<MyRepository<Customer>>(repository);
}
}