diff --git a/AppCoreNet.Data.sln b/AppCoreNet.Data.sln index f1629a1..033ec09 100644 --- a/AppCoreNet.Data.sln +++ b/AppCoreNet.Data.sln @@ -48,6 +48,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCoreNet.Data.MongoDB.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCoreNet.Data.SpecificationTests", "test\AppCoreNet.Data.SpecificationTests\AppCoreNet.Data.SpecificationTests.csproj", "{BFC43033-85FA-479A-AFCE-F7BF70DDB11B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCoreNet.Data.EntityFramework", "src\AppCoreNet.Data.EntityFramework\AppCoreNet.Data.EntityFramework.csproj", "{8C3B072A-50E4-4C49-847D-C99999999999}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCoreNet.Data.EntityFramework.Tests", "test\AppCoreNet.Data.EntityFramework.Tests\AppCoreNet.Data.EntityFramework.Tests.csproj", "{8C3B072A-50E4-4C49-847D-C9999999999A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -90,6 +94,14 @@ Global {BFC43033-85FA-479A-AFCE-F7BF70DDB11B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BFC43033-85FA-479A-AFCE-F7BF70DDB11B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFC43033-85FA-479A-AFCE-F7BF70DDB11B}.Release|Any CPU.Build.0 = Release|Any CPU + {8C3B072A-50E4-4C49-847D-C99999999999}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C3B072A-50E4-4C49-847D-C99999999999}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C3B072A-50E4-4C49-847D-C99999999999}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C3B072A-50E4-4C49-847D-C99999999999}.Release|Any CPU.Build.0 = Release|Any CPU + {8C3B072A-50E4-4C49-847D-C9999999999A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C3B072A-50E4-4C49-847D-C9999999999A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C3B072A-50E4-4C49-847D-C9999999999A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C3B072A-50E4-4C49-847D-C9999999999A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -101,7 +113,9 @@ Global {2C53E3E5-928F-4D96-8ADF-140F589C1887} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} {1CDC4550-43F9-4EEA-B5BB-B32ED68026FA} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} {EE52F420-0C7E-4F83-A171-3A1A7B5F5B4F} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} + {8C3B072A-50E4-4C49-847D-C99999999999} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} {5E374BA2-6CA6-4EA7-B572-15DFD1DE11EB} = {C8F2D282-96F6-48F4-B707-79E7FF58974C} + {8C3B072A-50E4-4C49-847D-C9999999999A} = {C8F2D282-96F6-48F4-B707-79E7FF58974C} {2F1C1FB9-22F0-4109-85C4-09A59A60465E} = {C8F2D282-96F6-48F4-B707-79E7FF58974C} {5EC3B837-5244-4C45-8046-D0D7A3C1FBBB} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} {AF7F153C-EFE8-4CE5-9882-34B006E8D94F} = {80A494A8-C591-4A8D-98DF-826D2D32ECA9} diff --git a/README.md b/README.md index 010a00b..8986139 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Latest development packages can be found on [MyGet](https://www.myget.org/galler | `AppCoreNet.Data` | Provides persistence framework default implementations. | | `AppCoreNet.Data.Abstractions` | Provides the public API of the persistence framework. | | `AppCoreNet.Data.EntityFrameworkCore` | Adds support for EntityFramework Core. | +| `AppCoreNet.Data.EntityFramework` | Adds support for EntityFramework 6. | | `AppCoreNet.Data.MongoDB` | Adds support for Mongo DB. | | `AppCoreNet.Data.AutoMapper` | Adds support for mapping entities using AutoMapper. | diff --git a/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFramework.csproj b/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFramework.csproj new file mode 100644 index 0000000..74702f4 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFramework.csproj @@ -0,0 +1,20 @@ + + + + net462;netstandard2.1;net8.0 + Adds EntityFramework (EF6) support to AppCore .NET persistence. + $(PackageTags);EntityFramework;EF6 + AppCoreNet.Data.EntityFramework + + + + + + + + + + + + + diff --git a/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFrameworkCore.csproj b/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFrameworkCore.csproj new file mode 100644 index 0000000..a442802 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/AppCoreNet.Data.EntityFrameworkCore.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + Adds EntityFramework Core support to AppCore .NET persistence. + + + + + + + + + + + diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextDataProvider.cs b/src/AppCoreNet.Data.EntityFramework/DbContextDataProvider.cs new file mode 100644 index 0000000..e67d862 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextDataProvider.cs @@ -0,0 +1,102 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using System.Data.Entity.Infrastructure; +using System.Threading; +using System.Threading.Tasks; +using AppCoreNet.Diagnostics; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Represents a Entity Framework data provider. +/// +/// The type of the . +public sealed class DbContextDataProvider : IDataProvider + where TDbContext : DbContext +{ + private readonly string _name; + private readonly DbContextDataProviderServices _services; + + /// + public string Name => _name; + + /// + /// Gets the of the data provider. + /// + /// The . + public TDbContext DbContext => _services.DbContext; + + /// + /// Gets the of the data provider. + /// + public IEntityMapper EntityMapper => _services.EntityMapper; + + /// + /// Gets the of the data provider. + /// + public ITokenGenerator TokenGenerator => _services.TokenGenerator; + + /// + /// Gets the of the data provider. + /// + public DbContextQueryHandlerFactory QueryHandlerFactory => _services.QueryHandlerFactory; + + /// + /// Gets the . + /// + public DbContextTransactionManager TransactionManager => _services.TransactionManager; + + ITransactionManager IDataProvider.TransactionManager => TransactionManager; + + internal DataProviderLogger> Logger => _services.Logger; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the data provider. + /// The . + public DbContextDataProvider( + string name, + DbContextDataProviderServices services) + { + Ensure.Arg.NotNull(name); + Ensure.Arg.NotNull(services); + + _name = name; + _services = services; + } + + /// + /// Saves changes made to the . + /// + /// Optional . + /// A representing the asynchronous operation. + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + // EF6 SaveChangesAsync returns Task + await DbContext.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (DbUpdateConcurrencyException error) + { + throw new EntityConcurrencyException(error); + } + catch (DbUpdateException error) + { + throw new EntityUpdateException(error); + } + + // detach all entities after saving changes + foreach (DbEntityEntry entry in DbContext.ChangeTracker.Entries()) + { + if (entry.Entity != null) + { + entry.State = EntityState.Detached; + } + } + } +} diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderOptions.cs b/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderOptions.cs new file mode 100644 index 0000000..db22bde --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderOptions.cs @@ -0,0 +1,16 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Collections.Generic; + +namespace AppCoreNet.Data.EntityFramework; + +internal sealed class DbContextDataProviderOptions +{ + public Type? EntityMapperType { get; set; } + + public Type? TokenGeneratorType { get; set; } + + public ISet QueryHandlerTypes { get; } = new HashSet(); +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderServices.cs b/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderServices.cs new file mode 100644 index 0000000..fb24faf --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextDataProviderServices.cs @@ -0,0 +1,94 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Data.Entity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides services for based data provider. +/// +/// The type of the . +public sealed class DbContextDataProviderServices + where TDbContext : DbContext +{ + /// + /// Gets the . + /// + public TDbContext DbContext { get; } + + /// + /// Gets the . + /// + public IEntityMapper EntityMapper { get; } + + /// + /// Gets the . + /// + public ITokenGenerator TokenGenerator { get; } + + /// + /// Gets the . + /// + public DbContextQueryHandlerFactory QueryHandlerFactory { get; } + + /// + /// Gets the . + /// + public DbContextTransactionManager TransactionManager { get; } + + /// + /// Gets the . + /// + public DataProviderLogger> Logger { get; } + + internal DbContextDataProviderServices( + TDbContext dbContext, + IEntityMapper entityMapper, + ITokenGenerator tokenGenerator, + DbContextQueryHandlerFactory queryHandlerFactory, + DbContextTransactionManager transactionManager, + DataProviderLogger> logger) + { + DbContext = dbContext; + EntityMapper = entityMapper; + TokenGenerator = tokenGenerator; + QueryHandlerFactory = queryHandlerFactory; + TransactionManager = transactionManager; + Logger = logger; + } + + private static T GetOrCreateInstance(IServiceProvider serviceProvider, Type? type) + where T : notnull + { + return type != null + ? (T)ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, type) + : serviceProvider.GetRequiredService(); + } + + internal static DbContextDataProviderServices Create(string name, IServiceProvider serviceProvider) + { + var optionsMonitor = serviceProvider.GetRequiredService>(); + DbContextDataProviderOptions options = optionsMonitor.Get(name); + + var entityMapper = GetOrCreateInstance(serviceProvider, options.EntityMapperType); + var tokenGenerator = GetOrCreateInstance(serviceProvider, options.TokenGeneratorType); + var logger = serviceProvider.GetRequiredService>>(); + + var dbContext = serviceProvider.GetRequiredService(); + var queryHandlerFactory = new DbContextQueryHandlerFactory(serviceProvider, options.QueryHandlerTypes); + var transactionManager = new DbContextTransactionManager(dbContext, logger); + + return new DbContextDataProviderServices( + dbContext, + entityMapper, + tokenGenerator, + queryHandlerFactory, + transactionManager, + logger); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextPagedQueryHandler.cs b/src/AppCoreNet.Data.EntityFramework/DbContextPagedQueryHandler.cs new file mode 100644 index 0000000..64bc85c --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextPagedQueryHandler.cs @@ -0,0 +1,86 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a base class for based query handlers which return a page of the result set. +/// +/// The type of the . +/// The type of the . +/// The type of the result. +/// The type of the . +/// The type of the DB entity. +public abstract class DbContextPagedQueryHandler + : DbContextQueryHandler, TDbContext, TDbEntity> + where TQuery : IPagedQuery + where TEntity : class, IEntity + where TDbContext : DbContext + where TDbEntity : class +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + protected DbContextPagedQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + + /// + /// Must be implemented to apply the query to the . + /// + /// The . + /// The to apply. + /// The with the query applied. + protected abstract IQueryable ApplyQuery(IQueryable queryable, TQuery query); + + /// + /// Must be implemented to project the result from to . + /// + /// The which must be projected. + /// The which is executed. + /// The projected . + protected abstract IQueryable ApplyProjection(IQueryable queryable, TQuery query); + + /// + /// Can be overriden to customize the paging. + /// + /// The which must be paged. + /// The which is executed. + /// The projected . + protected virtual IQueryable ApplyPaging(IQueryable queryable, TQuery query) + { + if (query.Offset > 0) + queryable = queryable.Skip((int)query.Offset); + + return queryable.Take(query.Limit); + } + + /// + protected override async Task> QueryResultAsync( + IQueryable queryable, + TQuery query, + CancellationToken cancellationToken) + { + queryable = ApplyQuery(queryable, query); + + long? totalCount = query.TotalCount + ? await queryable.LongCountAsync(cancellationToken) + .ConfigureAwait(false) + : null; + + queryable = ApplyPaging(queryable, query); + IQueryable projectedQueryable = ApplyProjection(queryable, query); + + TResult[] result = await projectedQueryable.ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + + return new PagedResult(result, totalCount); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandler.cs b/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandler.cs new file mode 100644 index 0000000..3c2ae43 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandler.cs @@ -0,0 +1,106 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AppCoreNet.Diagnostics; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a base class for based query handlers. +/// +/// The type of the . +/// The type of the . +/// The type of the result. +/// The type of the . +/// The type of the DB entity. +public abstract class DbContextQueryHandler + : IDbContextQueryHandler + where TQuery : IQuery + where TEntity : class, IEntity + where TDbContext : DbContext + where TDbEntity : class +{ + /// + /// Gets the used by the query. + /// + protected DbContextDataProvider Provider { get; } + + /// + /// Initializes a new instance of the + /// class. + /// + /// The . + protected DbContextQueryHandler(DbContextDataProvider provider) + { + Ensure.Arg.NotNull(provider); + Provider = provider; + } + + /// + /// Gets the of the database entity. + /// + /// The query. + /// Token which can be used to cancel the process. + /// The queryable. + protected virtual ValueTask> GetQueryableAsync( + TQuery query, + CancellationToken cancellationToken) + { + IQueryable queryable = + Provider.DbContext + .Set() + .AsNoTracking(); + + return new ValueTask>(queryable); + } + + /// + /// Must be implemented to query the result. + /// + /// The which must be queried. + /// The for which results must be queried. + /// Token which can be used to cancel the process. + /// The result of the query. + protected abstract Task QueryResultAsync( + IQueryable queryable, + TQuery query, + CancellationToken cancellationToken); + + /// + /// Executes the given query. + /// + /// The query to execute. + /// Token which can be used to cancel the process. + /// The result of the query. + public virtual async Task ExecuteAsync(TQuery query, CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(query); + + IQueryable queryable = await GetQueryableAsync(query, cancellationToken) + .ConfigureAwait(false); + + TResult result = await QueryResultAsync(queryable, query, cancellationToken) + .ConfigureAwait(false); + + return result; + } + + /// + bool IDbContextQueryHandler.CanExecute(IQuery query) + { + return query is TQuery; + } + + /// + Task IDbContextQueryHandler.ExecuteAsync( + IQuery query, + CancellationToken cancellationToken) + { + Ensure.Arg.NotNull(query); + return ExecuteAsync((TQuery)query, cancellationToken); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandlerFactory.cs b/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandlerFactory.cs new file mode 100644 index 0000000..5642bee --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextQueryHandlerFactory.cs @@ -0,0 +1,74 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Collections.Generic; +using System.Data.Entity; +using System.Linq; +using AppCoreNet.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides query handler factory for . +/// +/// The type of the . +public sealed class DbContextQueryHandlerFactory + where TDbContext : DbContext +{ + private readonly IServiceProvider _serviceProvider; + private readonly IEnumerable _queryHandlerTypes; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The types of the query handlers. + public DbContextQueryHandlerFactory(IServiceProvider serviceProvider, IEnumerable queryHandlerTypes) + { + Ensure.Arg.NotNull(serviceProvider); + Ensure.Arg.NotNull(queryHandlerTypes); + + _serviceProvider = serviceProvider; + _queryHandlerTypes = queryHandlerTypes; + } + + /// + /// Creates a query handler for the specified query. + /// + /// The . + /// The query. + /// The type of the . + /// The type of the result. + /// The . + /// There is no handler registered for the specified query. + public IDbContextQueryHandler CreateHandler( + DbContextDataProvider provider, + IQuery query) + where TEntity : class, IEntity + { + Ensure.Arg.NotNull(provider); + Ensure.Arg.NotNull(query); + + Type queryHandlerType = typeof(IDbContextQueryHandler); + + IEnumerable eligibleHandlers = _queryHandlerTypes.Where( + t => queryHandlerType.IsAssignableFrom(t)); + + foreach (Type handlerType in eligibleHandlers) + { + var handler = + (IDbContextQueryHandler)ActivatorUtilities.CreateInstance( + _serviceProvider, + handlerType, + provider); + + if (handler.CanExecute(query)) + return handler; + } + + throw new InvalidOperationException( + $"There is no handler for query type '{query.GetType().GetDisplayName()}' registered."); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextRepository.cs b/src/AppCoreNet.Data.EntityFramework/DbContextRepository.cs new file mode 100644 index 0000000..0217eef --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextRepository.cs @@ -0,0 +1,570 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Collections.Generic; +using System.Data.Entity; +using System.Data.Entity.Infrastructure; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using AppCoreNet.Diagnostics; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a based implementation of the interface. +/// +/// The type of the entity id. +/// The type of the entity. +/// The type of the . +/// The type of the database entity. +public class DbContextRepository : IDbContextRepository, IRepository + where TEntity : class, IEntity + where TDbContext : DbContext + where TDbEntity : class +{ + /// + /// Provides a base class for scalar query handlers of this repository. + /// + /// The type of the query. + /// The type of the result. + public abstract class ScalarQueryHandler : DbContextScalarQueryHandler + where TQuery : IQuery + { + /// + /// Initializes a new instance of the class. + /// + /// The . + protected ScalarQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + } + + /// + /// Provides a base class for vector query handlers of this repository. + /// + /// The type of the query. + /// The type of the result. + public abstract class VectorQueryHandler : DbContextVectorQueryHandler + where TQuery : IQuery> + { + /// + /// Initializes a new instance of the class. + /// + /// The . + protected VectorQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + } + + /// + /// Provides a base class for paged query handlers of this repository. + /// + /// The type of the query. + /// The type of the result. + public abstract class PagedQueryHandler : DbContextPagedQueryHandler + where TQuery : IPagedQuery + { + /// + /// Initializes a new instance of the class. + /// + /// The . + protected PagedQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + } + + private static readonly EntityModelProperties _entityModelProperties = new(); + private readonly DbModelProperties _modelProperties; + + /// + public DbContextDataProvider Provider { get; } + + /// + /// Gets the . + /// + protected DbSet Set => Provider.DbContext.Set(); + + IDataProvider IRepository.Provider => Provider; + + /// + /// Initializes a new instance of the class. + /// + /// The data provider. + public DbContextRepository(DbContextDataProvider provider) + { + Ensure.Arg.NotNull(provider); + Provider = provider; + _modelProperties = DbModelProperties.Get(provider.DbContext, typeof(TDbEntity), typeof(TEntity)); + } + + /// + /// Can be overridden to get the primary key from the specified entity id. + /// + /// The unique entity id. + /// The primary key values. + protected virtual object?[] GetPrimaryKey(TId id) + { + return _entityModelProperties.GetIdValues(id); + } + + /// + /// Can be overridden to apply the query expression used when searching for entities by + /// it's primary key. + /// + /// The queryable. + /// The unique entity id. + /// The queryable filtered by primary key. + protected virtual IQueryable ApplyPrimaryKeyExpression(IQueryable queryable, TId id) + { + object?[] primaryKeyValues = GetPrimaryKey(id); + ParameterExpression parameter = Expression.Parameter(typeof(TDbEntity), "e"); + + Expression? predicate = null; + for (int i = 0; i < _modelProperties.PrimaryKeyPropertyNames.Count; i++) + { + string pkPropertyName = _modelProperties.PrimaryKeyPropertyNames[i]; + object? pkValue = primaryKeyValues[i]; + + Expression property = Expression.Property(parameter, pkPropertyName); + Expression value = Expression.Constant(pkValue); + Expression equals = Expression.Equal(property, Expression.Convert(value, property.Type)); + + predicate = predicate == null ? equals : Expression.AndAlso(predicate, equals); + } + + if (predicate == null) + return queryable; + + return queryable.Where(Expression.Lambda>(predicate, parameter)); + } + + /// + /// Can be overridden to apply includes to the query. + /// + /// The . + /// The queryable. + protected virtual IQueryable ApplyIncludes(IQueryable queryable) + { + return queryable; + } + + private void UpdateChangeToken(DbEntityEntry entry, TEntity entity) + { + if (_modelProperties.HasConcurrencyToken) + { + DbPropertyEntry concurrencyTokenProperty = + entry.Property(_modelProperties.ConcurrencyTokenPropertyName!); + + if (entity is IHasChangeTokenEx expectedChangeToken) + { + if (entry.State != EntityState.Added) + { + concurrencyTokenProperty.OriginalValue = expectedChangeToken.ExpectedChangeToken; + } + + if (entry.State != EntityState.Deleted) + { + string? changeToken = expectedChangeToken.ChangeToken; + if (string.IsNullOrWhiteSpace(changeToken) + || string.Equals(changeToken, expectedChangeToken.ExpectedChangeToken)) + { + changeToken = Provider.TokenGenerator.Generate(); + } + + concurrencyTokenProperty.CurrentValue = changeToken; + } + } + else if (entity is IHasChangeToken changeToken) + { + if (entry.State != EntityState.Added) + { + concurrencyTokenProperty.OriginalValue = changeToken.ChangeToken; + } + + if (entry.State != EntityState.Deleted) + { + concurrencyTokenProperty.CurrentValue = Provider.TokenGenerator.Generate(); + } + } + } + } + + private IQueryable GetQueryable(TId id) + { + return ApplyPrimaryKeyExpression(ApplyIncludes(Set), id); + } + + private async Task DoAsync(Action before, Func> action, Action after, Action failed) + { + before(); + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + try + { + T result = await action() + .ConfigureAwait(false); + + after(result, stopwatch.ElapsedMilliseconds); + + return result; + } + catch (Exception error) + { + failed(error); + throw; + } + } + + private async Task DoAsync(Action before, Func action, Action after, Action failed) + { + await DoAsync( + before, + async () => + { + await action() + .ConfigureAwait(false); + + return true; + }, + (_, elapsedTimeMs) => after(elapsedTimeMs), + failed); + } + + /// + public async Task QueryAsync( + IQuery query, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(query); + + IDbContextQueryHandler queryHandler = + Provider.QueryHandlerFactory.CreateHandler(Provider, query); + + TResult result; + try + { + result = + await DoAsync( + () => + { + Provider.Logger.QueryExecuting(query); + }, + async () => + { + return await queryHandler.ExecuteAsync(query, cancellationToken) + .ConfigureAwait(false); + }, + (_, elapsedTimeMs) => + { + Provider.Logger.QueryExecuted(query, elapsedTimeMs); + }, + error => + { + Provider.Logger.QueryExecuteFailed(error, query); + }) + .ConfigureAwait(false); + } + finally + { + await DisposeQueryHandlerAsync(queryHandler) + .ConfigureAwait(false); + } + + [SuppressMessage( + "IDisposableAnalyzers.Correctness", + "IDISP007:Don\'t dispose injected", + Justification = "Ownership is correct.")] + [SuppressMessage( + "ReSharper", + "SuspiciousTypeConversion.Global", + Justification = "Handler may implement IDisposable.")] + async ValueTask DisposeQueryHandlerAsync(IDbContextQueryHandler handler) + { + switch (handler) + { + case IAsyncDisposable disposable: + await disposable.DisposeAsync() + .ConfigureAwait(false); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } + + return result; + } + + /// + /// Provides the core logic to find an entity by it's unique identifier. + /// + /// The entity identifier. + /// Optional . + /// The entity if found, otherwise null. + protected virtual async Task FindCoreAsync(TId id, CancellationToken cancellationToken) + { + TDbEntity? dbEntity = await GetQueryable(id) + .AsNoTracking() + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + return dbEntity; + } + + /// + public virtual async Task FindAsync(TId id, CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(id); + + TEntity? entity = + await DoAsync( + () => + { + Provider.Logger.EntityLoading(typeof(TEntity), id); + }, + async () => + { + TDbEntity? dbEntity = await FindCoreAsync(id, cancellationToken) + .ConfigureAwait(false); + + return dbEntity != null + ? Provider.EntityMapper.Map(dbEntity) + : default; + }, + (result, elapsedTimeMs) => + { + if (result != default) + { + Provider.Logger.EntityLoaded(result, elapsedTimeMs); + } + else + { + Provider.Logger.EntityNotFound(typeof(TEntity), id); + } + }, + error => + { + Provider.Logger.EntityLoadFailed(error, typeof(TEntity), id); + }) + .ConfigureAwait(false); + + return entity; + } + + /// + public virtual async Task LoadAsync(TId id, CancellationToken cancellationToken = default) + { + TEntity? entity = await FindAsync(id, cancellationToken) + .ConfigureAwait(false); + + if (entity == null) + { + throw new EntityNotFoundException(typeof(TEntity), id!); + } + + return entity; + } + + /// + /// Provides the core logic to create an entity. + /// + /// The entity. + /// The database entity. + /// Optional . + /// The created entity. + protected virtual ValueTask> CreateCoreAsync( + TEntity entity, + TDbEntity dbEntity, + CancellationToken cancellationToken) + { + TDbEntity addedDbEntity = Set.Add(dbEntity); + return new ValueTask>(Provider.DbContext.Entry(addedDbEntity)); + } + + /// + public virtual async Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(entity); + + TEntity result = + await DoAsync( + () => + { + Provider.Logger.EntityCreating(entity); + }, + async () => + { + var dbEntity = Provider.EntityMapper.Map(entity); + + DbEntityEntry dbEntry = await CreateCoreAsync(entity, dbEntity, cancellationToken) + .ConfigureAwait(false); + + dbEntity = dbEntry.Entity; + UpdateChangeToken(dbEntry, entity); + + await Provider.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + + return Provider.EntityMapper.Map(dbEntity); + }, + (e, elapsedTimeMs) => + { + Provider.Logger.EntityCreated(e, elapsedTimeMs); + }, + error => + { + Provider.Logger.EntityCreateFailed(error, entity); + }) + .ConfigureAwait(false); + + return result; + } + + /// + /// Provides the core logic to update an entity. + /// + /// The entity. + /// The database entity. + /// Optional . + /// The updated entity. + protected virtual ValueTask> UpdateCoreAsync( + TEntity entity, + TDbEntity dbEntity, + CancellationToken cancellationToken) + { + // This method is called from UpdateAsync where dbEntity is loaded from the context + // and then mapped. So, it should be tracked. + // If it were detached for some reason, attaching and setting state is correct. + DbEntityEntry entry = Provider.DbContext.Entry(dbEntity); + if (entry.State == EntityState.Detached) + { + Set.Attach(dbEntity); + entry = Provider.DbContext.Entry(dbEntity); + } + + entry.State = EntityState.Modified; + return new ValueTask>(entry); + } + + /// + public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(entity); + + if (entity.IsTransient()) + { + throw new InvalidOperationException( + $"The entity cannot be updated because the '{nameof(IEntity.Id)}' property has the default value."); + } + + TEntity result = + await DoAsync( + () => + { + Provider.Logger.EntityUpdating(entity); + }, + async () => + { + TDbEntity? dbEntity = await GetQueryable(entity.Id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (dbEntity == null) + throw new EntityConcurrencyException(); + + Provider.EntityMapper.Map(entity, dbEntity); + Provider.DbContext.Entry(dbEntity).State = EntityState.Modified; + + DbEntityEntry dbEntry = await UpdateCoreAsync( + entity, + dbEntity, + cancellationToken) + .ConfigureAwait(false); + + dbEntity = dbEntry.Entity; + UpdateChangeToken(dbEntry, entity); + + await Provider.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + + return Provider.EntityMapper.Map(dbEntity); + }, + (e, elapsedTimeMs) => + { + Provider.Logger.EntityUpdated(e, elapsedTimeMs); + }, + error => + { + Provider.Logger.EntityUpdateFailed(error, entity); + }) + .ConfigureAwait(false); + + return result; + } + + /// + /// Provides the core logic to delete an entity. + /// + /// The entity. + /// The database entity. + /// Optional . + /// The deleted entity. + protected virtual ValueTask> DeleteCoreAsync( + TEntity entity, + TDbEntity dbEntity, + CancellationToken cancellationToken) + { + DbEntityEntry entry = Provider.DbContext.Entry(dbEntity); + if (entry.State == EntityState.Detached) + { + Set.Attach(dbEntity); + entry = Provider.DbContext.Entry(dbEntity); + } + + TDbEntity removedEntity = Set.Remove(dbEntity); + return new ValueTask>(Provider.DbContext.Entry(removedEntity)); + } + + /// + public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(entity); + + await DoAsync( + () => + { + Provider.Logger.EntityDeleting(entity); + }, + async () => + { + var dbEntity = Provider.EntityMapper.Map(entity); + + DbEntityEntry dbEntry = await DeleteCoreAsync(entity, dbEntity, cancellationToken) + .ConfigureAwait(false); + + UpdateChangeToken(dbEntry, entity); + + await Provider.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + }, + elapsedTimeMs => + { + Provider.Logger.EntityDeleted(entity, elapsedTimeMs); + }, + error => + { + Provider.Logger.EntityDeleteFailed(error, entity); + }) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextScalarQueryHandler.cs b/src/AppCoreNet.Data.EntityFramework/DbContextScalarQueryHandler.cs new file mode 100644 index 0000000..1bceba9 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextScalarQueryHandler.cs @@ -0,0 +1,62 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a base class for based query handlers which return a scalar. +/// +/// The type of the . +/// The type of the . +/// The type of the result. +/// The type of the . +/// The type of the DB entity. +public abstract class DbContextScalarQueryHandler + : DbContextQueryHandler + where TQuery : IQuery + where TEntity : class, IEntity + where TDbContext : DbContext + where TDbEntity : class +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + protected DbContextScalarQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + + /// + /// Must be implemented to apply the query to the . + /// + /// The . + /// The to apply. + /// The with applied query. + protected abstract IQueryable ApplyQuery(IQueryable queryable, TQuery query); + + /// + /// Must be implemented to project the result from to . + /// + /// The which must be projected. + /// The which is executed. + /// The projected . + protected abstract IQueryable ApplyProjection(IQueryable queryable, TQuery query); + + /// + protected override async Task QueryResultAsync( + IQueryable queryable, + TQuery query, + CancellationToken cancellationToken) + { + queryable = ApplyQuery(queryable, query); + IQueryable result = ApplyProjection(queryable, query); + return await result.FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextTransaction.cs b/src/AppCoreNet.Data.EntityFramework/DbContextTransaction.cs new file mode 100644 index 0000000..e0104f3 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextTransaction.cs @@ -0,0 +1,133 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Data.Entity; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using AppCoreNet.Diagnostics; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a transaction scope. +/// +/// The type of the . +[SuppressMessage( + "IDisposableAnalyzers.Correctness", + "IDISP007:Don\'t dispose injected", + Justification = "Ownership is transferred from DbContextTransactionManager.")] +public sealed class DbContextTransaction : ITransaction + where TDbContext : DbContext +{ + private readonly TDbContext _dbContext; + private readonly DbContextTransaction _transaction; + private readonly DataProviderLogger> _logger; + private bool _disposed; + + /// + public string Id => _transaction.GetHashCode().ToString("X"); + + internal DbContextTransaction Transaction => _transaction; + + internal event EventHandler? TransactionFinished; + + internal DbContextTransaction( + TDbContext dbContext, + DbContextTransaction transaction, + DataProviderLogger> logger) + { + Ensure.Arg.NotNull(dbContext); + Ensure.Arg.NotNull(transaction); + Ensure.Arg.NotNull(logger); + + _dbContext = dbContext; + _transaction = transaction; + _logger = logger; + _logger.TransactionCreated(this); + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(DbContextTransaction)); + } + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _transaction.Dispose(); + _disposed = true; + _logger.TransactionDisposed(this); + + TransactionFinished?.Invoke(this, EventArgs.Empty); + } + + /// + public async ValueTask DisposeAsync() + { + Dispose(); + await Task.CompletedTask; + } + + /// + public void Commit() + { + EnsureNotDisposed(); + _logger.TransactionCommitting(this); + var stopwatch = Stopwatch.StartNew(); + try + { + _transaction.Commit(); + _logger.TransactionCommitted(this, stopwatch.ElapsedMilliseconds); + } + catch (Exception error) + { + _logger.TransactionCommitFailed(this, error); + throw; + } + + TransactionFinished?.Invoke(this, EventArgs.Empty); + } + + /// + public Task CommitAsync(CancellationToken cancellationToken = default) + { + Commit(); + return Task.CompletedTask; + } + + /// + public void Rollback() + { + EnsureNotDisposed(); + _logger.TransactionRollingback(this); + var stopwatch = Stopwatch.StartNew(); + try + { + _transaction.Rollback(); + _logger.TransactionRolledback(this, stopwatch.ElapsedMilliseconds); + } + catch (Exception error) + { + _logger.TransactionRollbackFailed(this, error); + throw; + } + + TransactionFinished?.Invoke(this, EventArgs.Empty); + } + + /// + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + Rollback(); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextTransactionManager.cs b/src/AppCoreNet.Data.EntityFramework/DbContextTransactionManager.cs new file mode 100644 index 0000000..e7169b1 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextTransactionManager.cs @@ -0,0 +1,114 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Data; +using System.Data.Entity; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using AppCoreNet.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a transaction manager using a . +/// +/// The type of the . +[SuppressMessage( + "IDisposableAnalyzers.Correctness", + "IDISP003:Dispose previous before re-assigning", + Justification = "Pre-condition is that no transaction is active.")] +[SuppressMessage( + "IDisposableAnalyzers.Correctness", + "IDISP006:Implement IDisposable", + Justification = "Transaction must be disposed by consumer.")] +public sealed class DbContextTransactionManager : ITransactionManager + where TDbContext : DbContext +{ + private readonly TDbContext _dbContext; + private readonly DataProviderLogger> _logger; + private DbContextTransaction? _currentTransaction; + + /// + /// Gets the currently active . + /// + public DbContextTransaction? CurrentTransaction => _currentTransaction; + + ITransaction? ITransactionManager.CurrentTransaction => CurrentTransaction; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public DbContextTransactionManager( + TDbContext dbContext, + DataProviderLogger> logger) + { + Ensure.Arg.NotNull(dbContext); + Ensure.Arg.NotNull(logger); + + _dbContext = dbContext; + _logger = logger; + } + + private void OnTransactionFinished(object? sender, EventArgs args) + { + var transaction = (DbContextTransaction)sender!; + transaction.TransactionFinished -= OnTransactionFinished; + _currentTransaction = null; + } + + /// + /// Begins a new transaction in the context of the data provider. + /// + /// Specifies the isolation level of the transaction. + /// Can be used to cancel the asynchronous operation. + /// The created transaction. + public Task BeginTransactionAsync( + IsolationLevel isolationLevel, + CancellationToken cancellationToken = default) + { + return Task.FromResult(BeginTransaction(isolationLevel)); + } + + /// + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + return await BeginTransactionAsync(IsolationLevel.Unspecified, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Begins a new transaction in the context of the data provider. + /// + /// Specifies the isolation level of the transaction. + /// The created transaction. + public ITransaction BeginTransaction(IsolationLevel isolationLevel) + { + if (CurrentTransaction != null) + throw new InvalidOperationException("A transaction is already in progress."); + + DbContextTransaction transaction = _dbContext.Database.BeginTransaction(isolationLevel); + + try + { + var t = new DbContextTransaction(_dbContext, transaction, _logger); + t.TransactionFinished += OnTransactionFinished; + return _currentTransaction = t; + } + catch + { + transaction.Dispose(); + throw; + } + } + + /// + public ITransaction BeginTransaction() + { + return BeginTransaction(IsolationLevel.Unspecified); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DbContextVectorQueryHandler.cs b/src/AppCoreNet.Data.EntityFramework/DbContextVectorQueryHandler.cs new file mode 100644 index 0000000..a55624c --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DbContextVectorQueryHandler.cs @@ -0,0 +1,63 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Collections.Generic; +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Provides a base class for based query handlers which return a vector. +/// +/// The type of the . +/// The type of the . +/// The type of the result. +/// The type of the . +/// The type of the DB entity. +public abstract class DbContextVectorQueryHandler + : DbContextQueryHandler, TDbContext, TDbEntity> + where TQuery : IQuery> + where TEntity : class, IEntity + where TDbContext : DbContext + where TDbEntity : class +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + protected DbContextVectorQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + + /// + /// Must be implemented to apply the query to the . + /// + /// The . + /// The to apply. + /// The with applied query. + protected abstract IQueryable ApplyQuery(IQueryable queryable, TQuery query); + + /// + /// Must be implemented to project the result from to . + /// + /// The which must be projected. + /// The which is executed. + /// The projected . + protected abstract IQueryable ApplyProjection(IQueryable queryable, TQuery query); + + /// + protected override async Task> QueryResultAsync( + IQueryable queryable, + TQuery query, + CancellationToken cancellationToken) + { + queryable = ApplyQuery(queryable, query); + IQueryable result = ApplyProjection(queryable, query); + return await result.ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilder.cs b/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilder.cs new file mode 100644 index 0000000..7341619 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilder.cs @@ -0,0 +1,142 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.ComponentModel; +using System.Data.Entity; +using AppCoreNet.Data; +using AppCoreNet.Data.EntityFramework; +using AppCoreNet.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Extensions.DependencyInjection; + +/// +/// Represents the builder for Entity Framework data providers. +/// +/// The type of the . +public sealed class EntityFrameworkDataProviderBuilder + where TDbContext : DbContext +{ + /// + /// Gets the name of the provider. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public string Name { get; } + + /// + /// Gets the . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Gets the of the provider. + /// + public ServiceLifetime ProviderLifetime { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the data provider. + /// The . + /// The lifetime of the . + public EntityFrameworkDataProviderBuilder( + string name, + IServiceCollection services, + ServiceLifetime providerLifetime) + { + Ensure.Arg.NotNull(name); + Ensure.Arg.NotNull(services); + + Name = name; + Services = services; + ProviderLifetime = providerLifetime; + } + + /// + /// Adds the specified repository implementation. + /// + /// The type of the repository. + /// The . + public EntityFrameworkDataProviderBuilder AddRepository() + where TImplementation : class, IDbContextRepository + { + return AddRepository(); + } + + /// + /// Adds the specified repository implementation. + /// + /// The type of the repository service. + /// The type of the repository implementation. + /// The . + public EntityFrameworkDataProviderBuilder AddRepository() + where TService : class + where TImplementation : class, IDbContextRepository, TService + { + Services.TryAddEnumerable( + ServiceDescriptor.Describe( + typeof(TService), + new Func(sp => + { + var provider = + (DbContextDataProvider)sp.GetRequiredService() + .Resolve(Name); + + return ActivatorUtilities.CreateInstance(sp, provider); + }), + ProviderLifetime)); + + return this; + } + + /// + /// Adds the specified query handler implementation. + /// + /// The type of the . + /// The . + public EntityFrameworkDataProviderBuilder AddQueryHandler() + where TQueryHandler : class, IDbContextQueryHandler + { + Type queryHandlerType = typeof(TQueryHandler); + + Services.Configure( + Name, + o => o.QueryHandlerTypes.Add(queryHandlerType)); + + return this; + } + + /// + /// Registers a which generates concurrency tokens. + /// + /// The type of the . + /// The . + public EntityFrameworkDataProviderBuilder AddTokenGenerator() + where T : class, ITokenGenerator + { + Services.Configure( + Name, + o => o.TokenGeneratorType = typeof(T)); + + return this; + } + + /// + /// Registers a which maps entities to database entities. + /// + /// The type of the . + /// The . + public EntityFrameworkDataProviderBuilder AddEntityMapper() + where T : class, IEntityMapper + { + Services.Configure( + Name, + o => o.EntityMapperType = typeof(T)); + + return this; + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilderExtensions.cs b/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilderExtensions.cs new file mode 100644 index 0000000..f6226de --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/DependencyInjection/EntityFrameworkDataProviderBuilderExtensions.cs @@ -0,0 +1,74 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using AppCoreNet.Data.EntityFramework; +using AppCoreNet.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Extensions.DependencyInjection; + +/// +/// Provides extension methods to register a . +/// +public static class EntityFrameworkDataProviderBuilderExtensions +{ + /// + /// Registers a data provider in the . + /// + /// + /// Note that you have to register the on your own. + /// + /// The type of the . + /// The . + /// The name of the data provider. + /// The lifetime of the data provider. + /// The . + public static EntityFrameworkDataProviderBuilder AddEntityFramework( + this IDataProviderBuilder builder, + string name, + ServiceLifetime providerLifetime = ServiceLifetime.Scoped) + where TDbContext : DbContext + { + Ensure.Arg.NotNull(builder); + Ensure.Arg.NotNull(name); + + builder.Services.AddOptions(); + builder.Services.AddLogging(); + + builder.Services.TryAdd(ServiceDescriptor.Describe(typeof(TDbContext), typeof(TDbContext), providerLifetime)); + + builder.AddProvider>( + name, + providerLifetime, + static (sp, name) => + { + DbContextDataProviderServices services = + DbContextDataProviderServices.Create(name, sp); + + return new DbContextDataProvider(name, services); + }); + + return new EntityFrameworkDataProviderBuilder(name, builder.Services, providerLifetime); + } + + /// + /// Registers a default data provider in the . + /// + /// + /// Note that you have to register the on your own. + /// + /// The type of the . + /// The . + /// The lifetime of the data provider. + /// The . + public static EntityFrameworkDataProviderBuilder AddEntityFramework( + this IDataProviderBuilder builder, + ServiceLifetime providerLifetime = ServiceLifetime.Scoped) + where TDbContext : DbContext + { + return builder.AddEntityFramework(string.Empty, providerLifetime); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/IDbContextQueryHandler.cs b/src/AppCoreNet.Data.EntityFramework/IDbContextQueryHandler.cs new file mode 100644 index 0000000..a0f7bb0 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/IDbContextQueryHandler.cs @@ -0,0 +1,43 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Represents a based query handler. +/// +/// The type of the . +public interface IDbContextQueryHandler + where TDbContext : DbContext +{ +} + +/// +/// Represents a handler for . +/// +/// The type of the entity. +/// The type of the result. +/// The type of the . +public interface IDbContextQueryHandler : IDbContextQueryHandler + where TEntity : class, IEntity + where TDbContext : DbContext +{ + /// + /// Gets a value indicating whether the query can be executed. + /// + /// The to test. + /// true if the query can be executed; otherwise, false. + bool CanExecute(IQuery query); + + /// + /// Executes the given query. + /// + /// The to execute. + /// Token which can be used to cancel the process. + /// The result of the query. + Task ExecuteAsync(IQuery query, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/IDbContextRepository.cs b/src/AppCoreNet.Data.EntityFramework/IDbContextRepository.cs new file mode 100644 index 0000000..0d342e4 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/IDbContextRepository.cs @@ -0,0 +1,19 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Data.Entity; + +namespace AppCoreNet.Data.EntityFramework; + +/// +/// Represents a based repository. +/// +/// The type of the . +public interface IDbContextRepository + where TDbContext : DbContext +{ + /// + /// Gets the which owns the repository. + /// + DbContextDataProvider Provider { get; } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/Internal/DbModelProperties.cs b/src/AppCoreNet.Data.EntityFramework/Internal/DbModelProperties.cs new file mode 100644 index 0000000..74ca71f --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/Internal/DbModelProperties.cs @@ -0,0 +1,105 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data.Entity; +using System.Data.Entity.Core.Metadata.Edm; +using System.Data.Entity.Core.Objects; +using System.Data.Entity.Infrastructure; +using System.Linq; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Data.EntityFramework; + +internal sealed class DbModelProperties +{ + private static readonly + ConcurrentDictionary<(Type DbContextType, Type DbEntityType, Type EntityType), DbModelProperties> _cache = + new(); + + public IReadOnlyList PrimaryKeyPropertyNames { get; private set; } = new List(); + + public bool HasConcurrencyToken { get; private set; } + + public string? ConcurrencyTokenPropertyName { get; private set; } + + private readonly Type _dbContextType; + private readonly Type _dbEntityType; // This is the type used in DbSet, potentially a proxy + private readonly Type _entityType; // This is the domain entity type IEntity + + private DbModelProperties(DbContext dbContext, Type dbEntityType, Type entityType) + { + _dbContextType = dbContext.GetType(); + _dbEntityType = dbEntityType; + _entityType = entityType; + Initialize(dbContext); + } + + private void Initialize(DbContext dbContext) + { + ObjectContext objectContext = ((IObjectContextAdapter)dbContext).ObjectContext; + MetadataWorkspace workspace = objectContext.MetadataWorkspace; + + // Determine the non-proxy type for dbEntityType to ensure reliable metadata lookup + Type typeForMetadataLookup = _dbEntityType; + if (_dbEntityType.Namespace == "System.Data.Entity.DynamicProxies" && _dbEntityType.BaseType != null) + { + typeForMetadataLookup = _dbEntityType.BaseType; + } + + // Get OSpace EntityType first, as dbEntityType is a CLR type + EntityType? ospaceEntityType = workspace + .GetItems(DataSpace.OSpace) + .FirstOrDefault(et => et.FullName == typeForMetadataLookup.FullName); + + if (ospaceEntityType == null) + { + throw new InvalidOperationException($"Could not find OSpace EntityType for '{typeForMetadataLookup.FullName}' in MetadataWorkspace of DbContext '{_dbContextType.FullName}'."); + } + + // Get corresponding CSpace EntityType + var cspaceEntityType = workspace.GetItem(ospaceEntityType.FullName, DataSpace.CSpace); + if (cspaceEntityType == null) + { + // This case should ideally not happen if OSpace type was found and metadata is consistent. + throw new InvalidOperationException($"Could not find CSpace EntityType corresponding to OSpace '{ospaceEntityType.FullName}' for DbContext '{_dbContextType.FullName}'."); + } + + PrimaryKeyPropertyNames = cspaceEntityType.KeyProperties.Select(p => p.Name).ToList().AsReadOnly(); + + foreach (EdmProperty property in cspaceEntityType.Properties) + { + if (property.ConcurrencyMode == ConcurrencyMode.Fixed) + { + ConcurrencyTokenPropertyName = property.Name; + HasConcurrencyToken = true; + break; + } + } + + // Validation logic + if (typeof(IHasChangeToken).IsAssignableFrom(_entityType) + || typeof(IHasChangeTokenEx).IsAssignableFrom(_entityType)) + { + if (!HasConcurrencyToken) + { + throw new ArgumentException( + $"The entity type '{_entityType.FullName}' implements 'IHasChangeToken' or 'IHasChangeTokenEx' but no matching concurrency token property (ConcurrencyMode.Fixed) was found in the database model for DbEntityType '{_dbEntityType.FullName}' (resolved as '{typeForMetadataLookup.FullName}')."); + } + } + else if (HasConcurrencyToken) + { + throw new ArgumentException( + $"The database model for DbEntityType '{_dbEntityType.FullName}' (resolved as '{typeForMetadataLookup.FullName}') contains concurrency token property '{ConcurrencyTokenPropertyName}' but the entity type '{_entityType.FullName}' does not implement 'IHasChangeToken' or 'IHasChangeTokenEx'."); + } + } + + internal static DbModelProperties Get(DbContext dbContext, Type dbEntityType, Type entityType) + { + return _cache.GetOrAdd( + (dbContext.GetType(), DbEntityType: dbEntityType, EntityType: entityType), + key => new DbModelProperties(dbContext, key.DbEntityType, key.EntityType)); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/Internal/EntityModelProperties.cs b/src/AppCoreNet.Data.EntityFramework/Internal/EntityModelProperties.cs new file mode 100644 index 0000000..3c3296e --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/Internal/EntityModelProperties.cs @@ -0,0 +1,63 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Data.EntityFramework; + +internal sealed class EntityModelProperties + where TEntity : class, IEntity +{ + public Func GetIdValues { get; } + + public IReadOnlyList IdPropertyNames { get; } + + public EntityModelProperties() + { + Type entityType = typeof(TEntity); + + Type entityIfaceType = + entityType.GetInterfaces() + .First(f => f.GetGenericTypeDefinition() == typeof(IEntity<>)); + + Type idType = entityIfaceType.GenericTypeArguments[0]; + if (IsComplexIdType(idType)) + { + PropertyInfo[] idProperties = idType.GetProperties(BindingFlags.Instance | BindingFlags.Public); + + IdPropertyNames = idProperties.Select(p => p.Name) + .ToList() + .AsReadOnly(); + + Func[] idPropertyGetters = + idProperties + .Select(p => new Func(o => p.GetValue(o))) + .ToArray(); + + GetIdValues = id => + { + object?[] result = new object[idPropertyGetters.Length]; + for (int i = 0; i < idPropertyGetters.Length; i++) + { + result[i] = idPropertyGetters[i](id); + } + + return result; + }; + } + else + { + IdPropertyNames = new List().AsReadOnly(); + GetIdValues = id => new object?[] { id }; + } + } + + private static bool IsComplexIdType(Type idType) + { + return Type.GetTypeCode(idType) == TypeCode.Object && idType != typeof(Guid); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/Internal/InternalsVisibleTo.cs b/src/AppCoreNet.Data.EntityFramework/Internal/InternalsVisibleTo.cs new file mode 100644 index 0000000..1a54d94 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/Internal/InternalsVisibleTo.cs @@ -0,0 +1,6 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AppCoreNet.Data.EntityFramework.Tests")] \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/Internal/LogEventIds.cs b/src/AppCoreNet.Data.EntityFramework/Internal/LogEventIds.cs new file mode 100644 index 0000000..9f1f20c --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/Internal/LogEventIds.cs @@ -0,0 +1,41 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using Microsoft.Extensions.Logging; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Data.EntityFramework; + +internal static class LogEventIds +{ + // DbContextDataProvider + public static readonly EventId SavingChanges = new EventId(0, "saving_changes"); + + public static readonly EventId SavedChanges = new EventId(1, "saved_changes"); + + public static readonly EventId SaveChangesDeferred = new EventId(2, "saving_changes_delayed"); + + public static readonly EventId TransactionInit = new EventId(3, "transaction_init"); + + public static readonly EventId TransactionCommit = new EventId(4, "transaction_commit"); + + public static readonly EventId TransactionRollback = new EventId(5, "transaction_rollback"); + + public static readonly EventId TransactionDisposed = new EventId(6, "transaction_disposed"); + + // DbContextRepository + public static readonly EventId EntitySaving = new EventId(0, "entity_saving"); + + public static readonly EventId EntitySaved = new EventId(1, "entity_saved"); + + public static readonly EventId EntityDeleting = new EventId(2, "entity_deleting"); + + public static readonly EventId EntityDeleted = new EventId(3, "entity_deleted"); + + // DbContextQueryHandler + public static readonly EventId QueryExecuting = new EventId(0, "query_executing"); + + public static readonly EventId QueryExecuted = new EventId(1, "query_executed"); + + public static readonly EventId QueryFailed = new EventId(1, "query_failed"); +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/Internal/LoggerExtensions.cs b/src/AppCoreNet.Data.EntityFramework/Internal/LoggerExtensions.cs new file mode 100644 index 0000000..d655c74 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/Internal/LoggerExtensions.cs @@ -0,0 +1,222 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using Microsoft.Extensions.Logging; + +// ReSharper disable once CheckNamespace +namespace AppCoreNet.Data.EntityFramework; + +internal static class LoggerExtensions +{ + // DbContextDataProvider + private static readonly Action _savingChanges = + LoggerMessage.Define( + LogLevel.Trace, + LogEventIds.SavingChanges, + "Saving changes for context {dbContextType} ..."); + + private static readonly Action _savedChanges = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.SavedChanges, + "Saved {entityCount} changes for context {dbContextType}."); + + private static readonly Action _saveChangesDeferred = + LoggerMessage.Define( + LogLevel.Trace, + LogEventIds.SaveChangesDeferred, + "Deferred saving changes for context {dbContextType}."); + + internal static void SavingChanges(this ILogger logger, Type dbContextType) + { + _savingChanges(logger, dbContextType, null); + } + + internal static void SavedChanges(this ILogger logger, Type dbContextType, int entityCount) + { + _savedChanges(logger, entityCount, dbContextType, null); + } + + internal static void SaveChangesDeferred(this ILogger logger, Type dbContextType) + { + _saveChangesDeferred(logger, dbContextType, null); + } + + // DbContextTransactionManager + private static readonly Action _transactionInit = + LoggerMessage.Define( + LogLevel.Trace, + LogEventIds.TransactionInit, + "Initialized transaction {transactionId} for context {dbContextType}."); + + private static readonly Action _transactionCommit = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.TransactionCommit, + "Committed transaction {transactionId} for context {dbContextType}."); + + private static readonly Action _transactionRollback = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.TransactionRollback, + "Rolled back transaction {transactionId} for context {dbContextType}."); + + private static readonly Action _transactionDisposed = + LoggerMessage.Define( + LogLevel.Trace, + LogEventIds.TransactionDisposed, + "Disposed transaction {transactionId} for context {dbContextType}."); + + internal static void TransactionInit(this ILogger logger, Type dbContextType, string transactionId) + { + _transactionInit(logger, transactionId, dbContextType, null); + } + + internal static void TransactionCommit(this ILogger logger, Type dbContextType, string transactionId) + { + _transactionCommit(logger, transactionId, dbContextType, null); + } + + internal static void TransactionRollback(this ILogger logger, Type dbContextType, string transactionId) + { + _transactionRollback(logger, transactionId, dbContextType, null); + } + + internal static void TransactionDisposed(this ILogger logger, Type dbContextType, string transactionId) + { + _transactionDisposed(logger, transactionId, dbContextType, null); + } + + // DbContextRepository + private static readonly Action _entitySaving = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.EntitySaving, + "Saving entity {entityType} with id {entityId} ..."); + + private static readonly Action _concurrentEntitySaving = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.EntitySaving, + "Saving entity {entityType} with id {entityId} and token {entityConcurrencyToken} ..."); + + private static readonly Action _entitySaved = + LoggerMessage.Define( + LogLevel.Information, + LogEventIds.EntitySaved, + "Entity {entityType} with id {entityId} saved."); + + private static readonly Action _concurrentEntitySaved = + LoggerMessage.Define( + LogLevel.Information, + LogEventIds.EntitySaved, + "Entity {entityType} with id {entityId} and token {entityConcurrencyToken} saved."); + + private static readonly Action _entityDeleting = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.EntityDeleting, + "Deleting entity {entityType} with id {entityId} ..."); + + private static readonly Action _concurrentEntityDeleting = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.EntityDeleting, + "Deleting entity {entityType} with id {entityId} and token {entityConcurrencyToken} ..."); + + private static readonly Action _entityDeleted = + LoggerMessage.Define( + LogLevel.Information, + LogEventIds.EntityDeleted, + "Entity {entityType} with id {entityId} deleted."); + + public static void EntitySaving(this ILogger logger, IEntity entity) + { + if (entity is IHasChangeToken concurrentEntity) + { + _concurrentEntitySaving( + logger, + entity.GetType(), + entity.Id, + concurrentEntity.ChangeToken, + null); + } + else + { + _entitySaving(logger, entity.GetType(), entity.Id, null); + } + } + + public static void EntitySaved(this ILogger logger, IEntity entity) + { + if (entity is IHasChangeToken concurrentEntity) + { + _concurrentEntitySaved( + logger, + entity.GetType(), + entity.Id, + concurrentEntity.ChangeToken, + null); + } + else + { + _entitySaved(logger, entity.GetType(), entity.Id, null); + } + } + + public static void EntityDeleting(this ILogger logger, IEntity entity) + { + if (entity is IHasChangeToken concurrentEntity) + { + _concurrentEntityDeleting( + logger, + entity.GetType(), + entity.Id, + concurrentEntity.ChangeToken, + null); + } + else + { + _entityDeleting(logger, entity.GetType(), entity.Id, null); + } + } + + public static void EntityDeleted(this ILogger logger, IEntity entity) + { + _entityDeleted(logger, entity.GetType(), entity.Id, null); + } + + private static readonly Action _queryExecuting = + LoggerMessage.Define( + LogLevel.Debug, + LogEventIds.QueryExecuting, + "Executing query {queryType} ..."); + + private static readonly Action _queryExecuted = + LoggerMessage.Define( + LogLevel.Information, + LogEventIds.QueryExecuted, + "Executed query {queryType} in {queryExecutionTime}s"); + + private static readonly Action _queryFailed = + LoggerMessage.Define( + LogLevel.Error, + LogEventIds.QueryFailed, + "Failed to execute query {queryType}: {errorMessage}"); + + public static void QueryExecuting(this ILogger logger, Type queryType) + { + _queryExecuting(logger, queryType, null); + } + + public static void QueryExecuted(this ILogger logger, Type queryType, TimeSpan duration) + { + _queryExecuted(logger, queryType, duration.TotalSeconds, null); + } + + public static void QueryFailed(this ILogger logger, Exception exception, Type queryType) + { + _queryFailed(logger, queryType, exception.Message, exception); + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFramework/PagedResult.cs b/src/AppCoreNet.Data.EntityFramework/PagedResult.cs new file mode 100644 index 0000000..93770c7 --- /dev/null +++ b/src/AppCoreNet.Data.EntityFramework/PagedResult.cs @@ -0,0 +1,19 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Collections.Generic; + +namespace AppCoreNet.Data.EntityFramework; + +internal sealed class PagedResult : IPagedResult +{ + public long? TotalCount { get; } + + public IReadOnlyCollection Items { get; } + + public PagedResult(IReadOnlyCollection items, long? totalCount) + { + TotalCount = totalCount; + Items = items; + } +} \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFrameworkCore/DbContextTransaction.cs b/src/AppCoreNet.Data.EntityFrameworkCore/DbContextTransaction.cs index d578d95..0668607 100644 --- a/src/AppCoreNet.Data.EntityFrameworkCore/DbContextTransaction.cs +++ b/src/AppCoreNet.Data.EntityFrameworkCore/DbContextTransaction.cs @@ -104,6 +104,8 @@ public void Commit() _logger.TransactionCommitFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -126,6 +128,8 @@ await _transaction.CommitAsync(cancellationToken) _logger.TransactionCommitFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -146,6 +150,8 @@ public void Rollback() _logger.TransactionRollbackFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -168,5 +174,7 @@ await _transaction.RollbackAsync(cancellationToken) _logger.TransactionRollbackFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } } \ No newline at end of file diff --git a/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilder.cs b/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilder.cs similarity index 77% rename from src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilder.cs rename to src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilder.cs index c08288d..957f4ba 100644 --- a/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilder.cs +++ b/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilder.cs @@ -17,7 +17,7 @@ namespace AppCoreNet.Extensions.DependencyInjection; /// Represents the builder for Entity Framework Core data providers. /// /// The type of the . -public sealed class DbContextDataProviderBuilder +public sealed class EntityFrameworkCoreDataProviderBuilder where TDbContext : DbContext { /// @@ -38,12 +38,15 @@ public sealed class DbContextDataProviderBuilder public ServiceLifetime ProviderLifetime { get; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The name of the data provider. /// The . /// The lifetime of the . - public DbContextDataProviderBuilder(string name, IServiceCollection services, ServiceLifetime providerLifetime) + public EntityFrameworkCoreDataProviderBuilder( + string name, + IServiceCollection services, + ServiceLifetime providerLifetime) { Ensure.Arg.NotNull(name); Ensure.Arg.NotNull(services); @@ -57,8 +60,8 @@ public DbContextDataProviderBuilder(string name, IServiceCollection services, Se /// Adds the specified repository implementation. /// /// The type of the repository. - /// The . - public DbContextDataProviderBuilder AddRepository() + /// The . + public EntityFrameworkCoreDataProviderBuilder AddRepository() where TImplementation : class, IDbContextRepository { return AddRepository(); @@ -69,8 +72,8 @@ public DbContextDataProviderBuilder AddRepository() /// /// The type of the repository service. /// The type of the repository implementation. - /// The . - public DbContextDataProviderBuilder AddRepository() + /// The . + public EntityFrameworkCoreDataProviderBuilder AddRepository() where TService : class where TImplementation : class, IDbContextRepository, TService { @@ -95,8 +98,8 @@ public DbContextDataProviderBuilder AddRepository /// The type of the . - /// The . - public DbContextDataProviderBuilder AddQueryHandler() + /// The . + public EntityFrameworkCoreDataProviderBuilder AddQueryHandler() where TQueryHandler : class, IDbContextQueryHandler { Type queryHandlerType = typeof(TQueryHandler); @@ -112,8 +115,8 @@ public DbContextDataProviderBuilder AddQueryHandler() /// Registers a which generates concurrency tokens. /// /// The type of the . - /// The . - public DbContextDataProviderBuilder AddTokenGenerator() + /// The . + public EntityFrameworkCoreDataProviderBuilder AddTokenGenerator() where T : class, ITokenGenerator { Services.Configure( @@ -127,8 +130,8 @@ public DbContextDataProviderBuilder AddTokenGenerator() /// Registers a which maps entities to database entities. /// /// The type of the . - /// The . - public DbContextDataProviderBuilder AddEntityMapper() + /// The . + public EntityFrameworkCoreDataProviderBuilder AddEntityMapper() where T : class, IEntityMapper { Services.Configure( diff --git a/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilderExtensions.cs b/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilderExtensions.cs similarity index 76% rename from src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilderExtensions.cs rename to src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilderExtensions.cs index b5ab438..fff8032 100644 --- a/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/DbContextDataProviderBuilderExtensions.cs +++ b/src/AppCoreNet.Data.EntityFrameworkCore/DependencyInjection/EntityFrameworkCoreDataProviderBuilderExtensions.cs @@ -14,7 +14,7 @@ namespace AppCoreNet.Extensions.DependencyInjection; /// /// Provides extension methods to register a . /// -public static class DbContextDataProviderBuilderExtensions +public static class EntityFrameworkCoreDataProviderBuilderExtensions { private const int DefaultPoolSize = 128; @@ -28,8 +28,8 @@ public static class DbContextDataProviderBuilderExtensions /// The . /// The name of the data provider. /// The lifetime of the data provider. - /// The . - public static DbContextDataProviderBuilder AddDbContextCore( + /// The . + private static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCoreBase( this IDataProviderBuilder builder, string name, ServiceLifetime providerLifetime = ServiceLifetime.Scoped) @@ -52,7 +52,7 @@ public static DbContextDataProviderBuilder AddDbContextCore(name, services); }); - return new DbContextDataProviderBuilder(name, builder.Services, providerLifetime); + return new EntityFrameworkCoreDataProviderBuilder(name, builder.Services, providerLifetime); } /// @@ -64,13 +64,13 @@ public static DbContextDataProviderBuilder AddDbContextCoreThe type of the . /// The . /// The lifetime of the data provider. - /// The . - public static DbContextDataProviderBuilder AddDbContextCore( + /// The . + private static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCoreBase( this IDataProviderBuilder builder, ServiceLifetime providerLifetime = ServiceLifetime.Scoped) where TDbContext : DbContext { - return builder.AddDbContextCore(string.Empty, providerLifetime); + return builder.AddEntityFrameworkCoreBase(string.Empty, providerLifetime); } /// @@ -82,8 +82,8 @@ public static DbContextDataProviderBuilder AddDbContextCoreThe delegate used to configure the options. /// The lifetime of the . /// The lifetime of the . - /// The . - public static DbContextDataProviderBuilder AddDbContext( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCore( this IDataProviderBuilder builder, string name, Action? optionsAction = null, @@ -95,7 +95,7 @@ public static DbContextDataProviderBuilder AddDbContext( Ensure.Arg.NotNull(name); builder.Services.AddDbContext(optionsAction, contextLifetime, optionsLifetime); - return builder.AddDbContextCore(name, contextLifetime); + return builder.AddEntityFrameworkCoreBase(name, contextLifetime); } /// @@ -107,8 +107,8 @@ public static DbContextDataProviderBuilder AddDbContext( /// The delegate used to configure the options. /// The lifetime of the . /// The lifetime of the . - /// The . - public static DbContextDataProviderBuilder AddDbContext( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCore( this IDataProviderBuilder builder, string name, Action? optionsAction, @@ -116,7 +116,7 @@ public static DbContextDataProviderBuilder AddDbContext( ServiceLifetime optionsLifetime = ServiceLifetime.Scoped) where TDbContext : DbContext { - return builder.AddDbContext( + return builder.AddEntityFrameworkCore( name, (_, ob) => optionsAction?.Invoke(ob), contextLifetime, @@ -131,15 +131,15 @@ public static DbContextDataProviderBuilder AddDbContext( /// The delegate used to configure the options. /// The lifetime of the . /// The lifetime of the . - /// The . - public static DbContextDataProviderBuilder AddDbContext( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCore( this IDataProviderBuilder builder, Action? optionsAction = null, ServiceLifetime contextLifetime = ServiceLifetime.Scoped, ServiceLifetime optionsLifetime = ServiceLifetime.Scoped) where TDbContext : DbContext { - return builder.AddDbContext(string.Empty, optionsAction, contextLifetime, optionsLifetime); + return builder.AddEntityFrameworkCore(string.Empty, optionsAction, contextLifetime, optionsLifetime); } /// @@ -150,15 +150,15 @@ public static DbContextDataProviderBuilder AddDbContext( /// The delegate used to configure the options. /// The lifetime of the . /// The lifetime of the . - /// The . - public static DbContextDataProviderBuilder AddDbContext( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCore( this IDataProviderBuilder builder, Action? optionsAction, ServiceLifetime contextLifetime = ServiceLifetime.Scoped, ServiceLifetime optionsLifetime = ServiceLifetime.Scoped) where TDbContext : DbContext { - return builder.AddDbContext(string.Empty, optionsAction, contextLifetime, optionsLifetime); + return builder.AddEntityFrameworkCore(string.Empty, optionsAction, contextLifetime, optionsLifetime); } /// @@ -169,8 +169,8 @@ public static DbContextDataProviderBuilder AddDbContext( /// The name of the data provider. /// The delegate used to configure the options. /// The size of the pool. - /// The . - public static DbContextDataProviderBuilder AddDbContextPool( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCorePool( this IDataProviderBuilder builder, string name, Action? optionsAction = null, @@ -183,7 +183,7 @@ public static DbContextDataProviderBuilder AddDbContextPool { }; builder.Services.AddDbContextPool(optionsAction, poolSize); - return builder.AddDbContextCore(name); + return builder.AddEntityFrameworkCoreBase(name); } /// @@ -194,15 +194,15 @@ public static DbContextDataProviderBuilder AddDbContextPoolThe name of the data provider. /// The delegate used to configure the options. /// The size of the pool. - /// The . - public static DbContextDataProviderBuilder AddDbContextPool( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCorePool( this IDataProviderBuilder builder, string name, Action? optionsAction = null, int poolSize = DefaultPoolSize) where TDbContext : DbContext { - return builder.AddDbContextPool( + return builder.AddEntityFrameworkCorePool( name, (_, ob) => optionsAction?.Invoke(ob), poolSize); @@ -215,14 +215,14 @@ public static DbContextDataProviderBuilder AddDbContextPoolThe . /// The delegate used to configure the options. /// The size of the pool. - /// The . - public static DbContextDataProviderBuilder AddDbContextPool( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCorePool( this IDataProviderBuilder builder, Action? optionsAction = null, int poolSize = DefaultPoolSize) where TDbContext : DbContext { - return builder.AddDbContextPool(string.Empty, optionsAction, poolSize); + return builder.AddEntityFrameworkCorePool(string.Empty, optionsAction, poolSize); } /// @@ -232,13 +232,13 @@ public static DbContextDataProviderBuilder AddDbContextPoolThe . /// The delegate used to configure the options. /// The size of the pool. - /// The . - public static DbContextDataProviderBuilder AddDbContextPool( + /// The . + public static EntityFrameworkCoreDataProviderBuilder AddEntityFrameworkCorePool( this IDataProviderBuilder builder, Action? optionsAction = null, int poolSize = DefaultPoolSize) where TDbContext : DbContext { - return builder.AddDbContextPool(string.Empty, optionsAction, poolSize); + return builder.AddEntityFrameworkCorePool(string.Empty, optionsAction, poolSize); } } \ No newline at end of file diff --git a/src/AppCoreNet.Data.MongoDB/AppCoreNet.Data.MongoDB.csproj b/src/AppCoreNet.Data.MongoDB/AppCoreNet.Data.MongoDB.csproj index 551c490..84a0540 100644 --- a/src/AppCoreNet.Data.MongoDB/AppCoreNet.Data.MongoDB.csproj +++ b/src/AppCoreNet.Data.MongoDB/AppCoreNet.Data.MongoDB.csproj @@ -1,13 +1,14 @@  - net8.0;netstandard2.0;net462 + net8.0;netstandard2.1;net472 Adds Mongo DB support to AppCore .NET persistence. - + + diff --git a/src/AppCoreNet.Data.MongoDB/DependencyInjection/MongoDataProviderBuilderExtensions.cs b/src/AppCoreNet.Data.MongoDB/DependencyInjection/MongoDataProviderBuilderExtensions.cs index 425c823..94ddf6f 100644 --- a/src/AppCoreNet.Data.MongoDB/DependencyInjection/MongoDataProviderBuilderExtensions.cs +++ b/src/AppCoreNet.Data.MongoDB/DependencyInjection/MongoDataProviderBuilderExtensions.cs @@ -21,7 +21,7 @@ public static class MongoDataProviderBuilderExtensions /// The name of the data provider. /// Optional delegate to configure the . /// A to further configure the data provider. - public static MongoDataProviderBuilder AddMongoDB( + public static MongoDataProviderBuilder AddMongoDb( this IDataProviderBuilder builder, string name, Action? configure) @@ -30,11 +30,11 @@ public static MongoDataProviderBuilder AddMongoDB( Ensure.Arg.NotNull(name); builder.Services.AddOptions(); + builder.Services.AddLogging(); - // TODO: builder.Services.AddLogging(); if (configure != null) { - builder.Services.Configure(configure); + builder.Services.Configure(name, configure); } builder.AddProvider( diff --git a/src/AppCoreNet.Data.MongoDB/MongoTransaction.cs b/src/AppCoreNet.Data.MongoDB/MongoTransaction.cs index 04763b1..8ca0de1 100644 --- a/src/AppCoreNet.Data.MongoDB/MongoTransaction.cs +++ b/src/AppCoreNet.Data.MongoDB/MongoTransaction.cs @@ -101,6 +101,8 @@ public void Commit() _logger.TransactionCommitFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -121,6 +123,8 @@ public void Rollback() _logger.TransactionRollbackFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -143,6 +147,8 @@ await _session.CommitTransactionAsync(cancellationToken) _logger.TransactionCommitFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } /// @@ -165,5 +171,7 @@ await _session.AbortTransactionAsync(cancellationToken) _logger.TransactionRollbackFailed(this, error); throw; } + + TransactionFinished?.Invoke(this, EventArgs.Empty); } } \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/AppCoreNet.Data.EntityFramework.Tests.csproj b/test/AppCoreNet.Data.EntityFramework.Tests/AppCoreNet.Data.EntityFramework.Tests.csproj new file mode 100644 index 0000000..1e4b962 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/AppCoreNet.Data.EntityFramework.Tests.csproj @@ -0,0 +1,19 @@ + + + + net9.0;net8.0 + $(TargetFrameworks);net472 + AppCoreNet.Data.EntityFramework + + + + + + + + + + + + + diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao.cs new file mode 100644 index 0000000..00ae4f7 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao.cs @@ -0,0 +1,15 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; + +namespace AppCoreNet.Data.EntityFramework.DAO; + +public class TestDao +{ + public Guid Id { get; set; } + + public string? Name { get; set; } + + public string? ChangeToken { get; set; } +} diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao2.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao2.cs new file mode 100644 index 0000000..11532f4 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDao2.cs @@ -0,0 +1,17 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; + +namespace AppCoreNet.Data.EntityFramework.DAO; + +public class TestDao2 +{ + public Guid Id { get; set; } + + public int Version { get; set; } + + public string? Name { get; set; } + + public string? ChangeToken { get; set; } +} diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDbContext.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDbContext.cs new file mode 100644 index 0000000..97b5504 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DAO/TestDbContext.cs @@ -0,0 +1,33 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.ComponentModel.DataAnnotations.Schema; +using System.Data.Entity; + +namespace AppCoreNet.Data.EntityFramework.DAO; + +public class TestDbContext : DbContext +{ + public TestDbContext(string connectionString) + : base(connectionString) + { + } + + public TestDbContext() + : base(Effort.DbConnectionFactory.CreateTransient(), true) + { + } + + protected override void OnModelCreating(DbModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().ToTable("TestEntities"); + modelBuilder.Entity().HasKey(e => e.Id); + modelBuilder.Entity().Property(p => p.ChangeToken).IsConcurrencyToken(); + + modelBuilder.Entity().ToTable("TestEntities2"); + modelBuilder.Entity().HasKey(e => new { e.Id, e.Version }); + modelBuilder.Entity().Property(p => p.ChangeToken).IsConcurrencyToken(); + } +} diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DbContextRepositoryTests.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextRepositoryTests.cs new file mode 100644 index 0000000..f71caf8 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextRepositoryTests.cs @@ -0,0 +1,151 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using System.Data.Entity; +using System.Data.Entity.Infrastructure; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using AppCoreNet.Data.EntityFramework.DAO; +using AppCoreNet.Data.EntityFramework.Queries; +using AppCoreNet.Extensions.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AppCoreNet.Data.EntityFramework; + +public class DbContextRepositoryTests : RepositoryTests +{ + protected override void ConfigureServices(IServiceCollection services) + { + base.ConfigureServices(services); + + Mapper = EntityMapper.Instance; + + services.AddDataProvider( + p => + { + // TDbContext (TestDbContext) is registered with DI here by the AddEntityFramework call + // if a factory is provided. For EF6, it's common to new it up or have a factory. + // The AddEntityFramework method will resolve TDbContext if it's registered. + // For Effort, TestDbContext has a parameterless constructor that uses Effort. + services.AddScoped(); // Register TestDbContext for Effort + + p.AddEntityFramework(ProviderName) + .AddRepository() + .AddRepository() + .AddQueryHandler() + .AddQueryHandler(); + }); + } + + private async Task FindDataEntity(IDataProvider provider, Expression> expression) + where TDao : class + where TEntity : IEntity + { + var dbContextDataProvider = (DbContextDataProvider)provider; + + TDao? dao = + await dbContextDataProvider.DbContext.Set() + .AsNoTracking() + .Where(expression) + .FirstOrDefaultAsync(); + + return dao; + } + + private async Task FindDataEntity(IDataProvider provider, Guid id) + { + return await FindDataEntity( + provider, + e => e.Id == id); + } + + private async Task FindDataEntity(IDataProvider provider, Entities.ComplexId id) + { + return await FindDataEntity( + provider, + e => e.Id == id.Id && e.Version == id.Version); + } + + protected override async Task AssertExistingDataEntity(IDataProvider provider, Entities.TestEntity entity) + { + DAO.TestDao? dao = await FindDataEntity(provider, entity.Id); + + dao.Should() + .NotBeNull(); + + dao.Should() + .BeEquivalentTo(entity); + } + + protected override async Task AssertExistingDataEntity(IDataProvider provider, Entities.TestEntity2 entity) + { + DAO.TestDao2? dao = await FindDataEntity(provider, entity.Id); + + dao.Should() + .NotBeNull(); + + dao.Should() + .BeEquivalentTo( + entity, + o => o.Excluding(e => e.Id) + .ExcludingMissingMembers()); + + dao!.Id.Should() + .Be(entity.Id.Id); + + dao.Version.Should() + .Be(entity.Id.Version); + } + + protected override async Task AssertNonExistingDataEntity(IDataProvider provider, Guid id) + { + DAO.TestDao? dao = await FindDataEntity(provider, id); + + dao.Should() + .BeNull(); + } + + private async Task CreateDataEntity(IDataProvider provider, TDao dataEntity) + where TDao : class + { + var dbContextDataProvider = (DbContextDataProvider)provider; + TestDbContext dbContext = dbContextDataProvider.DbContext; + + dbContext.Set() + .Add(dataEntity); + + await dbContext.SaveChangesAsync(); + + DbEntityEntry[] entries = dbContext.ChangeTracker.Entries() + .ToArray(); + + foreach (DbEntityEntry entry in entries) + { + entry.State = EntityState.Detached; + } + } + + protected override async Task CreateDataEntity(IDataProvider provider, Entities.TestEntity entity) + { + var dataEntity = Mapper.Map(entity); + await CreateDataEntity(provider, dataEntity); + return dataEntity; + } + + protected override async Task CreateDataEntity(IDataProvider provider, Entities.TestEntity2 entity) + { + var dataEntity = Mapper.Map(entity); + await CreateDataEntity(provider, dataEntity); + return dataEntity; + } + + [Fact(Skip = "Not supported by EF6")] + public override Task CreateAssignsId() + { + return base.CreateAssignsId(); + } +} diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntity2Repository.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntity2Repository.cs new file mode 100644 index 0000000..e40008c --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntity2Repository.cs @@ -0,0 +1,15 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using AppCoreNet.Data.EntityFramework.DAO; + +namespace AppCoreNet.Data.EntityFramework; + +public class DbContextTestEntity2Repository + : DbContextRepository, ITestEntity2Repository +{ + public DbContextTestEntity2Repository(DbContextDataProvider provider) + : base(provider) + { + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntityRepository.cs b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntityRepository.cs new file mode 100644 index 0000000..a13d8a2 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/DbContextTestEntityRepository.cs @@ -0,0 +1,16 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; +using AppCoreNet.Data.EntityFramework.DAO; + +namespace AppCoreNet.Data.EntityFramework; + +public class DbContextTestEntityRepository + : DbContextRepository, ITestEntityRepository +{ + public DbContextTestEntityRepository(DbContextDataProvider provider) + : base(provider) + { + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/EntityMapper.cs b/test/AppCoreNet.Data.EntityFramework.Tests/EntityMapper.cs new file mode 100644 index 0000000..8bc224a --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/EntityMapper.cs @@ -0,0 +1,85 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using NSubstitute; + +namespace AppCoreNet.Data.EntityFramework; + +public static class EntityMapper +{ + public static readonly IEntityMapper Instance; + + static EntityMapper() + { + Instance = Substitute.For(); + + Instance.Map(Arg.Any()) + .Returns( + ci => + { + var entity = ci.ArgAt(0); + return new DAO.TestDao() + { + Id = entity.Id, + Name = entity.Name, + ChangeToken = entity.ChangeToken, + }; + }); + + Instance.When(m => m.Map(Arg.Any(), Arg.Any())).Do( + ci => + { + var from = ci.ArgAt(0); + var to = ci.ArgAt(1); + + to.Id = from.Id; + to.Name = from.Name; + to.ChangeToken = from.ChangeToken; + }); + + Instance.Map(Arg.Any()) + .Returns( + ci => + { + var document = ci.ArgAt(0); + return new Entities.TestEntity() + { + Id = document.Id, + Name = document.Name, + ChangeToken = document.ChangeToken, + }; + }); + + Instance.Map(Arg.Any()) + .Returns( + ci => + { + var entity = ci.ArgAt(0); + return new DAO.TestDao2() + { + Id = entity.Id.Id, + Version = entity.Id.Version, + Name = entity.Name, + ChangeToken = entity.ChangeToken, + }; + }); + + Instance.Map(Arg.Any()) + .Returns( + ci => + { + var document = ci.ArgAt(0); + return new Entities.TestEntity2() + { + Id = new Entities.ComplexId + { + Id = document.Id, + Version = document.Version, + }, + Name = document.Name, + ChangeToken = document.ChangeToken, + ExpectedChangeToken = document.ChangeToken, + }; + }); + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntity2ByIdQueryHandler.cs b/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntity2ByIdQueryHandler.cs new file mode 100644 index 0000000..9bdd860 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntity2ByIdQueryHandler.cs @@ -0,0 +1,37 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Linq; +using AppCoreNet.Data.Entities; +using AppCoreNet.Data.EntityFramework.DAO; +using AppCoreNet.Data.Queries; + +namespace AppCoreNet.Data.EntityFramework.Queries; + +public class TestEntity2ByIdQueryHandler : DbContextTestEntity2Repository.ScalarQueryHandler +{ + public TestEntity2ByIdQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + + protected override IQueryable ApplyQuery(IQueryable queryable, TestEntity2ByIdQuery query) + { + return queryable.Where(e => e.Id == query.Id.Id && e.Version == query.Id.Version); + } + + protected override IQueryable ApplyProjection(IQueryable queryable, TestEntity2ByIdQuery query) + { + return queryable.Select( + e => new TestEntity2() + { + Id = new ComplexId + { + Id = e.Id, + Version = e.Version, + }, + ChangeToken = e.ChangeToken, + Name = e.Name, + }); + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntityByIdQueryHandler.cs b/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntityByIdQueryHandler.cs new file mode 100644 index 0000000..574a685 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/Queries/TestEntityByIdQueryHandler.cs @@ -0,0 +1,27 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System.Linq; +using AppCoreNet.Data.Entities; +using AppCoreNet.Data.EntityFramework.DAO; +using AppCoreNet.Data.Queries; + +namespace AppCoreNet.Data.EntityFramework.Queries; + +public class TestEntityByIdQueryHandler : DbContextTestEntityRepository.ScalarQueryHandler +{ + public TestEntityByIdQueryHandler(DbContextDataProvider provider) + : base(provider) + { + } + + protected override IQueryable ApplyQuery(IQueryable queryable, TestEntityByIdQuery query) + { + return queryable.Where(e => e.Id == query.Id); + } + + protected override IQueryable ApplyProjection(IQueryable queryable, TestEntityByIdQuery query) + { + return queryable.Select(e => new TestEntity() { Id = e.Id, ChangeToken = e.ChangeToken, Name = e.Name }); + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFramework.Tests/VersionId.cs b/test/AppCoreNet.Data.EntityFramework.Tests/VersionId.cs new file mode 100644 index 0000000..e1eb665 --- /dev/null +++ b/test/AppCoreNet.Data.EntityFramework.Tests/VersionId.cs @@ -0,0 +1,44 @@ +// Licensed under the MIT license. +// Copyright (c) The AppCore .NET project. + +using System; + +namespace AppCoreNet.Data.EntityFrameworkCore; + +public readonly struct VersionId : IEquatable +{ + public int Id { get; } + + public int Version { get; } + + public VersionId(int id, int version) + { + Id = id; + Version = version; + } + + public bool Equals(VersionId other) + { + return Id == other.Id && Version == other.Version; + } + + public override bool Equals(object? obj) + { + return obj is VersionId other && Equals(other); + } + + public override int GetHashCode() + { + return Id + Version; + } + + public static bool operator ==(VersionId left, VersionId right) + { + return left.Equals(right); + } + + public static bool operator !=(VersionId left, VersionId right) + { + return !left.Equals(right); + } +} \ No newline at end of file diff --git a/test/AppCoreNet.Data.EntityFrameworkCore.Tests/DbContextRepositoryTests.cs b/test/AppCoreNet.Data.EntityFrameworkCore.Tests/DbContextRepositoryTests.cs index 4f605cd..3119f31 100644 --- a/test/AppCoreNet.Data.EntityFrameworkCore.Tests/DbContextRepositoryTests.cs +++ b/test/AppCoreNet.Data.EntityFrameworkCore.Tests/DbContextRepositoryTests.cs @@ -27,7 +27,7 @@ protected override void ConfigureServices(IServiceCollection services) services.AddDataProvider( p => { - p.AddDbContext( + p.AddEntityFrameworkCore( ProviderName, o => { diff --git a/test/AppCoreNet.Data.MongoDB.Tests/MongoRepositoryTests.cs b/test/AppCoreNet.Data.MongoDB.Tests/MongoRepositoryTests.cs index be18efb..d34c52c 100644 --- a/test/AppCoreNet.Data.MongoDB.Tests/MongoRepositoryTests.cs +++ b/test/AppCoreNet.Data.MongoDB.Tests/MongoRepositoryTests.cs @@ -8,6 +8,9 @@ using AppCoreNet.Extensions.DependencyInjection; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; using MongoDB.Driver; using Xunit; @@ -21,6 +24,11 @@ public class MongoRepositoryTests : RepositoryTests private readonly MongoTestFixture _mongoTestFixture; + static MongoRepositoryTests() + { + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + } + public MongoRepositoryTests(MongoTestFixture mongoTestFixture) { _mongoTestFixture = mongoTestFixture; @@ -35,13 +43,13 @@ protected override void ConfigureServices(IServiceCollection services) services.AddDataProvider( p => { - p.AddMongoDB( - ProviderName, - o => - { - o.ClientSettings = MongoClientSettings.FromConnectionString(_mongoTestFixture.ConnectionString); - o.Database = DatabaseName; - }) + p.AddMongoDb( + ProviderName, + o => + { + o.ClientSettings = MongoClientSettings.FromConnectionString(_mongoTestFixture.ConnectionString); + o.Database = DatabaseName; + }) .AddRepository() .AddQueryHandler() .AddQueryHandler() diff --git a/test/AppCoreNet.Data.SpecificationTests/RepositoryTests.cs b/test/AppCoreNet.Data.SpecificationTests/RepositoryTests.cs index e47874e..f20752d 100644 --- a/test/AppCoreNet.Data.SpecificationTests/RepositoryTests.cs +++ b/test/AppCoreNet.Data.SpecificationTests/RepositoryTests.cs @@ -73,7 +73,7 @@ protected virtual ServiceProvider CreateServiceProvider() protected abstract Task CreateDataEntity(IDataProvider provider, TestEntity2 entity); [Fact] - public async Task CreateAssignsId() + public virtual async Task CreateAssignsId() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -97,7 +97,7 @@ public async Task CreateAssignsId() } [Fact] - public async Task CreateUsesProvidedId() + public virtual async Task CreateUsesProvidedId() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -115,7 +115,7 @@ public async Task CreateUsesProvidedId() } [Fact] - public async Task CreateUsesProvidedComplexId() + public virtual async Task CreateUsesProvidedComplexId() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -138,7 +138,7 @@ public async Task CreateUsesProvidedComplexId() } [Fact] - public async Task CreateCreatesDataEntity() + public virtual async Task CreateCreatesDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -160,7 +160,7 @@ public async Task CreateCreatesDataEntity() } [Fact] - public async Task CreateCreatesComplexIdDataEntity() + public virtual async Task CreateCreatesComplexIdDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -186,7 +186,7 @@ public async Task CreateCreatesComplexIdDataEntity() } [Fact] - public async Task CreateAssignsChangeToken() + public virtual async Task CreateAssignsChangeToken() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -202,10 +202,15 @@ public async Task CreateAssignsChangeToken() createdEntity.ChangeToken.Should() .Be(changeToken); + + IDataProvider provider = sp.GetRequiredService() + .Resolve(ProviderName); + + await AssertExistingDataEntity(provider, createdEntity); } [Fact] - public async Task CreateUsesExplicitChangeToken() + public virtual async Task CreateUsesExplicitChangeToken() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -224,7 +229,7 @@ public async Task CreateUsesExplicitChangeToken() } [Fact] - public async Task FindLoadsDataEntity() + public virtual async Task FindLoadsDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -253,7 +258,7 @@ public async Task FindLoadsDataEntity() } [Fact] - public async Task FindReturnsNullForUnknownDataEntity() + public virtual async Task FindReturnsNullForUnknownDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -267,7 +272,7 @@ public async Task FindReturnsNullForUnknownDataEntity() } [Fact] - public async Task LoadLoadsDataEntity() + public virtual async Task LoadLoadsDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -296,7 +301,7 @@ public async Task LoadLoadsDataEntity() } [Fact] - public async Task LoadThrowsForUnknownDataEntity() + public virtual async Task LoadThrowsForUnknownDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -309,7 +314,7 @@ public async Task LoadThrowsForUnknownDataEntity() } [Fact] - public async Task DeleteRemovesDataEntity() + public virtual async Task DeleteRemovesDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -333,7 +338,7 @@ public async Task DeleteRemovesDataEntity() } [Fact] - public async Task DeleteThrowsForUnknownDataEntity() + public virtual async Task DeleteThrowsForUnknownDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -351,7 +356,7 @@ public async Task DeleteThrowsForUnknownDataEntity() } [Fact] - public async Task UpdateModifiesExistingDataEntity() + public virtual async Task UpdateModifiesExistingDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -383,7 +388,7 @@ public async Task UpdateModifiesExistingDataEntity() } [Fact] - public async Task UpdateThrowsForNonExistentDataEntity() + public virtual async Task UpdateThrowsForNonExistentDataEntity() { await using ServiceProvider sp = CreateServiceProvider(); var repository = sp.GetRequiredService(); @@ -400,7 +405,7 @@ public async Task UpdateThrowsForNonExistentDataEntity() } [Fact] - public async Task UpdateModifiesDataEntityIfChangeTokenMatches() + public virtual async Task UpdateModifiesDataEntityIfChangeTokenMatches() { await using ServiceProvider sp = CreateServiceProvider(); @@ -431,7 +436,7 @@ public async Task UpdateModifiesDataEntityIfChangeTokenMatches() } [Fact] - public async Task UpdateThrowsIfChangeTokenDoesNotMatch() + public virtual async Task UpdateThrowsIfChangeTokenDoesNotMatch() { await using ServiceProvider sp = CreateServiceProvider(); @@ -458,7 +463,7 @@ public async Task UpdateThrowsIfChangeTokenDoesNotMatch() } [Fact] - public async Task UpdateModifiesDataEntityIfExpectedChangeTokenMatches() + public virtual async Task UpdateModifiesDataEntityIfExpectedChangeTokenMatches() { await using ServiceProvider sp = CreateServiceProvider(); @@ -493,7 +498,7 @@ public async Task UpdateModifiesDataEntityIfExpectedChangeTokenMatches() } [Fact] - public async Task UpdateThrowsIfExpectedChangeTokenDoesNotMatch() + public virtual async Task UpdateThrowsIfExpectedChangeTokenDoesNotMatch() { await using ServiceProvider sp = CreateServiceProvider(); @@ -520,7 +525,7 @@ public async Task UpdateThrowsIfExpectedChangeTokenDoesNotMatch() } [Fact] - public async Task QueryByIdReturnsEntity() + public virtual async Task QueryByIdReturnsEntity() { await using ServiceProvider sp = CreateServiceProvider(); @@ -548,7 +553,7 @@ public async Task QueryByIdReturnsEntity() } [Fact] - public async Task QueryByComplexIdReturnsEntity() + public virtual async Task QueryByComplexIdReturnsEntity() { await using ServiceProvider sp = CreateServiceProvider();