diff --git a/DotNetElements.sln b/DotNetElements.sln index db93d90..2846965 100644 --- a/DotNetElements.sln +++ b/DotNetElements.sln @@ -5,7 +5,9 @@ VisualStudioVersion = 17.8.34330.188 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNetElements.CrudExample", "samples\DotNetElements.CrudExample\DotNetElements.CrudExample.csproj", "{5841B4C6-1339-412F-97F4-D17C6E3D3D24}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetElements.Core", "src\DotNetElements.Core\DotNetElements.Core.csproj", "{1CEE4FCD-1A35-4365-A6A1-6F05937B4E3A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNetElements.Core", "src\DotNetElements.Core\DotNetElements.Core.csproj", "{1CEE4FCD-1A35-4365-A6A1-6F05937B4E3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetElements.Core.Test", "test\DotNetElements.Core.Test\DotNetElements.Core.Test.csproj", "{9BA47821-EBE3-4290-877B-FE75340AE33E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -21,6 +23,10 @@ Global {1CEE4FCD-1A35-4365-A6A1-6F05937B4E3A}.Debug|Any CPU.Build.0 = Debug|Any CPU {1CEE4FCD-1A35-4365-A6A1-6F05937B4E3A}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CEE4FCD-1A35-4365-A6A1-6F05937B4E3A}.Release|Any CPU.Build.0 = Release|Any CPU + {9BA47821-EBE3-4290-877B-FE75340AE33E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BA47821-EBE3-4290-877B-FE75340AE33E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BA47821-EBE3-4290-877B-FE75340AE33E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BA47821-EBE3-4290-877B-FE75340AE33E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d599b20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Felix-CodingClimber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcb49ee --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ + + + + + +[![Uptime Robot status](https://img.shields.io/uptimerobot/status/m796178913-32633fee88c8a4bfdc895a64?label=DOCs%20STATUS)](https://dotnet-elements.felixstrauss.dev/) + + +
+
+ + Logo + + +

DotNet Elements

+ +

+ Opinionated framework to build .NET applications fast and easy while focusing more on the final product and less on writing low-level code. +
+ Explore the docs » +
+
+ + Report Bug + · + Request Feature +

+
+ + + + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. + +
  3. License
  4. + +
+
+ + + + +## About The Project + +> [!CAUTION] +> Framework is work in progress and not considered production ready (while still used in some personal projects). Feel free to try it out and share your thoughts. + +

(back to top)

+ + + +### Built With + +[![NET][.NET]][.NET-url] + +

(back to top)

+ + + + + + + + + + + + + + + + + + + + +## License + +Distributed under the MIT License. See `LICENSE.txt` for more information. + +

(back to top)

+ + + + + + + + + + + + + +[.NET]: https://img.shields.io/badge/.NET-000000?style=for-the-badge&logo=dotnet&labelColor=512BD4 +[.NET-url]: https://dotnet.microsoft.com/en-us/ + + diff --git a/brand/Logo_Modified/Logo_Small.png b/brand/Logo_Modified/Logo_Small.png new file mode 100644 index 0000000..848307a Binary files /dev/null and b/brand/Logo_Modified/Logo_Small.png differ diff --git a/samples/DotNetElements.CrudExample/Components/Pages/Crud.razor b/samples/DotNetElements.CrudExample/Components/Pages/Crud.razor index 51e89c6..a08f037 100644 --- a/samples/DotNetElements.CrudExample/Components/Pages/Crud.razor +++ b/samples/DotNetElements.CrudExample/Components/Pages/Crud.razor @@ -208,11 +208,11 @@ else if (result.Canceled) return; - Result createdTag = await tagRepository.CreateAsync(editTagModel.MapToEntity()); + CrudResult createdTag = await tagRepository.CreateAsync(editTagModel.MapToEntity()); if (createdTag.IsFail) { - snackbar.Add("Failed to create tag", Severity.Error); + snackbar.Add($"Failed to create tag.\n{createdTag.Message}", Severity.Error); return; } @@ -238,11 +238,11 @@ else if (result.Canceled) return; - Result updatedTag = await tagRepository.UpdateAsync(editTagModel.Id, editTagModel); + CrudResult updatedTag = await tagRepository.UpdateAsync(editTagModel.Id, editTagModel); if (updatedTag.IsFail) { - snackbar.Add("Failed to update tag", Severity.Error); + snackbar.Add($"Failed to update tag.\n{updatedTag.Message}", Severity.Error); return; } @@ -259,11 +259,11 @@ else if (confirmResult.IsFail) return; - Result deleteResult = await tagRepository.DeleteAsync(tag.Id); + CrudResult deleteResult = await tagRepository.DeleteAsync(tag); if(deleteResult.IsFail) { - snackbar.Add("Failed to delete tag", Severity.Error); + snackbar.Add($"Failed to delete tag.\n{deleteResult.Message}", Severity.Error); return; } @@ -281,11 +281,11 @@ else return; } - Result details = await tagRepository.GetAuditedModelDetailsByIdAsync(context.Value.Id); + CrudResult details = await tagRepository.GetAuditedModelDetailsByIdAsync(context.Value.Id); if(details.IsFail) { - snackbar.Add("Failed to fetch tag details", Severity.Error); + snackbar.Add($"Failed to fetch tag details.\n{details.Message}", Severity.Error); return; } @@ -313,11 +313,11 @@ else if (result.Canceled) return; - Result createdBlogPost = await blogPostRepository.CreateAsync(editBlogPostModel.MapToEntity()); + CrudResult createdBlogPost = await blogPostRepository.CreateAsync(editBlogPostModel.MapToEntity()); if (createdBlogPost.IsFail) { - snackbar.Add("Failed to create blog post", Severity.Error); + snackbar.Add($"Failed to create blog post.\n{createdBlogPost.Message}", Severity.Error); return; } @@ -346,11 +346,11 @@ else if (result.Canceled) return; - Result updatedBlogPost = await blogPostRepository.UpdateAsync(editBlogPostModel.Id, editBlogPostModel); + CrudResult updatedBlogPost = await blogPostRepository.UpdateAsync(editBlogPostModel.Id, editBlogPostModel); if (updatedBlogPost.IsFail) { - snackbar.Add("Failed to update blog post", Severity.Error); + snackbar.Add($"Failed to update blog post.\n{updatedBlogPost.Message}", Severity.Error); return; } @@ -366,11 +366,11 @@ else if (confirmResult.IsFail) return; - Result deleteResult = await blogPostRepository.DeleteAsync(blogPost.Id); + CrudResult deleteResult = await blogPostRepository.DeleteAsync(blogPost); if(deleteResult.IsFail) { - snackbar.Add("Failed to delete blog post", Severity.Error); + snackbar.Add($"Failed to delete blog post.\n{deleteResult.Message}", Severity.Error); return; } @@ -387,11 +387,11 @@ else return; } - Result details = await blogPostRepository.GetAuditedModelDetailsByIdAsync(context.Value.Id); + CrudResult details = await blogPostRepository.GetAuditedModelDetailsByIdAsync(context.Value.Id); if(details.IsFail) { - snackbar.Add("Failed to fetch blog post details", Severity.Error); + snackbar.Add($"Failed to fetch blog post details.\n{details.Message}", Severity.Error); return; } diff --git a/samples/DotNetElements.CrudExample/Modules/BlogPostModule/BlogPostModule.cs b/samples/DotNetElements.CrudExample/Modules/BlogPostModule/BlogPostModule.cs index 904cd2b..05615c1 100644 --- a/samples/DotNetElements.CrudExample/Modules/BlogPostModule/BlogPostModule.cs +++ b/samples/DotNetElements.CrudExample/Modules/BlogPostModule/BlogPostModule.cs @@ -1,4 +1,6 @@ -namespace DotNetElements.CrudExample.Modules.BlogPostModule; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetElements.CrudExample.Modules.BlogPostModule; public sealed class BlogPostModule : IModule { @@ -16,25 +18,25 @@ public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) { endpoints.MapPut(BaseUrl, async (EditBlogPostModel blogPost, BlogPostRepository blogPostRepo) => { - Result result = await blogPostRepo.CreateAsync(blogPost.MapToEntity()); + CrudResult result = await blogPostRepo.CreateAsync(blogPost.MapToEntity()); - return result.IsOk ? Results.Ok(result.Value.MapToModel()) : Results.Conflict(result.Error); + return result.MapToHttpResultWithProjection(entity => entity.MapToModel()); }); endpoints.MapPost(BaseUrl, async (EditBlogPostModel blogPost, BlogPostRepository blogPostRepo) => { - Result result = await blogPostRepo.UpdateAsync(blogPost.Id, blogPost); + CrudResult result = await blogPostRepo.UpdateAsync(blogPost.Id, blogPost); - return result.IsOk ? Results.Ok(result.Value.MapToModel()) : Results.NotFound(result.Error); + return result.MapToHttpResultWithProjection(entity => entity.MapToModel()); }); - endpoints.MapDelete($"{BaseUrl}/{{id}}", async (Guid id, BlogPostRepository blogPostRepo) => + endpoints.MapDelete(BaseUrl, async ([FromBody] BlogPostModel blogPost, BlogPostRepository blogPostRepo) => { - Result result = await blogPostRepo.DeleteAsync(id); + CrudResult result = await blogPostRepo.DeleteAsync(blogPost); - return result.IsOk ? Results.Ok() : Results.NotFound(result.Error); + return result.MapToHttpResult(); }); @@ -46,9 +48,9 @@ public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) endpoints.MapGet($"{BaseUrl}/{{id}}", async (Guid id, BlogPostRepository blogPostRepo, CancellationToken cancellationToken) => { - Result result = await blogPostRepo.GetByIdWithProjectionAsync(id, query => query.MapToModel(), cancellationToken: cancellationToken); + CrudResult result = await blogPostRepo.GetByIdWithProjectionAsync(id, query => query.MapToModel(), cancellationToken: cancellationToken); - return result.IsOk ? Results.Ok(result.Value) : Results.NotFound(result.Error); + return result.MapToHttpResult(); }); return endpoints; diff --git a/samples/DotNetElements.CrudExample/Modules/TagModule/TagModule.cs b/samples/DotNetElements.CrudExample/Modules/TagModule/TagModule.cs index af44910..5370507 100644 --- a/samples/DotNetElements.CrudExample/Modules/TagModule/TagModule.cs +++ b/samples/DotNetElements.CrudExample/Modules/TagModule/TagModule.cs @@ -1,4 +1,6 @@ -namespace DotNetElements.CrudExample.Modules.TagModule; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetElements.CrudExample.Modules.TagModule; public sealed class TagModule : IModule { @@ -16,25 +18,25 @@ public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) { endpoints.MapPut(BaseUrl, async (EditTagModel tag, TagRepository tagRepo) => { - Result result = await tagRepo.CreateAsync(tag.MapToEntity(), entity => entity.Label == tag.Label); + CrudResult result = await tagRepo.CreateAsync(tag.MapToEntity(), entity => entity.Label == tag.Label); - return result.IsOk ? Results.Ok(result.Value.MapToModel()) : Results.Conflict(result.Error); + return result.MapToHttpResultWithProjection(entity => entity.MapToModel()); }); endpoints.MapPost(BaseUrl, async (EditTagModel tag, TagRepository tagRepo) => { - Result result = await tagRepo.UpdateAsync(tag.Id, tag); + CrudResult result = await tagRepo.UpdateAsync(tag.Id, tag); - return result.IsOk ? Results.Ok(result.Value.MapToModel()) : Results.NotFound(result.Error); + return result.MapToHttpResultWithProjection(entity => entity.MapToModel()); }); - endpoints.MapDelete($"{BaseUrl}/{{id}}", async (Guid id, TagRepository tagRepo) => + endpoints.MapDelete(BaseUrl, async ([FromBody] TagModel tag, TagRepository tagRepo) => { - Result result = await tagRepo.DeleteAsync(id); + CrudResult result = await tagRepo.DeleteAsync(tag); - return result.IsOk ? Results.Ok() : Results.NotFound(result.Error); + return result.MapToHttpResult(); }); @@ -46,9 +48,9 @@ public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) endpoints.MapGet($"{BaseUrl}/{{id}}", async (Guid id, TagRepository tagRepo, CancellationToken cancellationToken) => { - Result result = await tagRepo.GetByIdWithProjectionAsync(id, query => query.MapToModel(), cancellationToken: cancellationToken); + CrudResult result = await tagRepo.GetByIdWithProjectionAsync(id, query => query.MapToModel(), cancellationToken: cancellationToken); - return result.IsOk ? Results.Ok(result.Value) : Results.NotFound(result.Error); + return result.MapToHttpResult(); }); return endpoints; diff --git a/samples/DotNetElements.CrudExample/Program.cs b/samples/DotNetElements.CrudExample/Program.cs index d6256bc..4d8e9ad 100644 --- a/samples/DotNetElements.CrudExample/Program.cs +++ b/samples/DotNetElements.CrudExample/Program.cs @@ -3,7 +3,7 @@ using DotNetElements.CrudExample.Components; using DotNetElements.CrudExample.Modules.BlogPostModule; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents() @@ -21,7 +21,7 @@ builder.Services.RegisterModules(typeof(BlogPostModule).Assembly); -var app = builder.Build(); +WebApplication app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) diff --git a/samples/DotNetElements.CrudExample/TestDb.db b/samples/DotNetElements.CrudExample/TestDb.db index 5b3afd0..0b750a0 100644 Binary files a/samples/DotNetElements.CrudExample/TestDb.db and b/samples/DotNetElements.CrudExample/TestDb.db differ diff --git a/samples/DotNetElements.CrudExample/TestDb.db-shm b/samples/DotNetElements.CrudExample/TestDb.db-shm index 9e2a72b..93a2d2b 100644 Binary files a/samples/DotNetElements.CrudExample/TestDb.db-shm and b/samples/DotNetElements.CrudExample/TestDb.db-shm differ diff --git a/samples/DotNetElements.CrudExample/TestDb.db-wal b/samples/DotNetElements.CrudExample/TestDb.db-wal index aafff11..331d652 100644 Binary files a/samples/DotNetElements.CrudExample/TestDb.db-wal and b/samples/DotNetElements.CrudExample/TestDb.db-wal differ diff --git a/src/DotNetElements.Core/Core/IHasVersion.cs b/src/DotNetElements.Core/Core/Contracts.cs similarity index 85% rename from src/DotNetElements.Core/Core/IHasVersion.cs rename to src/DotNetElements.Core/Core/Contracts.cs index c7ab3a3..e9cc854 100644 --- a/src/DotNetElements.Core/Core/IHasVersion.cs +++ b/src/DotNetElements.Core/Core/Contracts.cs @@ -1,5 +1,13 @@ namespace DotNetElements.Core; +public interface IHasKey + where TKey : notnull +{ + TKey Id { get; } + + bool HasKey => !Id.Equals(default(TKey)); +} + public interface IHasVersionReadOnly { Guid Version { get; } diff --git a/src/DotNetElements.Core/Core/EntityBase.cs b/src/DotNetElements.Core/Core/EntityBase.cs index 6be31c4..e747dc3 100644 --- a/src/DotNetElements.Core/Core/EntityBase.cs +++ b/src/DotNetElements.Core/Core/EntityBase.cs @@ -2,22 +2,11 @@ namespace DotNetElements.Core; -public interface IEntity -{ - const string NoKey = "NOKEY"; - - abstract bool HasKey { get; } +public interface IEntity : IHasKey + where TKey : notnull; - string Key { get; } -} - -public interface IEntity : IEntity +public interface ICreationAuditedEntity : IEntity where TKey : notnull -{ - TKey Id { get; } -} - -public interface ICreationAuditedEntity : IEntity { Guid CreatorId { get; } @@ -26,7 +15,8 @@ public interface ICreationAuditedEntity : IEntity void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime); } -public interface IAuditedEntity : ICreationAuditedEntity +public interface IAuditedEntity : ICreationAuditedEntity + where TKey : notnull { Guid? LastModifierId { get; } @@ -76,13 +66,9 @@ public abstract class Entity : Entity, IEntity where TKey : notnull { public TKey Id { get; protected set; } = default!; - - public bool HasKey => !Id.Equals(default(TKey)); - - string IEntity.Key => HasKey ? Id.ToString()! : IEntity.NoKey; } -public class CreationAuditedEntity : Entity, ICreationAuditedEntity +public class CreationAuditedEntity : Entity, ICreationAuditedEntity where TKey : notnull { public Guid CreatorId { get; private set; } @@ -99,7 +85,7 @@ public void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime) } } -public class AuditedEntity : CreationAuditedEntity, IAuditedEntity +public class AuditedEntity : CreationAuditedEntity, IAuditedEntity where TKey : notnull { public Guid? LastModifierId { get; private set; } diff --git a/src/DotNetElements.Core/Core/IReadOnlyRepository.cs b/src/DotNetElements.Core/Core/IReadOnlyRepository.cs index 3c78a71..a766f44 100644 --- a/src/DotNetElements.Core/Core/IReadOnlyRepository.cs +++ b/src/DotNetElements.Core/Core/IReadOnlyRepository.cs @@ -1,13 +1,14 @@ using System.Linq.Expressions; namespace DotNetElements.Core; + public interface IReadOnlyRepository where TEntity : Entity where TKey : notnull { Task> GetAllAsync(CancellationToken cancellationToken = default); - Task> GetAllAsync( + Task> GetAllFilteredAsync( Expression>? filter = null, Expression>? orderBy = null, bool descending = true, @@ -39,20 +40,20 @@ Task> GetAllWithProjectionAsync( bool descending = true, CancellationToken cancellationToken = default); - Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default); - Task> GetByIdAsync( + Task> GetByIdFilteredAsync( TKey id, Expression>? filter = null, CancellationToken cancellationToken = default); - Task> GetByIdWithProjectionAsync( + Task> GetByIdWithProjectionAsync( TKey id, Expression, IQueryable>> selector, Expression>? filter = null, CancellationToken cancellationToken = default); - Task> GetAuditedModelDetailsByIdAsync( + Task> GetAuditedModelDetailsByIdAsync( TKey id, CancellationToken cancellationToken = default) where TAuditedEntity : AuditedEntity; diff --git a/src/DotNetElements.Core/Core/IRepository.cs b/src/DotNetElements.Core/Core/IRepository.cs index 3bd9980..7e4114b 100644 --- a/src/DotNetElements.Core/Core/IRepository.cs +++ b/src/DotNetElements.Core/Core/IRepository.cs @@ -1,24 +1,22 @@ using System.Linq.Expressions; namespace DotNetElements.Core; + public interface IRepository : IReadOnlyRepository where TEntity : Entity where TKey : notnull { Task ClearTable(); - Task> CreateAsync(TEntity entity, Expression>? checkDuplicate = null); + Task> CreateAsync(TEntity entity, Expression>? checkDuplicate = null); - Task> CreateOrUpdateAsync( - TKey id, - TSelf entity, - Expression>? checkDuplicate = null) + Task> CreateOrUpdateAsync(TKey id, TSelf entity, Expression>? checkDuplicate = null) where TSelf : Entity, IUpdatable; - Task DeleteAsync(TKey id); + Task DeleteAsync(TEntityToDelete entityToDelete) + where TEntityToDelete : IHasKey, IHasVersionReadOnly; - Task> UpdateAsync( - TKey id, - TFrom from) - where TUpdatableEntity : Entity, IUpdatable; + Task> UpdateAsync(TKey id, TFrom from) + where TUpdatableEntity : Entity, IUpdatable + where TFrom : notnull; } \ No newline at end of file diff --git a/src/DotNetElements.Core/Core/ManagedRepository.cs b/src/DotNetElements.Core/Core/ManagedRepository.cs index 63716f7..46f7d8e 100644 --- a/src/DotNetElements.Core/Core/ManagedRepository.cs +++ b/src/DotNetElements.Core/Core/ManagedRepository.cs @@ -14,14 +14,14 @@ public ManagedRepository(IScopedRepositoryFactory re this.repositoryFactory = repositoryFactory; } - public Task> CreateAsync(TEntity entity, Expression>? checkDuplicate = null) + public Task> CreateAsync(TEntity entity, Expression>? checkDuplicate = null) { using var repository = repositoryFactory.Create(); return repository.Inner.CreateAsync(entity, checkDuplicate); } - public Task> CreateOrUpdateAsync(TKey id, TSelf entity, Expression>? checkDuplicate = null) + public Task> CreateOrUpdateAsync(TKey id, TSelf entity, Expression>? checkDuplicate = null) where TSelf : Entity, IUpdatable { using var repository = repositoryFactory.Create(); @@ -29,11 +29,12 @@ public Task> CreateOrUpdateAsync(TKey id, TSelf entity, Exp return repository.Inner.CreateOrUpdateAsync(id, entity, checkDuplicate); } - public Task DeleteAsync(TKey id) + public Task DeleteAsync(TEntityToDelete entityToDelete) + where TEntityToDelete : IHasKey, IHasVersionReadOnly { using var repository = repositoryFactory.Create(); - return repository.Inner.DeleteAsync(id); + return repository.Inner.DeleteAsync(entityToDelete); } public Task> GetAllAsync(CancellationToken cancellationToken = default) @@ -43,7 +44,7 @@ public Task> GetAllAsync(CancellationToken cancellationTo return repository.Inner.GetAllAsync(cancellationToken); } - public Task> GetAllAsync( + public Task> GetAllFilteredAsync( Expression>? filter = null, Expression>? orderBy = null, bool descending = true, @@ -51,7 +52,7 @@ public Task> GetAllAsync( { using var repository = repositoryFactory.Create(); - return repository.Inner.GetAllAsync(filter, orderBy, descending, cancellationToken); + return repository.Inner.GetAllFilteredAsync(filter, orderBy, descending, cancellationToken); } public Task> GetAllPagedAsync( @@ -94,21 +95,21 @@ public Task> GetAllWithProjectionAsync( return repository.Inner.GetAllWithProjectionAsync(selector, filter, orderBy, descending, cancellationToken); } - public Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + public Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default) { using var repository = repositoryFactory.Create(); return repository.Inner.GetByIdAsync(id, cancellationToken); } - public Task> GetByIdAsync(TKey id, Expression>? filter = null, CancellationToken cancellationToken = default) + public Task> GetByIdFilteredAsync(TKey id, Expression>? filter = null, CancellationToken cancellationToken = default) { using var repository = repositoryFactory.Create(); - return repository.Inner.GetByIdAsync(id, filter, cancellationToken); + return repository.Inner.GetByIdFilteredAsync(id, filter, cancellationToken); } - public Task> GetByIdWithProjectionAsync( + public Task> GetByIdWithProjectionAsync( TKey id, Expression, IQueryable>> selector, Expression>? filter = null, @@ -119,8 +120,9 @@ public Task> GetByIdWithProjectionAsync( return repository.Inner.GetByIdWithProjectionAsync(id, selector, filter, cancellationToken); } - public Task> UpdateAsync(TKey id, TFrom from) + public Task> UpdateAsync(TKey id, TFrom from) where TUpdatableEntity : Entity, IUpdatable + where TFrom : notnull { using var repository = repositoryFactory.Create(); @@ -134,7 +136,7 @@ public Task ClearTable() return repository.Inner.ClearTable(); } - public Task> GetAuditedModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) + public Task> GetAuditedModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) where TAuditedEntity : AuditedEntity { using var repository = repositoryFactory.Create(); diff --git a/src/DotNetElements.Core/Core/ModelBase.cs b/src/DotNetElements.Core/Core/ModelBase.cs index 6f32a3d..77d01a0 100644 --- a/src/DotNetElements.Core/Core/ModelBase.cs +++ b/src/DotNetElements.Core/Core/ModelBase.cs @@ -1,10 +1,7 @@ namespace DotNetElements.Core; -public interface IModel - where TKey : notnull -{ - TKey Id { get; } -} +public interface IModel : IHasKey + where TKey : notnull; public abstract class Model : IModel where TKey : notnull @@ -44,9 +41,8 @@ protected VersionedEditModel(Guid version) : base(default!, version) { } protected VersionedEditModel(TKey id, Guid version) : base(id, version) { } } -public abstract class ModelDetails -{ -} +// todo interface? +public abstract class ModelDetails; public class CreationAuditedModelDetails : ModelDetails { diff --git a/src/DotNetElements.Core/Core/ReadOnlyRepository.cs b/src/DotNetElements.Core/Core/ReadOnlyRepository.cs index 09756fc..4c7b02d 100644 --- a/src/DotNetElements.Core/Core/ReadOnlyRepository.cs +++ b/src/DotNetElements.Core/Core/ReadOnlyRepository.cs @@ -25,17 +25,14 @@ public ReadOnlyRepository(TDbContext dbContext) Entities = dbContext.Set(); } - public virtual async Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + public virtual async Task> GetByIdAsync(TKey id, CancellationToken cancellationToken = default) { TEntity? entity = await Entities.AsNoTracking().FirstOrDefaultAsync(WithId(id), cancellationToken); - if (entity is null) - return Result.EntityNotFound(id); - - return Result.Ok(entity); + return CrudResult.OkIfNotNull(entity, CrudError.NotFound, id.ToString()); } - public async Task> GetByIdAsync( + public async Task> GetByIdFilteredAsync( TKey id, Expression>? filter = null, CancellationToken cancellationToken = default) @@ -47,13 +44,10 @@ public async Task> GetByIdAsync( TEntity? entity = await entityQuery.FirstOrDefaultAsync(WithId(id), cancellationToken); - if (entity is null) - return Result.EntityNotFound(id); - - return Result.Ok(entity); + return CrudResult.OkIfNotNull(entity, CrudError.NotFound, id.ToString()); } - public async Task> GetByIdWithProjectionAsync( + public async Task> GetByIdWithProjectionAsync( TKey id, Expression, IQueryable>> selector, Expression>? filter = null, @@ -68,10 +62,7 @@ public async Task> GetByIdWithProjectionAsync( TProjection? projectedEntity = await selector.Compile().Invoke(entityQuery.Where(WithId(id))).FirstOrDefaultAsync(cancellationToken); - if (projectedEntity is null) - return Result.EntityNotFound(id); - - return Result.Ok(projectedEntity); + return CrudResult.OkIfNotNull(projectedEntity, CrudError.NotFound, id.ToString()); } public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) @@ -79,7 +70,7 @@ public virtual async Task> GetAllAsync(CancellationToken return await Entities.AsNoTracking().ToListAsync(cancellationToken); } - public async Task> GetAllAsync( + public async Task> GetAllFilteredAsync( Expression>? filter = null, Expression>? orderBy = null, bool descending = true, @@ -149,7 +140,7 @@ public async Task> GetAllPagedWithProjectionAsync> GetAuditedModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) + public async Task> GetAuditedModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) where TAuditedEntity : AuditedEntity { DbSet localDbSet = DbContext.Set(); @@ -169,6 +160,6 @@ public async Task> GetAuditedModelDetailsByIdAsync> CreateAsync(TEntity entity, Expression>? checkDuplicate = null) + public virtual async Task> CreateAsync(TEntity entity, Expression>? checkDuplicate = null) { ArgumentNullException.ThrowIfNull(entity); @@ -26,11 +26,11 @@ public virtual async Task> CreateAsync(TEntity entity, Expressio bool isDuplicate = await Entities.AnyAsync(checkDuplicate); if (isDuplicate) - return Result.DuplicateEntity(); + return CrudResult.DuplicateEntry(); } // Set audit properties if needed - if (entity is ICreationAuditedEntity auditedEntity) + if (entity is ICreationAuditedEntity auditedEntity) auditedEntity.SetCreationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); var createdEntity = Entities.Attach(entity); @@ -40,7 +40,7 @@ public virtual async Task> CreateAsync(TEntity entity, Expressio return createdEntity.Entity; } - public virtual async Task> CreateOrUpdateAsync(TKey id, TSelf entity, Expression>? checkDuplicate = null) + public virtual async Task> CreateOrUpdateAsync(TKey id, TSelf entity, Expression>? checkDuplicate = null) where TSelf : Entity, IUpdatable { ArgumentNullException.ThrowIfNull(entity); @@ -53,11 +53,11 @@ public virtual async Task> CreateOrUpdateAsync(TKey id, TSe bool isDuplicate = await DbContext.Set().AnyAsync(checkDuplicate); if (isDuplicate) - return Result.DuplicateEntity(); + return CrudResult.DuplicateEntry(); } // Set audit properties if needed - if (entity is ICreationAuditedEntity auditedEntity) + if (entity is ICreationAuditedEntity auditedEntity) auditedEntity.SetCreationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); var createdEntity = DbContext.Set().Attach(entity); @@ -72,28 +72,29 @@ public virtual async Task> CreateOrUpdateAsync(TKey id, TSe TSelf? existingEntity = await DbContext.Set().FirstOrDefaultAsync(WithId(id)); if (existingEntity is null) - return Result.EntityNotFound(id); + return CrudResult.NotFound(id); entity.Update(entity, this); // Check if entity has changed and set audit properties if needed if (DbContext.ChangeTracker.HasChanges()) { - if (existingEntity is IAuditedEntity auditedEntity) + if (existingEntity is IAuditedEntity auditedEntity) auditedEntity.SetModificationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); - if (existingEntity is IHasVersion entityWithVersion) - entityWithVersion.Version = Guid.NewGuid(); + UpdateEntityVersion(existingEntity, entity); - await DbContext.SaveChangesAsync(); + if (!await SaveChangesWithVersionCheckAsync()) + return CrudResult.ConcurrencyConflict(); } return existingEntity; } } - public virtual async Task> UpdateAsync(TKey id, TFrom from) + public virtual async Task> UpdateAsync(TKey id, TFrom from) where TUpdatableEntity : Entity, IUpdatable + where TFrom : notnull { IQueryable query = DbContext.Set(); @@ -108,80 +109,62 @@ public virtual async Task> UpdateAsync(id)); if (existingEntity is null) - return Result.EntityNotFound(id); - - // todo version 1 not ideal but working. Improve IReadVersion - //if(from is IReadVersion readVersion && existingEntity is IHasVersion hasVersion) - //{ - // if (readVersion.Version != hasVersion.Version) - // throw new DbUpdateConcurrencyException(); - //} + return CrudResult.NotFound(id); existingEntity.Update(from, this); // Check if entity has changed and set audit properties if needed if (DbContext.ChangeTracker.HasChanges()) { - if (existingEntity is IAuditedEntity auditedEntity) + if (existingEntity is IAuditedEntity auditedEntity) auditedEntity.SetModificationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); - // todo needed for version 1 - //if (existingEntity is IHasVersion entityWithVersion) - // entityWithVersion.Version = Guid.NewGuid(); - - // todo version 2 not ideal but working. Improve IReadVersion - if (existingEntity is IHasVersion entityWithVersion) - { - entityWithVersion.Version = Guid.NewGuid(); - - // Set queried entities version to the version from the updating entity to detect weather or not the data has changed - // between getting the data in the first place and updating it now - if (from is IHasVersionReadOnly entityWithVersionReadOnly) - DbContext.Entry(entityWithVersion).OriginalValues[nameof(IHasVersion.Version)] = entityWithVersionReadOnly.Version; - } + UpdateEntityVersion(existingEntity, from); - await DbContext.SaveChangesAsync(); + if (!await SaveChangesWithVersionCheckAsync()) + return CrudResult.ConcurrencyConflict(); } return existingEntity; } - public virtual async Task DeleteAsync(TKey id) + // todo check if id and originalVersion is the right fit or if it would be better to get a entity as param + // Or consider to remove the default null value to force the user to be explicit + public virtual async Task DeleteAsync(TEntityToDelete entityToDelete) + where TEntityToDelete : IHasKey, IHasVersionReadOnly { - TEntity? entityToDelete = await Entities.FirstOrDefaultAsync(WithId(id)); + TEntity? existingEntity = await Entities.FirstOrDefaultAsync(WithId(entityToDelete.Id)); - if (entityToDelete is null) - return Result.EntityNotFound(id); + if (existingEntity is null) + return CrudResult.NotFound(entityToDelete.Id); - if (entityToDelete is IDeletionAuditedEntity deletionAuditedEntity) + if (existingEntity is IDeletionAuditedEntity deletionAuditedEntity) { deletionAuditedEntity.Delete(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); - if (entityToDelete is IHasVersion entityWithVersion) - entityWithVersion.Version = Guid.NewGuid(); + UpdateEntityVersion(existingEntity, entityToDelete.Version); } - else if (entityToDelete is IHasDeletionTime entityWithDeletionTime) + else if (existingEntity is IHasDeletionTime entityWithDeletionTime) { entityWithDeletionTime.Delete(TimeProvider.GetUtcNow()); - if (entityToDelete is IHasVersion entityWithVersion) - entityWithVersion.Version = Guid.NewGuid(); + UpdateEntityVersion(existingEntity, entityToDelete.Version); } - else if (entityToDelete is ISoftDelete softDeletableEntity) + else if (existingEntity is ISoftDelete softDeletableEntity) { softDeletableEntity.Delete(); - if (entityToDelete is IHasVersion entityWithVersion) - entityWithVersion.Version = Guid.NewGuid(); + UpdateEntityVersion(existingEntity, entityToDelete.Version); } else { - Entities.Remove(entityToDelete); + Entities.Remove(existingEntity); } - await DbContext.SaveChangesAsync(); + if (!await SaveChangesWithVersionCheckAsync()) + return CrudResult.ConcurrencyConflict(); - return Result.Ok(); + return CrudResult.Ok(); } public virtual async Task ClearTable() @@ -189,6 +172,7 @@ public virtual async Task ClearTable() await Entities.ExecuteDeleteAsync(); } + // todo protected would be better! public TRelatedEntity AttachById(TRelatedEntityKey id) where TRelatedEntity : Entity, IRelatedEntity where TRelatedEntityKey : notnull @@ -196,21 +180,68 @@ public TRelatedEntity AttachById(TRelatedEnti return DbContext.Set().Attach(TRelatedEntity.CreateRefById(id)).Entity; } - protected async Task HardDeleteAsync(TKey id, Expression>? canBeDeleted = null) + protected void UpdateEntityVersion(TTargetEntity entityFromDb, Guid? originalVersion) + where TTargetEntity : notnull + { + if (entityFromDb is IHasVersion entityWithVersion) + { + entityWithVersion.Version = Guid.NewGuid(); + + SetOriginalVersionQueried(entityFromDb, originalVersion); + } + } + + protected void UpdateEntityVersion(TTargetEntity entityFromDb, TSourceEntity updatedEntity) + where TTargetEntity : notnull + where TSourceEntity : notnull + { + if (entityFromDb is IHasVersion entityWithVersion) + { + entityWithVersion.Version = Guid.NewGuid(); + + if (updatedEntity is IHasVersionReadOnly entityWithVersionReadOnly) + SetOriginalVersionQueried(entityFromDb, entityWithVersionReadOnly.Version); + } + } + + // Set queried entities version to the version of the updating entity to detect weather or not the data has changed + // between getting the data in the first place and updating it now + protected void SetOriginalVersionQueried(TTargetEntity entityFromDb, Guid? originalVersion) + where TTargetEntity : notnull + { + if (originalVersion is not null) + DbContext.Entry(entityFromDb).OriginalValues[nameof(IHasVersion.Version)] = originalVersion; + } + + protected async Task HardDeleteAsync(TKey id, Expression>? canBeDeleted = null) where TSoftDeleteEntity : Entity, ISoftDelete { TSoftDeleteEntity? entityToDelete = await DbContext.Set().FirstOrDefaultAsync(WithId(id)); if (entityToDelete is null) - return Result.EntityNotFound(id); + return CrudResult.NotFound(id); if (canBeDeleted is not null && !canBeDeleted.Compile().Invoke(entityToDelete)) - return Result.Fail("Entity can not be deleted. It is still in use."); + return CrudResult.Fail("Entity can not be deleted. It is still in use."); DbContext.Set().Remove(entityToDelete); await DbContext.SaveChangesAsync(); - return Result.Ok(); + return CrudResult.Ok(); + } + + protected async Task SaveChangesWithVersionCheckAsync() + { + try + { + await DbContext.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + return false; + } + + return true; } } \ No newline at end of file diff --git a/src/DotNetElements.Core/Core/Result/CrudResult.cs b/src/DotNetElements.Core/Core/Result/CrudResult.cs new file mode 100644 index 0000000..7c7735e --- /dev/null +++ b/src/DotNetElements.Core/Core/Result/CrudResult.cs @@ -0,0 +1,128 @@ +namespace DotNetElements.Core; + +public enum CrudError +{ + Unknown, + NotFound, + DuplicateEntry, + ConcurrencyConflict, +} + +public readonly partial struct CrudResult : IResult +{ + public bool IsFail { get; } + public bool IsOk => !IsFail; + + public CrudError Error => IsFail ? error!.Value : throw new ResultOkException(); + private readonly CrudError? error; + + public string Message => IsFail ? message! : throw new ResultOkException(); + private readonly string? message; + + private CrudResult(bool isFail, CrudError? error, string? message) + { + IsFail = isFail; + this.error = error; + this.message = message; + } + + /// + /// Create a successful result + /// + public static CrudResult Ok() => new CrudResult(false, null, null); + + /// + /// Create a failed result with the given error type and message + /// + /// Describes the error type + /// Optional error message + /// + public static CrudResult Fail(string? message = null) => new CrudResult(true, CrudError.Unknown, message); + + internal static CrudResult Fail_Internal(CrudError error, string? message = null) => new CrudResult(true, error, message); + + public static CrudResult DuplicateEntry() + => new CrudResult(true, CrudError.DuplicateEntry, "A similar entry does already exist."); + + public static CrudResult DuplicateEntry(TValue duplicateValue) + => new CrudResult(true, CrudError.DuplicateEntry, $"Entry with value {duplicateValue} does already exist."); + + public static CrudResult NotFound(TKey id) + where TKey : notnull + => new CrudResult(true, CrudError.DuplicateEntry, id.ToString()); + + public static CrudResult ConcurrencyConflict() + => new CrudResult(true, CrudError.DuplicateEntry, "Entry was changed, check updated values."); + + /// + /// Create a successful result + /// Helper to construct a CrudResult without the need to explicit define the generic T + /// + /// Type of the return value + /// Return value + /// + public static CrudResult Ok(T value) => new CrudResult(false, null, null, value); + + internal static CrudResult Fail_Internal(CrudError error, string? message = null) => new CrudResult(true, error, message, default); + + public override string ToString() => IsFail ? $"Failure. Error: {Error}" : $"Success"; +} + +public readonly partial struct CrudResult +{ + public bool IsFail { get; } + public bool IsOk => !IsFail; + + public CrudError Error => IsFail ? error!.Value : throw new ResultOkException(); + private readonly CrudError? error; + + public string Message => IsFail ? message! : throw new ResultOkException(); + private readonly string? message; + + public T Value => IsOk ? value! : throw new ResultFailException(error!.Value.ToString()); + private readonly T? value; + + // A result should be constructed using the static CrudResult.Ok and CrudResult.Fail methods + internal CrudResult(bool isFail, CrudError? error, string? message, T? value) + { + IsFail = isFail; + this.error = error; + this.message = message; + this.value = value; + } + + // Implicit cast from generic value (if given value is also a result, returns a copy) + public static implicit operator CrudResult(T value) + { + if (value is CrudResult result) + { + CrudError? resultError = result.IsFail ? result.Error : null; + string? resultMessage = result.IsFail ? result.Message : null; + T? resultValue = result.IsOk ? result.Value : default; + + return new CrudResult(result.IsFail, resultError, resultMessage, resultValue); + } + + return CrudResult.Ok(value); + } + + // Implicit cast to the non generic result version + public static implicit operator CrudResult(CrudResult result) + { + if (result.IsOk) + return CrudResult.Ok(); + else + return CrudResult.Fail_Internal(result.Error, result.Message); + } + + // Implicit cast from the generic result version + public static implicit operator CrudResult(CrudResult result) + { + if (result.IsOk) + throw new ResultOkException("Can not convert from a CrudResult.Ok to a CrudResult.Ok"); + else + return CrudResult.Fail_Internal(result.Error, result.Message); + } + + public override string ToString() => IsFail ? $"Failed to return {typeof(T)}. Error: {Error}" : $"Successfully returned {typeof(T)} with value {Value}"; +} diff --git a/src/DotNetElements.Core/Core/Result/CrudResultExtensions.cs b/src/DotNetElements.Core/Core/Result/CrudResultExtensions.cs new file mode 100644 index 0000000..63b440e --- /dev/null +++ b/src/DotNetElements.Core/Core/Result/CrudResultExtensions.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using Microsoft.AspNetCore.Http; +using IHttpResult = Microsoft.AspNetCore.Http.IResult; + +namespace DotNetElements.Core; + +public static class CrudResultExtensions +{ + public static IHttpResult MapToHttpResult(this CrudResult crudResult) + { + if (crudResult.IsOk) + return Results.Ok(); + + return MapToFailedHttpResult(crudResult.Error, crudResult.Message); + } + + public static IHttpResult MapToHttpResultWithProjection(this CrudResult crudResult, Expression> projection) + { + if (crudResult.IsOk) + return Results.Ok(projection.Compile().Invoke(crudResult.Value)); + + return MapToFailedHttpResult(crudResult.Error, crudResult.Message); + } + + public static IHttpResult MapToHttpResult(this CrudResult crudResult) + { + if (crudResult.IsOk) + return Results.Ok(crudResult.Value); + + return MapToFailedHttpResult(crudResult.Error, crudResult.Message); + } + + private static IHttpResult MapToFailedHttpResult(CrudError error, string? message) + { + return error switch + { + CrudError.Unknown => Results.Problem(detail: message), + CrudError.NotFound => Results.NotFound(message), + CrudError.DuplicateEntry => Results.Conflict(message), + CrudError.ConcurrencyConflict => Results.Conflict(message), + _ => throw new NotImplementedException() + }; + } +} diff --git a/src/DotNetElements.Core/Core/Result/CrudResultHelper.cs b/src/DotNetElements.Core/Core/Result/CrudResultHelper.cs new file mode 100644 index 0000000..f6cb8ee --- /dev/null +++ b/src/DotNetElements.Core/Core/Result/CrudResultHelper.cs @@ -0,0 +1,64 @@ +namespace DotNetElements.Core; + +public partial struct CrudResult +{ + /// + /// Creates a result whose success/failure reflects the supplied condition. + /// + public static CrudResult OkIf(bool isSuccess, CrudError error, string? message = null) + { + return isSuccess ? Ok() : Fail_Internal(error, message); + } + + /// + /// Creates a result whose success/failure depends on the supplied predicate. + /// + public static CrudResult OkIf(Func predicate, CrudError error, string? message = null) + { + return OkIf(predicate(), error); + } + + /// + /// Creates a result whose success/failure reflects the supplied condition. + /// + public static CrudResult OkIf(bool isSuccess, T value, CrudError error, string? message = null) + { + return isSuccess ? Ok(value) : Fail_Internal(error, message); + } + + /// + /// Creates a result whose success/failure depends on the supplied predicate. + /// + public static CrudResult OkIf(Func predicate, T value, CrudError error, string? message = null) + { + return OkIf(predicate(), value, error, message); + } + + /// + /// Creates a result whose success/failure depends on weather the value is null or not. + /// + public static CrudResult OkIfNotNull(T? value, CrudError error, string? message = null) + { + return OkIf(value is not null, value!, error, message); + } + + /// + /// Creates a result whose success/failure depends on the supplied predicate. + /// + public static async Task OkIfAsync(Func> predicate, CrudError error, string? message = null) + { + bool isSuccess = await predicate(); + + return OkIf(isSuccess, error, message); + } + + /// + /// Creates a result whose success/failure depends on the supplied predicate. + /// + public static async Task> OkIfAsync(Func> predicate, T value, CrudError error, string? message = null) + { + bool isSuccess = await predicate(); + + return OkIf(isSuccess, value, error, message); + } +} diff --git a/src/DotNetElements.Core/Core/Result/Result.cs b/src/DotNetElements.Core/Core/Result/Result.cs index 707ad7b..c1d317f 100644 --- a/src/DotNetElements.Core/Core/Result/Result.cs +++ b/src/DotNetElements.Core/Core/Result/Result.cs @@ -64,7 +64,7 @@ private Result(bool isFail, string? error) public static Result Ok(T value) => new Result(false, null, value); // Create a failed result with the given error - public static Result Fail(string error = "See log file for more info") => new Result(true, error, default); + internal static Result Fail_Internal(string error) => new Result(true, error, default); // Create a failed result from another failed result public static Result Fail(Result failedResult) @@ -128,7 +128,7 @@ public static implicit operator Result(Result result) if (result.IsOk) throw new ResultOkException("Can not convert from a Result.Ok to a Result.Ok"); else - return Result.Fail(result.Error); + return Result.Fail_Internal(result.Error); } public override string ToString() => IsFail ? $"Failed to return {typeof(T)}. Error: {Error}" : $"Successfully returned {typeof(T)} with value {Value}"; diff --git a/src/DotNetElements.Core/Core/Result/ResultHelper.cs b/src/DotNetElements.Core/Core/Result/ResultHelper.cs index 88ac739..cb177ce 100644 --- a/src/DotNetElements.Core/Core/Result/ResultHelper.cs +++ b/src/DotNetElements.Core/Core/Result/ResultHelper.cs @@ -23,7 +23,7 @@ public static Result OkIf(Func predicate, string error) /// public static Result OkIf(bool isSuccess, T value, string error) { - return isSuccess ? Ok(value) : Fail(error); + return isSuccess ? Ok(value) : Fail_Internal(error); } /// diff --git a/test/DotNetElements.Core.Test/DotNetElements.Core.Test.csproj b/test/DotNetElements.Core.Test/DotNetElements.Core.Test.csproj new file mode 100644 index 0000000..813d7ee --- /dev/null +++ b/test/DotNetElements.Core.Test/DotNetElements.Core.Test.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + true + + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/test/DotNetElements.Core.Test/GlobalUsings.cs b/test/DotNetElements.Core.Test/GlobalUsings.cs new file mode 100644 index 0000000..2ed9e37 --- /dev/null +++ b/test/DotNetElements.Core.Test/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System.ComponentModel.DataAnnotations; + +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Microsoft.EntityFrameworkCore; + +global using FluentAssertions; + +global using DotNetElements.Core; +global using DotNetElements.Core.Test.Utils; \ No newline at end of file diff --git a/test/DotNetElements.Core.Test/ReadOnlyRespositoryTest.cs b/test/DotNetElements.Core.Test/ReadOnlyRespositoryTest.cs new file mode 100644 index 0000000..7d82d0f --- /dev/null +++ b/test/DotNetElements.Core.Test/ReadOnlyRespositoryTest.cs @@ -0,0 +1,34 @@ +using DotNetElements.Core.Test.TestData; + +namespace DotNetElements.Core.Test; + +[TestClass] +public class ReadOnlyRepositoryTest +{ + [TestMethod] + public async Task CreateAsync_SingleEntity_ReturnsValidEntityWithId() + { + using FakeRepositoryFactory factory = new(); + + using (FakeTagRepository tagRepo = factory.CreateRepository()) + { + CrudResult result = await tagRepo.CreateAsync(FakeEntities.TagOne); + } + + using (FakeTagRepository tagRepo = factory.CreateRepository()) + { + IReadOnlyList result = await tagRepo.GetAllAsync(); + + result.Count.Should().Be(1); + + result[0].Should().BeEquivalentTo(FakeEntities.TagOne, + options => options + .Excluding(entity => entity.Id) + .Excluding(entity => entity.CreatorId) + .Excluding(entity => entity.CreationTime)); + + result[0].Id.Should().NotBeEmpty(); + result[0].CreatorId.Should().Be(FakeCurrentUserProvider.); + } + } +} \ No newline at end of file diff --git a/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPost.cs b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPost.cs new file mode 100644 index 0000000..6b36b27 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPost.cs @@ -0,0 +1,43 @@ +namespace DotNetElements.Core.Test.TestData; + +[RelatedEntities([nameof(Tags)])] +public class BlogPost : AuditedEntity, IUpdatable, IHasVersion +{ + [SQLStringColumn(Length = 256)] + public string Title { get; private set; } + + private List tags = default!; + + [BackingField(nameof(tags))] + public IReadOnlyList Tags => tags; + + [ConcurrencyCheck] + public Guid Version { get; set; } + + public BlogPost(string title, List tags) + { + Title = title; + this.tags = tags; + } + + public BlogPost(Guid id, string title, List tags, Guid version) + { + Id = id; + Title = title; + this.tags = tags; + Version = version; + } + +#nullable disable + private BlogPost() { } +#nullable enable + + public void Update(EditBlogPostModel from, IAttachRelatedEntity attachRelatedEntity) + { + ArgumentNullException.ThrowIfNull(from); + + Title = from.Title; + + EntityHelper.UpdateRelatedEntities(tags, from.Tags, attachRelatedEntity); + } +} diff --git a/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostModel.cs b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostModel.cs new file mode 100644 index 0000000..2fe053a --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostModel.cs @@ -0,0 +1,32 @@ +namespace DotNetElements.Core.Test.TestData; + +public class BlogPostModel : VersionedModel +{ + public string Title { get; private init; } + public IReadOnlyList Tags { get; private init; } + + public BlogPostModel(Guid id, string title, IReadOnlyList tags, Guid version) : base(id, version) + { + Title = title; + Tags = tags; + } +} + +public class EditBlogPostModel : VersionedEditModel +{ + public string Title { get; set; } + public List Tags { get; set; } + +#nullable disable + public EditBlogPostModel() : base(Guid.NewGuid()) + { + Tags = []; + } +#nullable enable + + public EditBlogPostModel(BlogPostModel blogPost) : base(blogPost.Id, blogPost.Version) + { + Title = blogPost.Title; + Tags = [.. blogPost.Tags]; + } +} diff --git a/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostRepository.cs b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostRepository.cs new file mode 100644 index 0000000..0342dbe --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/BlogPostModule/BlogPostRepository.cs @@ -0,0 +1,9 @@ +namespace DotNetElements.Core.Test.TestData; + +public class BlogPostRepository : Repository +{ + public BlogPostRepository(TestDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) + : base(dbContext, currentUserProvider, timeProvider) + { + } +} diff --git a/test/DotNetElements.Core.Test/TestData/BlogPostModule/ManagedBlogPostRepository.cs b/test/DotNetElements.Core.Test/TestData/BlogPostModule/ManagedBlogPostRepository.cs new file mode 100644 index 0000000..09f0719 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/BlogPostModule/ManagedBlogPostRepository.cs @@ -0,0 +1,9 @@ +namespace DotNetElements.Core.Test.TestData; + +public class ManagedBlogPostRepository : ManagedRepository +{ + public ManagedBlogPostRepository(IScopedRepositoryFactory repositoryFactory) + : base(repositoryFactory) + { + } +} diff --git a/test/DotNetElements.Core.Test/TestData/BlogPostModule/MapperExtensions.cs b/test/DotNetElements.Core.Test/TestData/BlogPostModule/MapperExtensions.cs new file mode 100644 index 0000000..8ac83b9 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/BlogPostModule/MapperExtensions.cs @@ -0,0 +1,48 @@ +namespace DotNetElements.Core.Test.TestData; + +public static partial class MapperExtensions +{ + public static BlogPost MapToEntity(this EditBlogPostModel model) + { + return new BlogPost + ( + model.Id, + model.Title, + model.Tags.Select(tag => new Tag(tag.Id)).ToList(), + model.Version + ); + } + + public static BlogPostModel MapToModel(this BlogPost entity) + { + return new BlogPostModel + ( + entity.Id, + entity.Title, + entity.Tags.Select(tag => tag.MapToModel()).ToList(), + entity.Version + ); + } + + public static IQueryable MapToModel(this IQueryable query) + { + return Queryable.Select(query, entity => new BlogPostModel + ( + entity.Id, + entity.Title, + Enumerable.ToList(Enumerable.Select(entity.Tags, tag => new TagModel(tag.Id, tag.Label, tag.Version))), + entity.Version + )); + } + + public static IQueryable> MapToModelWithDetails(this IQueryable query) + { + return Queryable.Select(query, entity => new ModelWithDetails(new BlogPostModel + ( + entity.Id, + entity.Title, + Enumerable.ToList(Enumerable.Select(entity.Tags, tag => new TagModel(tag.Id, tag.Label, tag.Version))), + entity.Version + ))); + } +} diff --git a/test/DotNetElements.Core.Test/TestData/FakeEntities.cs b/test/DotNetElements.Core.Test/TestData/FakeEntities.cs new file mode 100644 index 0000000..fb60880 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/FakeEntities.cs @@ -0,0 +1,12 @@ +namespace DotNetElements.Core.Test.TestData; + +internal static class FakeEntities +{ + public static Tag TagOne => new Tag("Test Tag 1"); + public static Tag TagTwo => new Tag("Test Tag 2"); + public static Tag TagThree => new Tag("Test Tag 3"); + + public static BlogPost BlogPostOne => new BlogPost("Test BlogPost 1", []); + public static BlogPost BlogPostTwo => new BlogPost("Test BlogPost 2", []); + public static BlogPost BlogPostThree => new BlogPost("Test BlogPost 3", []); +} diff --git a/test/DotNetElements.Core.Test/TestData/TagModule/FakeTagRepository.cs b/test/DotNetElements.Core.Test/TestData/TagModule/FakeTagRepository.cs new file mode 100644 index 0000000..34adcc8 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TagModule/FakeTagRepository.cs @@ -0,0 +1,14 @@ +namespace DotNetElements.Core.Test.TestData; + +internal class FakeTagRepository : FakeRepository +{ + public FakeTagRepository(TestDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) + : base(dbContext, currentUserProvider, timeProvider) + { + } + + public FakeTagRepository(TestDbContext dbContext) + : base(dbContext) + { + } +} diff --git a/test/DotNetElements.Core.Test/TestData/TagModule/ManagedTagRepository.cs b/test/DotNetElements.Core.Test/TestData/TagModule/ManagedTagRepository.cs new file mode 100644 index 0000000..938bdc9 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TagModule/ManagedTagRepository.cs @@ -0,0 +1,9 @@ +namespace DotNetElements.Core.Test.TestData; + +internal class ManagedTagRepository : ManagedRepository +{ + public ManagedTagRepository(IScopedRepositoryFactory repositoryFactory) + : base(repositoryFactory) + { + } +} diff --git a/test/DotNetElements.Core.Test/TestData/TagModule/MapperExtensions.cs b/test/DotNetElements.Core.Test/TestData/TagModule/MapperExtensions.cs new file mode 100644 index 0000000..97b3aa2 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TagModule/MapperExtensions.cs @@ -0,0 +1,44 @@ +namespace DotNetElements.Core.Test.TestData; + +public static partial class MapperExtensions +{ + public static Tag MapToEntity(this EditTagModel model) + { + return new Tag + ( + model.Id, + model.Label, + model.Version + ); + } + + public static TagModel MapToModel(this Tag entity) + { + return new TagModel + ( + entity.Id, + entity.Label, + entity.Version + ); + } + + public static IQueryable MapToModel(this IQueryable query) + { + return Queryable.Select(query, entity => new TagModel + ( + entity.Id, + entity.Label, + entity.Version + )); + } + + public static IQueryable> MapToModelWithDetails(this IQueryable query) + { + return Queryable.Select(query, entity => new ModelWithDetails(new TagModel + ( + entity.Id, + entity.Label, + entity.Version + ))); + } +} diff --git a/test/DotNetElements.Core.Test/TestData/TagModule/Tag.cs b/test/DotNetElements.Core.Test/TestData/TagModule/Tag.cs new file mode 100644 index 0000000..81db05e --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TagModule/Tag.cs @@ -0,0 +1,48 @@ +namespace DotNetElements.Core.Test.TestData; + +public class Tag : AuditedEntity, IUpdatable, IRelatedEntity, IHasVersion +{ + [SQLStringColumn(Length = 256)] + public string Label { get; private set; } + + private readonly List blogPosts = default!; + + [BackingField(nameof(blogPosts))] + public IReadOnlyList BlogPosts => blogPosts; + + [ConcurrencyCheck] + public Guid Version { get; set; } + + public Tag(string label) + { + Label = label; + } + + public Tag(Guid id, string label, Guid version) + { + Id = id; + Label = label; + Version = version; + } + +#nullable disable + public Tag(Guid id) + { + Id = id; + } + + private Tag() { } +#nullable enable + + public void Update(EditTagModel from, IAttachRelatedEntity _) + { + ArgumentNullException.ThrowIfNull(from); + + Label = from.Label; + } + + public static Tag CreateRefById(Guid id) + { + return new Tag(id); + } +} diff --git a/test/DotNetElements.Core.Test/TestData/TagModule/TagModel.cs b/test/DotNetElements.Core.Test/TestData/TagModule/TagModel.cs new file mode 100644 index 0000000..36f4a9b --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TagModule/TagModel.cs @@ -0,0 +1,27 @@ +namespace DotNetElements.Core.Test.TestData; + +public class TagModel : VersionedModel +{ + public string Label { get; private init; } + + public TagModel(Guid id, string label, Guid version) : base(id, version) + { + Label = label; + } + + public override string ToString() => Label; +} + +public class EditTagModel : VersionedEditModel +{ + public string Label { get; set; } + +#nullable disable + public EditTagModel() : base(Guid.NewGuid()) { } +#nullable enable + + public EditTagModel(TagModel tag) : base(tag.Id, tag.Version) + { + Label = tag.Label; + } +} diff --git a/test/DotNetElements.Core.Test/TestData/TestDbContext.cs b/test/DotNetElements.Core.Test/TestData/TestDbContext.cs new file mode 100644 index 0000000..296fe32 --- /dev/null +++ b/test/DotNetElements.Core.Test/TestData/TestDbContext.cs @@ -0,0 +1,13 @@ +namespace DotNetElements.Core.Test.TestData; + +public class TestDbContext : DbContext +{ + public DbSet BlogPosts { get; set; } + + public DbSet Tags { get; set; } + + public TestDbContext(DbContextOptions options) : base(options) + { + + } +} diff --git a/test/DotNetElements.Core.Test/Utils/FakeCurrentUserProvider.cs b/test/DotNetElements.Core.Test/Utils/FakeCurrentUserProvider.cs new file mode 100644 index 0000000..365f054 --- /dev/null +++ b/test/DotNetElements.Core.Test/Utils/FakeCurrentUserProvider.cs @@ -0,0 +1,14 @@ + +namespace DotNetElements.Core.Test.Utils; + +internal class FakeCurrentUserProvider : ICurrentUserProvider +{ + public static readonly Guid FakeUserIdOne = new Guid("DC0BA927-FBAE-4DCA-8BAE-C1C70CBB948D"); + public static readonly Guid FakeUserIdTwo = new Guid("65FA2034-6544-43E3-AF5C-DF311AE1B076"); + + private Guid currentUser = FakeUserIdOne; + + public void SetCurrentUserId(Guid user) => currentUser = user; + + public Guid GetCurrentUserId() => currentUser; +} diff --git a/test/DotNetElements.Core.Test/Utils/FakeDbContextFactory.cs b/test/DotNetElements.Core.Test/Utils/FakeDbContextFactory.cs new file mode 100644 index 0000000..857e95d --- /dev/null +++ b/test/DotNetElements.Core.Test/Utils/FakeDbContextFactory.cs @@ -0,0 +1,42 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; + +namespace DotNetElements.Core.Test.Utils; + +public sealed class FakeDbContextFactory : IDisposable + where TDbContext : DbContext +{ + private DbConnection? connection; + + private DbContextOptions CreateOptions() + { + ArgumentNullException.ThrowIfNull(connection); + + return new DbContextOptionsBuilder() + .UseSqlite(connection).Options; + } + + public TDbContext CreateContext() + { + if (connection is null) + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + using TDbContext context = (TDbContext)Activator.CreateInstance(typeof(TDbContext), CreateOptions())!; + + context.Database.EnsureCreated(); + } + + return (TDbContext)Activator.CreateInstance(typeof(TDbContext), CreateOptions())!; + } + + public void Dispose() + { + if (connection is not null) + { + connection.Dispose(); + connection = null; + } + } +} diff --git a/test/DotNetElements.Core.Test/Utils/FakeRepositoryFactory.cs b/test/DotNetElements.Core.Test/Utils/FakeRepositoryFactory.cs new file mode 100644 index 0000000..54a24e3 --- /dev/null +++ b/test/DotNetElements.Core.Test/Utils/FakeRepositoryFactory.cs @@ -0,0 +1,20 @@ +namespace DotNetElements.Core.Test.Utils; + +internal sealed class FakeRepositoryFactory : IDisposable + where TDbContext : DbContext + where TRepository : FakeRepository + where TEntity : Entity + where TKey : notnull +{ + private readonly FakeDbContextFactory dbContextFactory = new(); + + public TRepository CreateRepository() + { + return (TRepository)Activator.CreateInstance(typeof(TRepository), dbContextFactory.CreateContext())!; + } + + public void Dispose() + { + dbContextFactory.Dispose(); + } +} diff --git a/test/DotNetElements.Core.Test/Utils/RakeRepository.cs b/test/DotNetElements.Core.Test/Utils/RakeRepository.cs new file mode 100644 index 0000000..3125f7d --- /dev/null +++ b/test/DotNetElements.Core.Test/Utils/RakeRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Time.Testing; + +namespace DotNetElements.Core.Test.Utils; + +internal class FakeRepository : Repository, IDisposable + where TDbContext : DbContext + where TEntity : Entity + where TKey : notnull +{ + public FakeRepository(TDbContext dbContext) : base( + dbContext, + new FakeCurrentUserProvider(), + new FakeTimeProvider()) + { + } + + public FakeRepository(TDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) : base( + dbContext, + currentUserProvider, + timeProvider) + { + } + + public void Dispose() + { + DbContext.Dispose(); + } +}