diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5da7442 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to Deveel Repository + +We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## We Develop with Github +We use github to host code, to track issues and feature requests, as well as accept pull requests. + +## GitHub Flow + +We use the [Github Flow](https://guides.github.com/introduction/flow/index.html) that basically means that all code changes happen through pull requests. + +Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## License of Your Contributions + +In short, when you submit code changes, your submissions are understood to be under the same [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Reporting Bugs + +We use GitHub [issues](https://github.com/deveel/deveel.repository/issues) to track public bugs. Report a bug by [opening a new issue](); it's that easy! + +### Write bug reports with detail, background, and sample code + +[This is an example](http://stackoverflow.com/q/12488905/180626) of a bug report I wrote, and I think it's not a bad model. Here's [another example from Craig Hockenberry](http://www.openradar.me/11905408), an app developer whom I greatly respect. + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. [My stackoverflow question](http://stackoverflow.com/q/12488905/180626) includes sample code that *anyone* with a base R setup can run to reproduce what I was seeing +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. \ No newline at end of file diff --git a/Deveel.Repository.sln b/Deveel.Repository.sln index 3790aa0..d620531 100644 --- a/Deveel.Repository.sln +++ b/Deveel.Repository.sln @@ -42,9 +42,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Deveel.Repository.licenseheader = Deveel.Repository.licenseheader EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Repository.Manager", "src\Deveel.Repository.Manager\Deveel.Repository.Manager.csproj", "{C1029341-6EC0-4D83-AC9B-D4DA686237B4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager", "src\Deveel.Repository.Manager\Deveel.Repository.Manager.csproj", "{C1029341-6EC0-4D83-AC9B-D4DA686237B4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Repository.Manager.XUnit", "test\Deveel.Repository.Manager.XUnit\Deveel.Repository.Manager.XUnit.csproj", "{B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager.XUnit", "test\Deveel.Repository.Manager.XUnit\Deveel.Repository.Manager.XUnit.csproj", "{B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Repository.Manager.DynamicLinq", "src\Deveel.Repository.Manager.DynamicLinq\Deveel.Repository.Manager.DynamicLinq.csproj", "{638851EF-B000-490C-9035-A962279A3E9B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -108,6 +110,10 @@ Global {B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9}.Release|Any CPU.ActiveCfg = Release|Any CPU {B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9}.Release|Any CPU.Build.0 = Release|Any CPU + {638851EF-B000-490C-9035-A962279A3E9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {638851EF-B000-490C-9035-A962279A3E9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {638851EF-B000-490C-9035-A962279A3E9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {638851EF-B000-490C-9035-A962279A3E9B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,6 +133,7 @@ Global {201917F4-0557-46AE-B532-A4CC801AF5A7} = {50434E05-0F21-4871-AFB3-A483CEE4A300} {C1029341-6EC0-4D83-AC9B-D4DA686237B4} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} {B63DFEE8-7964-4DC3-9DD5-8E11319B8EA9} = {50434E05-0F21-4871-AFB3-A483CEE4A300} + {638851EF-B000-490C-9035-A962279A3E9B} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {01FD9B16-84B3-4D99-80C1-11B2F3D65B56} diff --git a/README.md b/README.md index 62183d8..ae232b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -[![Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Repository CI/CD](https://github.com/deveel/deveel.repository/actions/workflows/ci.yml/badge.svg)](https://github.com/deveel/deveel.repository/actions/workflows/ci.yml) +[![Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Repository CI/CD](https://github.com/deveel/deveel.repository/actions/workflows/ci.yml/badge.svg)](https://github.com/deveel/deveel.repository/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/deveel/deveel.repository/graph/badge.svg?token=5US7L3C7ES)](https://codecov.io/gh/deveel/deveel.repository) + # Deveel Repository This project wants to provide a _low-ambitions_ / _low-expectations_ implementation of the (_infamous_) _[Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html)_ for .NET to support the development of applications that need to access different data sources, using a common interface, respecting the principles of the _[Domain-Driven Design](https://en.wikipedia.org/wiki/Domain-driven_design)_ and the _[SOLID](https://en.wikipedia.org/wiki/SOLID)_ principles. @@ -34,6 +35,7 @@ The framework is based on a _kernel_ package, that provides the basic interfaces | _Deveel.Repository.MongoDb_ | [![NuGet](https://img.shields.io/nuget/v/Deveel.Repository.MongoDb.svg)](https://www.nuget.org/packages/Deveel.Repository.MongoDb/) | | _Deveel.Repository.EntityFramework_ | [![NuGet](https://img.shields.io/nuget/v/Deveel.Repository.EntityFramework.svg)](https://www.nuget.org/packages/Deveel.Repository.EntityFramework/) | | _Deveel.Repository.DynamicLinq_ | [![NuGet](https://img.shields.io/nuget/v/Deveel.Repository.DynamicLinq.svg)](https://www.nuget.org/packages/Deveel.Repository.DynamicLinq/) | +| _Deveel.Repository.Manager_ | [![NuGet](https://img.shields.io/nuget/v/Deveel.Repository.Manager.svg)](https://www.nuget.org/packages/Deveel.Repository.Manager/) | ### The Kernel Package @@ -57,7 +59,7 @@ The library provides a set of drivers to access different data sources, that can | Driver | Package | Description | | ------ | ------- | ----------- | -| _In-Memory_ | `Deveel.Repository.InMemory` | An implementation of the repository pattern that stores the data in-memory. | +| _In-Memory_ | `Deveel.Repository.InMemory` | A very simple implementation of the repository pattern that stores the data in-memory. | | _MongoDB_ | `Deveel.Repository.MongoDb` | An implementation of the repository pattern that stores the data in a MongoDB database. | | _Entity Framework Core_ | `Deveel.Repository.EntityFramework` | An implementation of the repository pattern that stores the data in a relational database, using the [Entity Framework Core](https://github.com/dotnet/efcore). | @@ -85,6 +87,8 @@ public void ConfigureServices(IServiceCollection services) { The type of the argument of the method is not the type of the entity, but the type of the repository: the library will use reflection to scan the type itself and find all the generic arguments of the `IRepository` interface, and register the repository in the dependency injection container. +### Consuming the Repository + In fact, after that exmaple call above, you will have the following services available to be injected in your application: | Service | Description | @@ -348,4 +352,48 @@ services.AddEntityRepository(); #### Filtering Data -The `EntityRepository` implements both the `IQueryableRepository` and the `IFilterableRepository` interfaces, and allows to query the data only through the `ExpressionFilter` class or through lambda expressions of type `Expression>`. \ No newline at end of file +The `EntityRepository` implements both the `IQueryableRepository` and the `IFilterableRepository` interfaces, and allows to query the data only through the `ExpressionFilter` class or through lambda expressions of type `Expression>`. + +## Entity Manager + +The framework provides an extension that allows to control the operations performed on the repository, ensuring the consistency of the data (through validation). + +The `EntityManager` class wraps around instances of `IRepository`, enriching the basic operations with validation logic, and providing a way to intercept the operations performed on the repository, and preventing exceptions to be thrown without a proper handling. + +It is possible to derive from the `EntityManager` class to implement your own business and validation logic, and to intercept the operations performed on the repository. + +This class is suited to be used in application contexts, where a higher level of control is required on the operations performed on the repository (such for example in the case of ASP.NET services). + +### Instrumentation + +To register an instance of `EntityManager` in the dependency injection container, you can use the `AddEntityManager` extension method of the `IServiceCollection` interface. + +```csharp +public void ConfigureServices(IServiceCollection services) { + services.AddEntityManager(); +} +``` + +The method will register an instance of `MyEntityManager` and `EntityManager` in the dependency injection container, ready to be used. + +### Entity Validation + +It is possible to validate the entities before they are added or updated in the repository, by implementing the `IEntityValidator` interface, and registering an instance of the validator in the dependency injection container. + +The `EntityManager` class will check for instances of `IEntityValidator` in the dependency injection container, and will use the first instance found to validate the entities before they are added or updated in the repository. + +### Operation Cancellation + +The `EntityManager` class provides a way to directly cancel the operations performed on the repository, by passing an argument of type `CancellationToken` to each asynchronous operation, and optionally verifies for instances of `IOperationCancellationSource` that are registered in the dependency injection container. + +When the `CancellationToken` argument of an operation is `null`, the `EntityManager` class will check for instances of `IOperationCancellationSource` in the dependency injection container, and will use the first instance found to cancel the operation. + +The value of this approach is to be able to attach the cancellation of the operation to a specific context (such as `HttpContext`), and to be able to cancel the operation from a different context (for instance when the HTTP request is cancelled). + +## License + +The project is licensed under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). + +## Contributing + +The project is open to contributions: if you want to contribute to the project, please read the [contributing guidelines](CONTRIBUTING.md) for more information. \ No newline at end of file diff --git a/src/Deveel.Repository.Manager.DynamicLinq/Data/EntityManagerExtensions.cs b/src/Deveel.Repository.Manager.DynamicLinq/Data/EntityManagerExtensions.cs new file mode 100644 index 0000000..ff7c1c6 --- /dev/null +++ b/src/Deveel.Repository.Manager.DynamicLinq/Data/EntityManagerExtensions.cs @@ -0,0 +1,92 @@ +// Copyright 2023 Deveel AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Data { + /// + /// Extends the to provide + /// filtering capabilities using a dynamic LINQ expression. + /// + /// + public static class EntityManagerExtensions { + /// + /// Finds the first entity in the repository that matches the given + /// dynamic LINQ expression. + /// + /// + /// The type of the entity to find. + /// + /// + /// The entity manager to use to find the entity. + /// + /// + /// The dynamic LINQ expression to use to filter the entity. + /// + /// + /// A token to cancel the operation. + /// + /// + /// Returns the first entity that matches the given expression, or + /// null if no entity was found. + /// + public static Task FindFirstAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) + where TEntity : class + => manager.FindFirstAsync(new DynamicLinqFilter(expression), cancellationToken); + + /// + /// Finds a range of entities in the repository that matches the given + /// dynamic LINQ expression. + /// + /// + /// The type of the entity to find. + /// + /// + /// The entity manager to use to find the entity. + /// + /// + /// The dynamic LINQ expression to use to filter the entity. + /// + /// + /// A token to cancel the operation. + /// + /// + /// Returns a list of entities that matches the given expression. + /// + public static Task> FindAllAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) + where TEntity : class + => manager.FindAllAsync(new DynamicLinqFilter(expression), cancellationToken); + + /// + /// Counts the number of entities in the repository that matches the + /// dynamic LINQ expression. + /// + /// + /// The type of the entity to count. + /// + /// + /// The entity manager to use to count the entities. + /// + /// + /// The dynamic LINQ expression to use to filter the entity. + /// + /// + /// A token to cancel the operation. + /// + /// + /// Returns the number of entities that matches the given expression. + /// + public static Task CountAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null) + where TEntity : class + => manager.CountAsync(new DynamicLinqFilter(expression), cancellationToken); + } +} \ No newline at end of file diff --git a/src/Deveel.Repository.Manager.DynamicLinq/Deveel.Repository.Manager.DynamicLinq.csproj b/src/Deveel.Repository.Manager.DynamicLinq/Deveel.Repository.Manager.DynamicLinq.csproj new file mode 100644 index 0000000..191566b --- /dev/null +++ b/src/Deveel.Repository.Manager.DynamicLinq/Deveel.Repository.Manager.DynamicLinq.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + enable + enable + + + + + + + + diff --git a/src/Deveel.Repository.Manager/Data/EntityManager.cs b/src/Deveel.Repository.Manager/Data/EntityManager.cs index cb301a4..ead5a97 100644 --- a/src/Deveel.Repository.Manager/Data/EntityManager.cs +++ b/src/Deveel.Repository.Manager/Data/EntityManager.cs @@ -109,7 +109,8 @@ protected virtual CancellationToken CancellationToken public virtual bool IsMultiTenant { get { ThrowIfDisposed(); - return (Repository is IMultiTenantRepository); + return (Repository is IMultiTenantRepository multiTenant) && + !String.IsNullOrWhiteSpace(multiTenant.TenantId); } } @@ -243,6 +244,30 @@ private void LogEntityUnknownError(object? entityKey, Exception ex) { Logger.LogEntityUnknownError(typeof(TEntity), entityKey, ex); } + private void LogEntityNotFound(object? entityKey) { + Logger.LogEntityNotFound(typeof(TEntity), entityKey); + } + + /// + /// Ensures that a cancellation token is available + /// for a cancellable operation. + /// + /// + /// The token that was provided by the caller. + /// + /// + /// This method checks if the given cancellation token + /// passed to an operation is null, and if so, + /// attempts to resolve a cancellation token from the + /// context. + /// + /// + /// Returns the cancellation token to be used for + /// an operation. + /// + protected CancellationToken GetCancellationToken(CancellationToken? cancellationToken) + => cancellationToken ?? CancellationToken; + /// /// Checks if the service has been disposed and @@ -406,17 +431,20 @@ protected OperationResult ValidationFailed(string errorCode, IList /// The entity to be validated. /// + /// + /// A token used to cancel the validation operation. + /// /// /// Returns a list of that /// describe the validation errors. /// - protected virtual async Task> ValidateAsync(TEntity entity) { + protected virtual async Task> ValidateAsync(TEntity entity, CancellationToken cancellationToken) { if (EntityValidator == null) return new List(); var results = new List(); - await foreach(var result in EntityValidator.ValidateAsync(this, entity, CancellationToken)) { + await foreach(var result in EntityValidator.ValidateAsync(this, entity, cancellationToken)) { if (result != null) results.Add(result); } @@ -451,7 +479,7 @@ protected virtual async Task> ValidateAsync(TEntity enti /// The entity that is being added. /// /// - /// When is + /// When is /// invoked, this method is invoked for each entity in the /// range of entities. /// @@ -469,17 +497,22 @@ protected virtual Task OnAddingEntityAsync(TEntity entity) { /// /// The entity to be added. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns an instance of that /// describes the result of the operation. /// - public virtual async Task AddAsync(TEntity entity) { + public virtual async Task AddAsync(TEntity entity, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); try { - Logger.LogAddingEntity(); + Logger.LogAddingEntity(typeof(TEntity)); + + var token = GetCancellationToken(cancellationToken); - var validation = await ValidateAsync(entity); + var validation = await ValidateAsync(entity, token); if (validation != null && validation.Count > 0) { Logger.LogEntityNotValid(typeof(TEntity)); return ValidationFailed(EntityErrorCodes.NotValid, validation); @@ -487,7 +520,7 @@ public virtual async Task AddAsync(TEntity entity) { entity = await OnAddingEntityAsync(entity); - await Repository.AddAsync(entity, CancellationToken); + await Repository.AddAsync(entity, token); Logger.LogEntityAdded(GetEntityKey(entity)!); @@ -504,6 +537,9 @@ public virtual async Task AddAsync(TEntity entity) { /// /// The range of entities to be added. /// + /// + /// A token used to cancel the operation. + /// /// /// The default implementation of this method attempts to /// validate each entity in the range before adding it to @@ -515,22 +551,24 @@ public virtual async Task AddAsync(TEntity entity) { /// operation. /// /// - public virtual async Task AddRangeAsync(IEnumerable entities) { + public virtual async Task AddRangeAsync(IEnumerable entities, CancellationToken? cancellationToken = null) { try { - Logger.LogAddingEntityRange(); + Logger.LogAddingEntityRange(typeof(TEntity)); + + var token = GetCancellationToken(cancellationToken); var toBeAdded = new List(); foreach (var entity in entities) { var item = await OnAddingEntityAsync(entity); - var validation = await ValidateAsync(item); + var validation = await ValidateAsync(item, token); if (validation != null && validation.Count > 0) return ValidationFailed(EntityErrorCodes.NotValid, validation); toBeAdded.Add(item); } - await Repository.AddRangeAsync(toBeAdded, CancellationToken); + await Repository.AddRangeAsync(toBeAdded, token); Logger.LogEntityRangeAdded(); @@ -600,6 +638,9 @@ protected virtual bool AreEqual(TEntity existing, TEntity other) { /// /// The entity to be updated. /// + /// + /// A token used to cancel the operation. + /// /// /// /// The default implementation of this method first @@ -621,7 +662,7 @@ protected virtual bool AreEqual(TEntity existing, TEntity other) { /// Returns a result object that describes the result of the /// update operation. /// - public virtual async Task UpdateAsync(TEntity entity) { + public virtual async Task UpdateAsync(TEntity entity, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); var entityKey = GetEntityKey(entity); @@ -630,16 +671,22 @@ public virtual async Task UpdateAsync(TEntity entity) { if (entityKey == null) return Fail(EntityErrorCodes.NotValid, "The entity does not have a valid key"); - Logger.LogUpdatingEntity(entityKey); + Logger.LogUpdatingEntity(typeof(TEntity), entityKey); + + var token = GetCancellationToken(cancellationToken); - var existing = await FindByKeyAsync(entityKey); - if (existing == null) + var existing = await FindByKeyAsync(entityKey, token); + if (existing == null) { + LogEntityNotFound(entityKey); return Fail(EntityErrorCodes.NotFound, "The entity was not found in the repository"); + } - if (AreEqual(existing, entity)) + if (AreEqual(existing, entity)) { + Logger.LogEntityNotModified(typeof(TEntity), entityKey); return NotModified(); + } - var validation = await ValidateAsync(entity); + var validation = await ValidateAsync(entity, token); if (validation != null && validation.Count > 0) { Logger.LogEntityNotValid(typeof(TEntity)); return ValidationFailed(EntityErrorCodes.NotValid, validation); @@ -647,7 +694,7 @@ public virtual async Task UpdateAsync(TEntity entity) { entity = await OnUpdatingEntityAsync(entity); - if (!await Repository.UpdateAsync(entity, CancellationToken)) { + if (!await Repository.UpdateAsync(entity, token)) { Logger.LogEntityNotModified(typeof(TEntity), entityKey); return NotModified(); } @@ -667,6 +714,9 @@ public virtual async Task UpdateAsync(TEntity entity) { /// /// The entity to be removed. /// + /// + /// A token used to cancel the operation. + /// /// /// /// The default implementation of this method first tries @@ -684,7 +734,7 @@ public virtual async Task UpdateAsync(TEntity entity) { /// Returns an instance of that /// describes the result of the operation. /// - public virtual async Task RemoveAsync(TEntity entity) { + public virtual async Task RemoveAsync(TEntity entity, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); var entityKey = GetEntityKey(entity); @@ -693,14 +743,18 @@ public virtual async Task RemoveAsync(TEntity entity) { if (entityKey == null) return Fail(EntityErrorCodes.NotValid, "The entity does not have a valid key"); - Logger.LogRemovingEntity(entityKey); + var token = GetCancellationToken(cancellationToken); + + Logger.LogRemovingEntity(typeof(TEntity), entityKey); - var found = await FindByKeyAsync(entityKey); - if (found == null) + var found = await FindByKeyAsync(entityKey, token); + if (found == null) { + LogEntityNotFound(entityKey); return Fail(EntityErrorCodes.NotFound, "The entity was not found in the repository"); + } - if (!await Repository.RemoveAsync(found, CancellationToken)) { - Logger.LogEntityNotRemoved(entityKey); + if (!await Repository.RemoveAsync(found, token)) { + Logger.LogEntityNotRemoved(typeof(TEntity), entityKey); return NotModified(); } @@ -717,19 +771,24 @@ public virtual async Task RemoveAsync(TEntity entity) { /// /// The range of entities to be removed. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns an instance of that /// describes the result of the operation. /// - public virtual async Task RemoveRangeAsync(IEnumerable entities) { + public virtual async Task RemoveRangeAsync(IEnumerable entities, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); try { Logger.LogRemovingEntityRange(); + var token = GetCancellationToken(cancellationToken); + // TODO: should we check for the entities to be valid? - await Repository.RemoveRangeAsync(entities, CancellationToken); + await Repository.RemoveRangeAsync(entities, token); Logger.LogEntityRangeRemoved(); @@ -746,6 +805,9 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// /// The key of the entity to be found. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns an instance of that /// is identified by the given key, or null if no entity @@ -756,15 +818,16 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// // TODO: Is there any use case for using OperationResult here // instead of returning an entity? - public virtual async Task FindByKeyAsync(object key) { + public virtual async Task FindByKeyAsync(object key, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(key, nameof(key)); try { - // TODO: log this operation + Logger.LogFindingEntityByKey(typeof(TEntity), key); - return await Repository.FindByKeyAsync(key, CancellationToken); + // TODO: log if the entity was not found + return await Repository.FindByKeyAsync(key, GetCancellationToken(cancellationToken)); } catch (Exception ex) { LogEntityUnknownError(key, ex); throw new OperationException(EntityErrorCodes.UnknownError, "Could not look for the entity", ex); @@ -777,6 +840,9 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// /// The filter to be used to look for the entity. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns the first instance of that /// matches the given filter, or null if no entity was found. @@ -793,7 +859,7 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// // TODO: Is there any use case for using OperationResult here // instead of returning the entity? - public virtual async Task FindFirstAsync(IQueryFilter filter) { + public virtual async Task FindFirstAsync(IQueryFilter filter, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); if (!SupportsFilters) @@ -802,7 +868,9 @@ public virtual async Task RemoveRangeAsync(IEnumerable ArgumentNullException.ThrowIfNull(filter, nameof(filter)); try { - return await FilterableRepository.FindAsync(filter, CancellationToken); + Logger.LogFindingFirstEntityByQuery(typeof(TEntity)); + + return await FilterableRepository.FindAsync(filter, GetCancellationToken(cancellationToken)); } catch (Exception ex) { LogUnknownError(ex); throw new OperationException(EntityErrorCodes.UnknownError, "Could not look for the entity", ex); @@ -816,13 +884,16 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// /// The filter expression to be used to look for the entity. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns the first instance of that /// mathces the given filter, or null if no entity was found. /// - /// - public Task FindFirstAsync(Expression>? filter = null) - => FindFirstAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter)); + /// + public Task FindFirstAsync(Expression>? filter = null, CancellationToken? cancellationToken = null) + => FindFirstAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter), cancellationToken); /// /// Finds all the entities in the repository that match the given filter. @@ -830,6 +901,9 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// /// The filter to be used to look for the entities. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns a list of that match the /// given filter. @@ -842,16 +916,16 @@ public virtual async Task RemoveRangeAsync(IEnumerable /// // TODO: Is there any use case for using OperationResult> here // instead of returning a list of entities? - public virtual async Task> FindAllAsync(IQueryFilter filter) { + public virtual async Task> FindAllAsync(IQueryFilter filter, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); if (!SupportsFilters) throw new NotSupportedException("The repository does not support filters"); try { - // TODO: log this operation ... + Logger.LogFindingAllEntitiesByQuery(typeof(TEntity)); - return await FilterableRepository.FindAllAsync(filter, CancellationToken); + return await FilterableRepository.FindAllAsync(filter, GetCancellationToken(cancellationToken)); } catch (Exception ex) { LogUnknownError(ex); throw new OperationException(EntityErrorCodes.UnknownError, "Could not look for the entity", ex); @@ -865,8 +939,11 @@ public virtual async Task> FindAllAsync(IQueryFilter filter) { /// /// The filter expression to be used to look for the entities. /// + /// + /// A token used to cancel the operation. + /// /// - /// This method is a shortcut to the + /// This method is a shortcut to the /// using an instance of as /// argument. /// @@ -874,13 +951,13 @@ public virtual async Task> FindAllAsync(IQueryFilter filter) { /// Returns a list of that match the /// given filter. /// - /// + /// /// /// /// Thrown when the repository does not support filters. /// - public Task> FindAllAsync(Expression>? filter = null) - => FindAllAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter)); + public Task> FindAllAsync(Expression>? filter = null, CancellationToken? cancellationToken = null) + => FindAllAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter), cancellationToken); /// /// Counts the number of entities in the repository that match @@ -889,6 +966,9 @@ public Task> FindAllAsync(Expression>? filter /// /// The filter to be used to look for the entities. /// + /// + /// A token used to cancel the operation. + /// /// /// Returns the number of entities that match the given filter. /// @@ -898,7 +978,7 @@ public Task> FindAllAsync(Expression>? filter /// /// Thrown when an unknown error occurs while looking for the entities. /// - public virtual Task CountAsync(IQueryFilter filter) { + public virtual Task CountAsync(IQueryFilter filter, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); if (!SupportsFilters) @@ -907,7 +987,9 @@ public virtual Task CountAsync(IQueryFilter filter) { ArgumentNullException.ThrowIfNull(filter, nameof(filter)); try { - return FilterableRepository.CountAsync(filter, CancellationToken); + Logger.LogCountingEntities(typeof(TEntity)); + + return FilterableRepository.CountAsync(filter, GetCancellationToken(cancellationToken)); } catch (Exception ex) { LogUnknownError(ex); throw new OperationException(EntityErrorCodes.UnknownError, "Could not look for the entity", ex); @@ -921,9 +1003,12 @@ public virtual Task CountAsync(IQueryFilter filter) { /// /// The filter expression to be used to look for the entities. /// + /// + /// A token used to cancel the operation. + /// /// /// - /// This method is a shortcut to the + /// This method is a shortcut to the /// overload, using a as /// argument of the method. /// @@ -936,10 +1021,18 @@ public virtual Task CountAsync(IQueryFilter filter) { /// /// Returns the number of entities that match the given filter. /// - public Task CountAsync(Expression>? filter = null) - => CountAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter)); + public Task CountAsync(Expression>? filter = null, CancellationToken? cancellationToken = null) + => CountAsync(filter == null ? QueryFilter.Empty : QueryFilter.Where(filter), cancellationToken); - public virtual async Task> GetPageAsync(PageQuery query) { + /// + /// + /// + /// + /// + /// + /// + /// + public virtual async Task> GetPageAsync(PageQuery query, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); if (!SupportsPaging) @@ -948,7 +1041,7 @@ public virtual async Task> GetPageAsync(PageQuery q try { // TODO: log this operation - return await PageableRepository.GetPageAsync(query, CancellationToken); + return await PageableRepository.GetPageAsync(query, GetCancellationToken(cancellationToken)); } catch (Exception ex) { LogUnknownError(ex); throw new OperationException(EntityErrorCodes.UnknownError, "Could not look for the entity", ex); diff --git a/src/Deveel.Repository.Manager/Data/EntityManagerEventIds.cs b/src/Deveel.Repository.Manager/Data/EntityManagerEventIds.cs index 0212624..608e5c0 100644 --- a/src/Deveel.Repository.Manager/Data/EntityManagerEventIds.cs +++ b/src/Deveel.Repository.Manager/Data/EntityManagerEventIds.cs @@ -81,6 +81,31 @@ public static class EntityManagerEventIds { /// public const int RemovingEntityRange = 100106; + /// + /// Attempting to find an entity by key. + /// + public const int FindingEntityByKey = 100107; + + /// + /// Attempting to find the first entity by a query. + /// + public const int FindingFirstEntityByQuery = 100108; + + /// + /// Attempting to find all entities by a query. + /// + public const int FindingAllEntitiesByQuery = 100109; + + /// + /// Attempting to get a page of entities. + /// + public const int GettingEntityPage = 1001010; + + /// + /// Counting the number of entities in the repository. + /// + public const int CountingEntities = 1001011; + // Information /// diff --git a/src/Deveel.Repository.Manager/Data/LoggerExtensions.cs b/src/Deveel.Repository.Manager/Data/LoggerExtensions.cs index 9cd5cd0..0985e7f 100644 --- a/src/Deveel.Repository.Manager/Data/LoggerExtensions.cs +++ b/src/Deveel.Repository.Manager/Data/LoggerExtensions.cs @@ -30,28 +30,43 @@ static partial class LoggerExtensions { [LoggerMessage(EntityManagerEventIds.EntityNotModified, LogLevel.Warning, "The entity of type {EntityType} identified by {EntityId} was not modified during the operation.")] public static partial void LogEntityNotModified(this ILogger logger, Type entityType, object? entityId); - [LoggerMessage(EntityManagerEventIds.EntityNotFound, LogLevel.Warning, "The entity {EntityId} was not found in the repository.")] - public static partial void LogEntityNotFound(this ILogger logger, object? entityId); + [LoggerMessage(EntityManagerEventIds.EntityNotFound, LogLevel.Warning, "The entity of type {EntityType} identified by {EntityId} was not found in the repository.")] + public static partial void LogEntityNotFound(this ILogger logger, Type entityType, object? entityId); - [LoggerMessage(EntityManagerEventIds.EntityNotRemoved, LogLevel.Warning, "The entity {EntityId} was not removed from the repository.")] - public static partial void LogEntityNotRemoved(this ILogger logger, object? entityId); + [LoggerMessage(EntityManagerEventIds.EntityNotRemoved, LogLevel.Warning, "The entity of type {EntityType} identified by {EntityId} was not removed from the repository.")] + public static partial void LogEntityNotRemoved(this ILogger logger, Type entityType, object? entityId); // Debugs - [LoggerMessage(EntityManagerEventIds.AddingEntity, LogLevel.Debug, "An entity is being added to the repository.")] - public static partial void LogAddingEntity(this ILogger logger); + [LoggerMessage(EntityManagerEventIds.AddingEntity, LogLevel.Debug, "An entity of type {EntityType} is being added to the repository.")] + public static partial void LogAddingEntity(this ILogger logger, Type entityType); - [LoggerMessage(EntityManagerEventIds.UpdatingEntity, LogLevel.Debug, "The entity {EntityId} is being updated in the repository.")] - public static partial void LogUpdatingEntity(this ILogger logger, object? entityId); + [LoggerMessage(EntityManagerEventIds.UpdatingEntity, LogLevel.Debug, "The entity of type {EntityType} identified by {EntityId} is being updated in the repository.")] + public static partial void LogUpdatingEntity(this ILogger logger, Type entityType, object? entityId); - [LoggerMessage(EntityManagerEventIds.RemovingEntity, LogLevel.Debug, "The entity {EntityId} is being removed from the repository.")] - public static partial void LogRemovingEntity(this ILogger logger, object? entityId); + [LoggerMessage(EntityManagerEventIds.RemovingEntity, LogLevel.Debug, "The entity of tpye {EntityType} identified by {EntityId} is being removed from the repository.")] + public static partial void LogRemovingEntity(this ILogger logger, Type entityType, object? entityId); - [LoggerMessage(EntityManagerEventIds.AddingEntityRange, LogLevel.Debug, "A range of entities is being added to the repository.")] - public static partial void LogAddingEntityRange(this ILogger logger); + [LoggerMessage(EntityManagerEventIds.AddingEntityRange, LogLevel.Debug, "A range of entities of type {EntityType} is being added to the repository.")] + public static partial void LogAddingEntityRange(this ILogger logger, Type entityType); [LoggerMessage(EntityManagerEventIds.RemovingEntityRange, LogLevel.Debug, "A range of entities is being removed from the repository.")] public static partial void LogRemovingEntityRange(this ILogger logger); + [LoggerMessage(EntityManagerEventIds.FindingEntityByKey, LogLevel.Debug, "Attempting to find an entity of type {EntityType} using the key {EntityKey}")] + public static partial void LogFindingEntityByKey(this ILogger logger, Type entityType, object? entityKey); + + [LoggerMessage(EntityManagerEventIds.FindingFirstEntityByQuery, LogLevel.Debug, "Attempting to find an entity of type {EntityType} using a query filter")] + public static partial void LogFindingFirstEntityByQuery(this ILogger logger, Type entityType); + + [LoggerMessage(EntityManagerEventIds.FindingAllEntitiesByQuery, LogLevel.Debug, "Attempting to find a range of entities of type {EntityType} using a query filter")] + public static partial void LogFindingAllEntitiesByQuery(this ILogger logger, Type entityType); + + [LoggerMessage(EntityManagerEventIds.GettingEntityPage, LogLevel.Debug, "Page {PageNumber} of {PageSize} entities of type {EntityType} is being requested from the repository")] + public static partial void LogGettingEntityPage(this ILogger logger, Type entityType, int pageNumber, int pageSize); + + [LoggerMessage(EntityManagerEventIds.CountingEntities, LogLevel.Debug, "The count of entities of type {EntityType} is being requested from the repository")] + public static partial void LogCountingEntities(this ILogger logger, Type entityType); + // Information [LoggerMessage(EntityManagerEventIds.EntityAdded, LogLevel.Information, "The entity {EntityId} was added to the repository.")] diff --git a/src/Deveel.Repository.Manager/Deveel.Repository.Manager.csproj b/src/Deveel.Repository.Manager/Deveel.Repository.Manager.csproj index a7850c5..ff5a752 100644 --- a/src/Deveel.Repository.Manager/Deveel.Repository.Manager.csproj +++ b/src/Deveel.Repository.Manager/Deveel.Repository.Manager.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Deveel.Repository.Manager/EntityValidationError.cs b/src/Deveel.Repository.Manager/EntityValidationError.cs index 9b47e19..30bae6f 100644 --- a/src/Deveel.Repository.Manager/EntityValidationError.cs +++ b/src/Deveel.Repository.Manager/EntityValidationError.cs @@ -15,7 +15,21 @@ using System.ComponentModel.DataAnnotations; namespace Deveel { + /// + /// An implementation of that + /// describes a validation error. + /// public sealed class EntityValidationError : IValidationError { + /// + /// Constructs the error with the given error code and + /// the list of validation results. + /// + /// + /// The error code of the validation. + /// + /// + /// The list of validation results. + /// public EntityValidationError(string errorCode, IEnumerable? results = null) { ArgumentNullException.ThrowIfNull(errorCode, nameof(errorCode)); @@ -23,10 +37,12 @@ public EntityValidationError(string errorCode, IEnumerable? re ValidationResults = results?.ToList() ?? new List(); } + /// public string ErrorCode { get; } string? IOperationError.Message { get; } + /// public IReadOnlyList ValidationResults { get; } } } diff --git a/src/Deveel.Repository.Manager/HttpRequestCancellationSource.cs b/src/Deveel.Repository.Manager/HttpRequestCancellationSource.cs new file mode 100644 index 0000000..54c737e --- /dev/null +++ b/src/Deveel.Repository.Manager/HttpRequestCancellationSource.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace Deveel { + /// + /// An implementation of that + /// uses the token. + /// + /// + /// This implementation requires the to + /// be registered in the context of the application. + /// + public sealed class HttpRequestCancellationSource : IOperationCancellationSource { + private readonly IHttpContextAccessor httpContextAccessor; + + /// + /// Constructs the cancellation source using the given . + /// + /// + public HttpRequestCancellationSource(IHttpContextAccessor httpContextAccessor) { + this.httpContextAccessor = httpContextAccessor; + } + + /// + /// Gets the cancellation token from the current HTTP context. + /// + public CancellationToken Token => httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None; + } +} diff --git a/src/Deveel.Repository.Manager/OperationResult_1.cs b/src/Deveel.Repository.Manager/OperationResult_1.cs index d17a394..cc04b37 100644 --- a/src/Deveel.Repository.Manager/OperationResult_1.cs +++ b/src/Deveel.Repository.Manager/OperationResult_1.cs @@ -67,18 +67,65 @@ public OperationResult(IOperationError error) : this(OperationResultType.Error, /// public TValue? Value { get; } - public Task MapAsync(Func> action) - => action(Value); + /// + /// Maps a result to a new value asynchronously. + /// + /// + /// The function to use to map the value. + /// + /// + /// Returns a value that is the result of the mapping + /// from this result. + /// + public Task MapAsync(Func> mapper) => mapper(Value); - public Task MapAsync(Func action) - => action(Value); + /// + /// Maps a result to a new value. + /// + /// + /// The function to use to map the value. + /// + /// + /// Returns a value that is the result of the mapping + /// from this result. + /// + public TValue? Map(Func mapper) => mapper(Value); - public TValue? Map(Func action) - => action(Value); + /// + /// Handles the result of the operation asynchronously. + /// + /// + /// The function used to handle the result value. + /// + /// + /// Returns a task that will handle the result value. + /// + public Task HandleAsync(Func handler) => handler(Value); - public void Map(Action action) - => action(Value); + /// + /// Handles the result of the operation. + /// + /// + /// The function used to handle the result value. + /// + public void Handle(Action handler) => handler(Value); + /// + /// Maps the result of the operation to a new value, + /// using the provided functions to handle the result. + /// + /// + /// The function to use to map the value if the operation + /// was successful. + /// + /// + /// The function to use to map the value if the operation failed. + /// + /// + /// The function to use to map the value if the operation + /// caused no changes to the entity. + /// + /// public Task MapAsync(Func>? ifSuccess = null, Func>? ifFailed = null, Func>? ifNotModified = null) { return ResultType switch { OperationResultType.Success => ifSuccess?.Invoke(Value) ?? Task.FromResult(Value), diff --git a/src/Deveel.Repository.Manager/ServiceCollectionExtensions.cs b/src/Deveel.Repository.Manager/ServiceCollectionExtensions.cs index cf5fc51..28182e7 100644 --- a/src/Deveel.Repository.Manager/ServiceCollectionExtensions.cs +++ b/src/Deveel.Repository.Manager/ServiceCollectionExtensions.cs @@ -12,10 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Deveel { + /// + /// Provides methods to register entity management services + /// in a collection of services. + /// public static class ServiceCollectionExtensions { /// /// Registers a service in the @@ -40,6 +45,22 @@ public static IServiceCollection AddOperationErrorFactory(this IServic return services; } + /// + /// Registers the operation cancellation source in the + /// collection of services. + /// + /// + /// The type of the operation cancellation source to register. + /// + /// + /// The collection of services to register the source. + /// + /// + /// The desired lifetime of the cancellation source. + /// + /// + /// Returns the given collection of services for chaining calls. + /// public static IServiceCollection AddOperationTokenSource(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TSource : class, IOperationCancellationSource { services.TryAdd(new ServiceDescriptor(typeof(IOperationCancellationSource), typeof(TSource), lifetime)); @@ -47,5 +68,26 @@ public static IServiceCollection AddOperationTokenSource(this IServiceC return services; } + + /// + /// Registers a singleton instance of the + /// in the collection of services. + /// + /// + /// The collection of services to register the source. + /// + /// + /// This method also tries to register the + /// into the collection of services, if not already registered. + /// + /// + /// Returns the given collection of services for chaining calls. + /// + public static IServiceCollection AddHttpRequestTokenSource(this IServiceCollection services) { + services.AddHttpContextAccessor(); + services.AddOperationTokenSource(ServiceLifetime.Singleton); + + return services; + } } } diff --git a/test/Deveel.Repository.Manager.XUnit/Data/DependencyInjectionTests.cs b/test/Deveel.Repository.Manager.XUnit/Data/DependencyInjectionTests.cs index 6a890f2..d8b5849 100644 --- a/test/Deveel.Repository.Manager.XUnit/Data/DependencyInjectionTests.cs +++ b/test/Deveel.Repository.Manager.XUnit/Data/DependencyInjectionTests.cs @@ -20,6 +20,7 @@ public static void AddDefaultManager() { Assert.NotNull(manager); Assert.True(manager.SupportsPaging); Assert.True(manager.SupportsQueries); + Assert.False(manager.IsMultiTenant); } [Fact] @@ -38,6 +39,7 @@ public static void AddCustomManager() { Assert.NotNull(manager); Assert.True(manager.SupportsPaging); Assert.True(manager.SupportsQueries); + Assert.False(manager.IsMultiTenant); } [Fact] @@ -70,6 +72,20 @@ public static void AddOperationCancellationSource() { Assert.IsType(source); } + [Fact] + public static void AddHttpRequestCancellationSource() { + var services = new ServiceCollection(); + services.AddHttpRequestTokenSource(); + + var provider = services.BuildServiceProvider(); + + var source = provider.GetService(); + + Assert.NotNull(source); + Assert.IsType(source); + Assert.Equal(CancellationToken.None, source.Token); + } + #region NotEntityManager class NotEntityManager { diff --git a/test/Deveel.Repository.Manager.XUnit/Data/EntityManagerTestSuite_1.cs b/test/Deveel.Repository.Manager.XUnit/Data/EntityManagerTestSuite_1.cs index 99c045a..5be320f 100644 --- a/test/Deveel.Repository.Manager.XUnit/Data/EntityManagerTestSuite_1.cs +++ b/test/Deveel.Repository.Manager.XUnit/Data/EntityManagerTestSuite_1.cs @@ -291,6 +291,21 @@ public async Task FindFirstFiltered() { Assert.Equal(person.Id, found.Id); } + [Fact] + public async Task FindFirstDynamicLinq() { + var person = People + .Where(x => x.FirstName.StartsWith("A")) + .OrderBy(x => x.Id) + .FirstOrDefault(); + + Assert.NotNull(person); + + var found = await Manager.FindFirstAsync("FirstName.StartsWith(\"A\")"); + + Assert.NotNull(found); + Assert.Equal(person.Id, found.Id); + } + [Fact] public async Task FindFirst() { var person = People @@ -317,6 +332,29 @@ public async Task FindAllFiltered() { Assert.Equal(people.Count, found.Count); } + [Fact] + public async Task FindAllDynamicLinq() { + var people = People + .Where(x => x.FirstName.StartsWith("A")) + .ToList(); + + var found = await Manager.FindAllAsync("FirstName.StartsWith(\"A\")"); + + Assert.NotNull(found); + Assert.Equal(people.Count, found.Count); + } + + [Fact] + public async Task FindAll() { + var people = People + .ToList(); + + var found = await Manager.FindAllAsync(); + + Assert.NotNull(found); + Assert.Equal(people.Count, found.Count); + } + [Fact] public async Task CountAll() { var count = await Manager.CountAsync(); @@ -324,6 +362,13 @@ public async Task CountAll() { Assert.Equal(People.Count(), count); } + [Fact] + public async Task CountDynamicLinq() { + var count = await Manager.CountAsync("FirstName.StartsWith(\"A\")"); + + Assert.Equal(People.Count(x => x.FirstName.StartsWith("A")), count); + } + [Fact] public async Task CountFiltered() { var count = await Manager.CountAsync(x => x.FirstName.StartsWith("A")); @@ -355,6 +400,7 @@ public void QueryEntities() { public async Task GetSimplePage() { var totalPeople = People.Count(); var totalPages = (int)Math.Ceiling((double)totalPeople / 10); + var perPage = Math.Min(10, totalPeople); var query = new PageQuery(1, 10); var page = await Manager.GetPageAsync(query); @@ -365,7 +411,27 @@ public async Task GetSimplePage() { Assert.Equal(totalPages, page.TotalPages); Assert.Equal(totalPeople, page.TotalItems); Assert.NotNull(page.Items); - Assert.Equal(10, page.Items.Count); + Assert.Equal(perPage, page.Items.Count); } + + [Fact] + public async Task GetPage_DynamicLinqFiltered() { + var totalPeople = People.Count(x => x.FirstName.StartsWith("A")); + var totalPages = (int)Math.Ceiling((double)totalPeople / 10); + var perPage = Math.Min(10, totalPeople); + + var query = new PageQuery(1, 10) + .Where("FirstName.StartsWith(\"A\")"); + + var page = await Manager.GetPageAsync(query); + + Assert.NotNull(page); + Assert.Equal(1, page.Request.Page); + Assert.Equal(10, page.Request.Size); + Assert.Equal(totalPages, page.TotalPages); + Assert.Equal(totalPeople, page.TotalItems); + Assert.NotNull(page.Items); + Assert.Equal(perPage, page.Items.Count); + } } } diff --git a/test/Deveel.Repository.Manager.XUnit/Data/MyPersonManager.cs b/test/Deveel.Repository.Manager.XUnit/Data/MyPersonManager.cs index 8d8b0d6..bbd6953 100644 --- a/test/Deveel.Repository.Manager.XUnit/Data/MyPersonManager.cs +++ b/test/Deveel.Repository.Manager.XUnit/Data/MyPersonManager.cs @@ -8,5 +8,9 @@ public MyPersonManager(IRepository repository, ILoggerFactory? loggerFactory = null) : base(repository, validator, services, loggerFactory) { } + + public async Task FindByEmailAsync(string email, CancellationToken? cancellationToken = null) { + return await FindFirstAsync(x => x.Email == email, cancellationToken); + } } } diff --git a/test/Deveel.Repository.Manager.XUnit/Deveel.Repository.Manager.XUnit.csproj b/test/Deveel.Repository.Manager.XUnit/Deveel.Repository.Manager.XUnit.csproj index 11e5aa4..88c63d1 100644 --- a/test/Deveel.Repository.Manager.XUnit/Deveel.Repository.Manager.XUnit.csproj +++ b/test/Deveel.Repository.Manager.XUnit/Deveel.Repository.Manager.XUnit.csproj @@ -30,6 +30,7 @@ +