diff --git a/src/DotNetElements.Core.EntityFramework.Example/FakeModuleService.cs b/src/DotNetElements.Core.EntityFramework.Example/FakeModuleService.cs deleted file mode 100644 index 5da53d0..0000000 --- a/src/DotNetElements.Core.EntityFramework.Example/FakeModuleService.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace DotNetElements.Core.EntityFramework.Example; - -internal sealed class FakeModuleService : IDisposable - where TDbContext : DbContext - where TModuleService : ModuleService -{ - public TModuleService Service { get; private init; } - - public static FakeModuleService Create(TDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) - { - return new FakeModuleService(dbContext, currentUserProvider, timeProvider); - } - - private readonly TDbContext dbContext; - - private FakeModuleService(TDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) - { - this.dbContext = dbContext; - Service = (TModuleService)Activator.CreateInstance(typeof(TModuleService), dbContext, currentUserProvider, timeProvider)!; - } - - public void Dispose() - { - dbContext.Dispose(); - } -} diff --git a/src/DotNetElements.Core.EntityFramework.Example/ObjectExtensions.cs b/src/DotNetElements.Core.EntityFramework.Example/ObjectExtensions.cs deleted file mode 100644 index fcba7be..0000000 --- a/src/DotNetElements.Core.EntityFramework.Example/ObjectExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json; - -namespace DotNetElements.Core.EntityFramework.Example; - -internal static class ObjectExtensions -{ - private readonly static JsonSerializerOptions jsonOptions = new() { WriteIndented = true }; - - public static TObject Dump(this TObject obj) - { - string json = JsonSerializer.Serialize(obj, jsonOptions); - - Console.WriteLine(json); - - return obj; - } -} diff --git a/src/DotNetElements.Core.EntityFramework.Example/Program.cs b/src/DotNetElements.Core.EntityFramework.Example/Program.cs index 899f497..0493a79 100644 --- a/src/DotNetElements.Core.EntityFramework.Example/Program.cs +++ b/src/DotNetElements.Core.EntityFramework.Example/Program.cs @@ -1,233 +1,417 @@ using DotNetElements.Core.EntityFramework; using DotNetElements.Core.EntityFramework.Example; +using Microsoft.Extensions.Logging; using FakeModuleServiceFactory factory = new(); -using(FakeModuleService authorService = factory.CreateModule()) +// Create author +using (FakeModuleService authorService = factory.CreateModule("Create author")) { - CreateAuthorModel model = new() - { - FirstName = "John", - LastName = "Doe" - }; + CreateAuthorModel model = new() + { + FirstName = "Author 1", + LastName = "Not Updated" + }; - AuthorModel author = await authorService.Service.CreateAuthorAsync(model); + AuthorModel author = await authorService.Service.CreateAuthorAsync(model); - author.Dump(); + author.Dump("Create"); + + // Get author audit details + CrudResult auditDetails = await authorService.Service.GetAuthorAuditDetailsById(author.Id); + auditDetails.Dump("Author Details"); } +// Get all authors +IReadOnlyList authors = []; +using (FakeModuleService authorService = factory.CreateModule("Get all authors")) +{ + authors = await authorService.Service.GetAllAuthorsAsync(); -//using (FakeModuleService bookService = factory.CreateModule()) -//{ + authors.Dump("GetAll"); +} +// Update author +using (FakeModuleService authorService = factory.CreateModule("Update author")) +{ + EditAuthorModel model = EditAuthorModel.MapFromModel(authors[0]); + model.LastName = "Update 1"; -//} + CrudResult updateResult = await authorService.Service.UpdateAuthorAsync(model); -class BookService : ModuleService + updateResult.Dump("Update"); + + // Get author audit details + CrudResult auditDetails = await authorService.Service.GetAuthorAuditDetailsById(model.Id); + auditDetails.Dump("Author Details"); +} + +// Create Book +using (FakeModuleService bookService = factory.CreateModule("Create book")) { - public BookService(LibraryDbContext dbContext) : base(dbContext) - { - } + CreateBookModel model = new() + { + Name = "Book 1", + AuthorId = authors[0].Id + }; - public async Task CreateBookAsync(CreateBookModel model) - { - ArgumentNullException.ThrowIfNull(model.Name); + BookModel book = await bookService.Service.CreateBookAsync(model); - Book book = new Book(model.Name, model.AuthorId); + book.Dump("Create"); - DbContext.Books.Add(book); + // Get book audit details + CrudResult auditDetails = await bookService.Service.GetBookAuditDetailsById(book.Id); + auditDetails.Dump("Book Details"); +} - await DbContext.SaveChangesAsync(); +// Get all books +IReadOnlyList books = []; +using (FakeModuleService bookService = factory.CreateModule("Get all books")) +{ + books = await bookService.Service.GetAllBooksAsync(); - return book.ToModel(); - } + books.Dump("GetAll"); +} - public async Task> UpdateBookAsync(EditBookModel model) - { - Book? existingBook = await DbContext.Books - .FindAsync(model.Id); +// Delete book +using (FakeModuleService bookService = factory.CreateModule("Delete book")) +{ + CrudResult deleteResult = await bookService.Service.DeleteBookByIdAsync(books[0].Id); - if (existingBook is null) - return Fail(CrudError.NotFound); + deleteResult.Dump("Delete"); +} - existingBook.Update(model); +// Get all books +books = []; +using (FakeModuleService bookService = factory.CreateModule("Get all books")) +{ + books = await bookService.Service.GetAllBooksAsync(); - await DbContext.SaveChangesAsync(); + books.Dump("GetAll"); - return existingBook.ToModel(); - } + // Get author audit details + CrudResult auditDetails = await bookService.Service.GetBookAuditDetailsById(books[0].Id); + auditDetails.Dump("Book Details"); } -class AuthorService : ModuleService +// Update book +using (FakeModuleService bookService = factory.CreateModule("Update book")) { - public AuthorService(LibraryDbContext dbContext) : base(dbContext) - { - } + EditBookModel model = EditBookModel.MapFromModel(books[0]); + model.Name = "Update 1"; + + CrudResult updateResult = await bookService.Service.UpdateBookAsync(model); + + updateResult.Dump("Update"); + + // Get book audit details + CrudResult auditDetails = await bookService.Service.GetBookAuditDetailsById(model.Id); + auditDetails.Dump("Book Details"); +} + +class BookService : ModuleService +{ + public BookService(LibraryDbContext dbContext) : base(dbContext) + { + } + + public async Task CreateBookAsync(CreateBookModel model) + { + ArgumentNullException.ThrowIfNull(model.Name); + + Book book = new(model.Name, model.AuthorId); + + DbContext.Books.Add(book); + + await DbContext.SaveChangesAsync(); + + return book.MapToModel(); + } - public async Task CreateAuthorAsync(CreateAuthorModel model) - { - ArgumentNullException.ThrowIfNull(model.FirstName); - ArgumentNullException.ThrowIfNull(model.LastName); + public async Task> UpdateBookAsync(EditBookModel model) + { + Book? existingBook = await DbContext.Books + .EnsureNotDeleted() + .FindAsync(model.Id); - Author author = new Author(model.FirstName, model.LastName); + if (existingBook is null) + return Fail(CrudError.NotFound); - DbContext.Authors.Add(author); + existingBook.Update(model); - await DbContext.SaveChangesAsync(); + await DbContext.SaveChangesAsync(); - return author.ToModel(); - } + return existingBook.MapToModel(); + } - public async Task> UpdateAuthorAsync(EditAuthorModel model) - { - Author? existingAuthor = await DbContext.Authors - .FindAsync(model.Id); + public async Task DeleteBookByIdAsync(Guid Id) + { + Book? existingBook = await DbContext.Books + .FindAsync(Id); - if (existingAuthor is null) - return Fail(CrudError.NotFound); + if (existingBook is null) + return Fail(CrudError.NotFound); - existingAuthor.Update(model); + DbContext.Books.Remove(existingBook); + await DbContext.SaveChangesAsync(); - await DbContext.SaveChangesAsync(); + return CrudResult.Ok(); + } - return existingAuthor.ToModel(); - } + public async Task> GetAllBooksAsync() + { + return await DbContext.Books + .MapToModel() + .ToListAsync(); + } + + public Task> GetBookAuditDetailsById(Guid id) + { + return GetDeletionAuditedDetailsByEntityId(id); + } } -class LibraryDbContext : DbContext +class AuthorService : ModuleService { - public DbSet Books { get; set; } - public DbSet Authors { get; set; } + public AuthorService(LibraryDbContext dbContext) : base(dbContext) + { + } + + public async Task CreateAuthorAsync(CreateAuthorModel model) + { + ArgumentNullException.ThrowIfNull(model.FirstName); + ArgumentNullException.ThrowIfNull(model.LastName); + + Author author = new(model.FirstName, model.LastName); + + DbContext.Authors.Add(author); + + await DbContext.SaveChangesAsync(); + + return author.MapToModel(); + } + + public async Task> UpdateAuthorAsync(EditAuthorModel model) + { + Author? existingAuthor = await DbContext.Authors + .FindAsync(model.Id); + + if (existingAuthor is null) + return Fail(CrudError.NotFound); + + existingAuthor.Update(model); + + await DbContext.SaveChangesAsync(); + + return existingAuthor.MapToModel(); + } + + public async Task DeleteAuthorByIdAsync(Guid Id) + { + Author? existingAuthor = await DbContext.Authors + .EnsureNotDeleted() + .FindAsync(Id); + + if (existingAuthor is null) + return Fail(CrudError.NotFound); + + DbContext.Authors.Remove(existingAuthor); + await DbContext.SaveChangesAsync(); + + return CrudResult.Ok(); + } + + public async Task> GetAllAuthorsAsync() + { + return await DbContext.Authors + .EnsureNotDeleted() + .MapToModel() + .ToListAsync(); + } + + public Task> GetAuthorAuditDetailsById(Guid id) + { + return GetDeletionAuditedDetailsByEntityId(id); + } } -class Book : AuditedEntity, IUpdateFrom, IMapToModel +class LibraryDbContext : DbContext, IFakeDbContext { - public string Name { get; private set; } - public Guid AuthorId { get; private set; } + public DbSet Books { get; set; } = default!; + public DbSet Authors { get; set; } = default!; - [ForeignKey(nameof(AuthorId))] - public Author Author { get; private set; } = null!; + public Action LogAction { set => logAction = value; } - public Book(string name, Guid authorId, Guid id = default) - { - Id = id; - Name = name; - AuthorId = authorId; - } + private Action? logAction; - public void Update(EditBookModel from) - { - ArgumentNullException.ThrowIfNull(from.Name); + public LibraryDbContext(DbContextOptions options) : base(options) + { - Name = from.Name; - AuthorId = from.AuthorId; - } + } - public BookModel ToModel() - { - return new BookModel - { - Id = Id, - Name = Name, - AuthorId = AuthorId - }; - } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo( + logAction ?? Console.WriteLine, + LogLevel.Information, + Microsoft.EntityFrameworkCore.Diagnostics.DbContextLoggerOptions.None) + .EnableSensitiveDataLogging(); +} + +class Book : DeletionAuditedEntity, IUpdateFrom +{ + public string Name { get; private set; } + public Guid AuthorId { get; private set; } + + [ForeignKey(nameof(AuthorId))] + public Author Author { get; private set; } = null!; + + public Book(string name, Guid authorId, Guid id = default) + { + Id = id; + Name = name; + AuthorId = authorId; + } + + public void Update(EditBookModel from) + { + ArgumentNullException.ThrowIfNull(from.Name); + + Name = from.Name; + AuthorId = from.AuthorId; + } +} + +static class BookMapper +{ + public static BookModel MapToModel(this Book entity) + { + return new BookModel + { + Id = entity.Id, + Name = entity.Name, + AuthorId = entity.AuthorId + }; + } + + public static IQueryable MapToModel(this IQueryable query) + { + return Queryable.Select(query, entity => new BookModel() + { + Id = entity.Id, + Name = entity.Name, + AuthorId = entity.AuthorId + }); + } } class BookModel : Model { - public required string Name { get; init; } - public required Guid AuthorId { get; init; } + public required string Name { get; init; } + public required Guid AuthorId { get; init; } } class CreateBookModel : CreateModel { - [Required] - public string? Name { get; set; } + [Required] + public string? Name { get; set; } - [Required] - public Guid AuthorId { get; set; } + [Required] + public Guid AuthorId { get; set; } } class EditBookModel : EditModel, IMapFromModel { - public string? Name { get; set; } - public Guid AuthorId { get; set; } - - public static EditBookModel MapFromModel(BookModel model) - { - return new EditBookModel - { - Id = model.Id, - Name = model.Name, - AuthorId = model.AuthorId - }; - } + public string? Name { get; set; } + public Guid AuthorId { get; set; } + + public static EditBookModel MapFromModel(BookModel model) + { + return new EditBookModel + { + Id = model.Id, + Name = model.Name, + AuthorId = model.AuthorId + }; + } } -class Author : AuditedEntity, IUpdateFrom, IMapToModel +class Author : DeletionAuditedEntity, IUpdateFrom { - public string FirstName { get; private set; } - public string LastName { get; private set; } + public string FirstName { get; private set; } + public string LastName { get; private set; } - private readonly List books = default!; + private readonly List books = default!; - [BackingField(nameof(books))] - public IReadOnlyList Books => books; + [BackingField(nameof(books))] + public IReadOnlyList Books => books; - public Author(string firstName, string lastName) - { - FirstName = firstName; - LastName = lastName; - } + public Author(string firstName, string lastName) + { + FirstName = firstName; + LastName = lastName; + } - public void Update(EditAuthorModel from) - { - ArgumentNullException.ThrowIfNull(from.FirstName); - ArgumentNullException.ThrowIfNull(from.LastName); + public void Update(EditAuthorModel from) + { + ArgumentNullException.ThrowIfNull(from.FirstName); + ArgumentNullException.ThrowIfNull(from.LastName); - FirstName = from.FirstName; - LastName = from.LastName; - } + FirstName = from.FirstName; + LastName = from.LastName; + } +} - public AuthorModel ToModel() - { - return new AuthorModel - { - Id = Id, - FirstName = FirstName, - LastName = LastName - }; - } +static class AuthorMapper +{ + public static AuthorModel MapToModel(this Author entity) + { + return new AuthorModel + { + Id = entity.Id, + FirstName = entity.FirstName, + LastName = entity.LastName + }; + } + + public static IQueryable MapToModel(this IQueryable query) + { + return Queryable.Select(query, entity => new AuthorModel() + { + Id = entity.Id, + FirstName = entity.FirstName, + LastName = entity.LastName + }); + } } class AuthorModel : Model { - public required string FirstName { get; init; } - public required string LastName { get; init; } + public required string FirstName { get; init; } + public required string LastName { get; init; } } class CreateAuthorModel : CreateModel { - [Required] - public string? FirstName { get; set; } + [Required] + public string? FirstName { get; set; } - [Required] - public string? LastName { get; set; } + [Required] + public string? LastName { get; set; } } class EditAuthorModel : EditModel, IMapFromModel { - public string? FirstName { get; set; } - public string? LastName { get; set; } - - public static EditAuthorModel MapFromModel(AuthorModel model) - { - return new EditAuthorModel - { - Id = model.Id, - FirstName = model.FirstName, - LastName = model.LastName - }; - } + public string? FirstName { get; set; } + public string? LastName { get; set; } + + public static EditAuthorModel MapFromModel(AuthorModel model) + { + return new EditAuthorModel + { + Id = model.Id, + FirstName = model.FirstName, + LastName = model.LastName + }; + } } \ No newline at end of file diff --git a/src/DotNetElements.Core.EntityFramework.Example/FakeCurrentUserProvider.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeCurrentUserProvider.cs similarity index 100% rename from src/DotNetElements.Core.EntityFramework.Example/FakeCurrentUserProvider.cs rename to src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeCurrentUserProvider.cs diff --git a/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleService.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleService.cs new file mode 100644 index 0000000..05a3ec6 --- /dev/null +++ b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleService.cs @@ -0,0 +1,58 @@ +namespace DotNetElements.Core.EntityFramework.Example; + +internal interface ILogEventStore +{ + public void Log(LogType LogType, string message); +} + +internal sealed class FakeModuleService : IDisposable, ILogEventStore + where TDbContext : DbContext, IFakeDbContext + where TModuleService : ModuleService +{ + public TModuleService Service { get; private init; } + + private readonly List logEvents = []; + + private readonly string? logContext; + + public static FakeModuleService Create(TDbContext dbContext, string? logContext) + { + return new FakeModuleService(dbContext, logContext); + } + + private readonly TDbContext dbContext; + + private FakeModuleService(TDbContext dbContext, string? logContext) + { + this.dbContext = dbContext; + this.dbContext.LogAction = LogFromDbContext; + this.logContext = logContext; + + Service = (TModuleService)Activator.CreateInstance(typeof(TModuleService), dbContext)!; + + logEvents.Add(new LogEvent(LogType.ModuleService, $"Started ModuleService operations {(logContext is null ? "" : $"<{logContext}>")} (TypeOf: {typeof(TModuleService).Name})", DateTime.Now)); + + Logger.Instance = this; + } + + public void Dispose() + { + dbContext.Dispose(); + + logEvents.Add(new LogEvent(LogType.ModuleService, $"Finished ModuleService operations {(logContext is null ? "" : $"<{logContext}>")} (TypeOf: {typeof(TModuleService).Name})", DateTime.Now)); + + Logger.Instance = null; + + Logger.LogToConsole(logEvents); + } + + public void Log(LogType LogType, string message) + { + logEvents.Add(new LogEvent(LogType, message, DateTime.Now)); + } + + private void LogFromDbContext(string message) + { + logEvents.Add(new LogEvent(LogType.DbContext, message, DateTime.Now)); + } +} diff --git a/src/DotNetElements.Core.EntityFramework.Example/FakeModuleServiceFactory.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleServiceFactory.cs similarity index 85% rename from src/DotNetElements.Core.EntityFramework.Example/FakeModuleServiceFactory.cs rename to src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleServiceFactory.cs index af02d6e..98f49a7 100644 --- a/src/DotNetElements.Core.EntityFramework.Example/FakeModuleServiceFactory.cs +++ b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/FakeModuleServiceFactory.cs @@ -5,10 +5,10 @@ namespace DotNetElements.Core.EntityFramework.Example; internal sealed class FakeModuleServiceFactory : IDisposable - where TDbContext : DbContext + where TDbContext : DbContext, IFakeDbContext { public readonly FakeCurrentUserProvider UserProvider = new(); - public readonly FakeTimeProvider TimeProvider = new(); + public readonly TimeProvider TimeProvider = TimeProvider.System; private DbConnection? connection; @@ -19,12 +19,11 @@ private DbContextOptions CreateOptions() return new DbContextOptionsBuilder() .UseSqlite(connection) .AddInterceptors( - new SoftDeleteInterceptor(TimeProvider, UserProvider), new AuditInterceptor(TimeProvider, UserProvider)) .Options; } - public FakeModuleService CreateModule() + public FakeModuleService CreateModule(string? logContext = null) where TModuleService : ModuleService { if (connection is null) @@ -39,7 +38,7 @@ public FakeModuleService CreateModule.Create(dbContext, UserProvider, TimeProvider); + return FakeModuleService.Create(dbContext, logContext); } public void Dispose() @@ -49,5 +48,7 @@ public void Dispose() connection.Dispose(); connection = null; } + + Console.ForegroundColor = ConsoleColor.White; } } diff --git a/src/DotNetElements.Core.EntityFramework.Example/TestUtils/IFakeDbContext.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/IFakeDbContext.cs new file mode 100644 index 0000000..641ddb3 --- /dev/null +++ b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/IFakeDbContext.cs @@ -0,0 +1,6 @@ +namespace DotNetElements.Core.EntityFramework.Example; + +internal interface IFakeDbContext +{ + public Action LogAction { set; } +} diff --git a/src/DotNetElements.Core.EntityFramework.Example/TestUtils/ObjectExtensions.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/ObjectExtensions.cs new file mode 100644 index 0000000..ef2c2ea --- /dev/null +++ b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/ObjectExtensions.cs @@ -0,0 +1,61 @@ +using DotNetElements.Core.Result; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace DotNetElements.Core.EntityFramework.Example; + +internal static class ObjectExtensions +{ + private readonly static JsonSerializerOptions jsonOptions = new() + { + WriteIndented = true + }; + + public static TObject Dump(this TObject obj, string? message = null, [CallerArgumentExpression(nameof(obj))] string? caller = null) + { + string? objectDump; + + if (obj is ErrorResult crudResult) + { + object? value = typeof(TObject).GetField("Value", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(obj); + + if (value is not null) + objectDump = GetObjectAsJson(new { Result = crudResult.IsOk ? "Ok" : "Fail", Value = value }); + else + objectDump = GetObjectAsJson(new { Result = crudResult.IsOk ? "Ok" : "Fail" }); + } + else + { + objectDump = GetObjectAsJson(obj); + } + + string cleansedObjectType = GetObjectTypeCleansed(); + + Logger.Instance?.Log(LogType.ObjectDumpHeader, $"{message} {caller} (TypeOf: {cleansedObjectType})"); + Logger.Instance?.Log(LogType.ObjectDump, objectDump); + + return obj; + } + + private static string GetObjectTypeCleansed() + { + Type objectType = typeof(TObject); + + string objectTypeCleansed = objectType.Name; + + if (objectType.IsGenericType) + { + objectTypeCleansed = objectTypeCleansed.Split('`')[0]; + objectTypeCleansed += "<"; + objectTypeCleansed += string.Join(", ", objectType.GetGenericArguments().First().Name); + objectTypeCleansed += ">"; + } + + return objectTypeCleansed; + } + + private static string GetObjectAsJson(TObject obj) + { + return JsonSerializer.Serialize(obj, jsonOptions); + } +} diff --git a/src/DotNetElements.Core.EntityFramework.Example/TestUtils/TestLogger.cs b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/TestLogger.cs new file mode 100644 index 0000000..71a6f20 --- /dev/null +++ b/src/DotNetElements.Core.EntityFramework.Example/TestUtils/TestLogger.cs @@ -0,0 +1,69 @@ +namespace DotNetElements.Core.EntityFramework.Example; + +internal enum LogType +{ + DbContext, + ModuleService, + ObjectDumpHeader, + ObjectDump, +} + +internal record struct LogEvent(LogType LogType, string Message, DateTime Timestamp); + +internal static partial class Logger +{ + public static ILogEventStore? Instance; + + public static void LogToConsole(List logEvents) + { + LogEvent? previousLogEvent = null; + + foreach (LogEvent logEvent in logEvents.OrderBy(e => e.Timestamp)) + { + ConsoleColor color = logEvent.LogType switch + { + LogType.DbContext => ConsoleColor.White, + LogType.ModuleService => ConsoleColor.Green, + LogType.ObjectDumpHeader => ConsoleColor.Yellow, + LogType.ObjectDump => ConsoleColor.White, + _ => ConsoleColor.White, + }; + + bool addEmptyLineBefore = logEvent.LogType switch + { + LogType.DbContext => false, + LogType.ModuleService => true, + LogType.ObjectDumpHeader => true, + LogType.ObjectDump => false, + _ => false, + }; + + // Check if the current logEvent is DbContext and the next logEvent is also DbContext + if (logEvent.LogType is LogType.DbContext && previousLogEvent?.LogType is LogType.DbContext) + addEmptyLineBefore = true; + + bool addEmptyLineAfter = logEvent.LogType switch + { + LogType.DbContext => false, + LogType.ModuleService => true, + LogType.ObjectDumpHeader => false, + LogType.ObjectDump => true, + _ => false, + }; + + Console.ForegroundColor = color; + + if (addEmptyLineBefore) + Console.WriteLine(); + + Console.WriteLine(logEvent.Message); + + if (addEmptyLineAfter) + Console.WriteLine(); + + previousLogEvent = logEvent; + }; + + Console.ResetColor(); + } +} diff --git a/src/DotNetElements.Core.EntityFramework.Shared/Model/ModelBase.cs b/src/DotNetElements.Core.EntityFramework.Shared/Model/ModelBase.cs index 21558f2..6140c20 100644 --- a/src/DotNetElements.Core.EntityFramework.Shared/Model/ModelBase.cs +++ b/src/DotNetElements.Core.EntityFramework.Shared/Model/ModelBase.cs @@ -55,20 +55,20 @@ public class CreationAuditedModelDetails : ModelDetails public class AuditedModelDetails : CreationAuditedModelDetails { - public Guid? LastModifierId { get; init; } + public required Guid? LastModifierId { get; init; } - public string? LastModifierDisplayName { get; init; } + public required string? LastModifierDisplayName { get; init; } - public DateTimeOffset? LastModificationTime { get; init; } + public required DateTimeOffset? LastModificationTime { get; init; } } -public class PersistentModelDetails : AuditedModelDetails +public class DeletionAuditedModelDetails : AuditedModelDetails { - public bool IsDeleted { get; init; } + public required bool IsDeleted { get; init; } - public Guid? DeleterId { get; init; } + public required Guid? DeleterId { get; init; } - public string? DeleterDisplayName { get; init; } + public required string? DeleterDisplayName { get; init; } - public DateTimeOffset? DeletionTime { get; init; } + public required DateTimeOffset? DeletionTime { get; init; } } diff --git a/src/DotNetElements.Core.EntityFramework.Shared/SharedContracts.cs b/src/DotNetElements.Core.EntityFramework.Shared/SharedContracts.cs index 19dcf57..eb16e5e 100644 --- a/src/DotNetElements.Core.EntityFramework.Shared/SharedContracts.cs +++ b/src/DotNetElements.Core.EntityFramework.Shared/SharedContracts.cs @@ -7,8 +7,3 @@ public interface IHasKey bool HasKey => !Id.Equals(default); } - -public interface IMapToModel -{ - TModel ToModel(); -} diff --git a/src/DotNetElements.Core.EntityFramework/EfCore/AuditInterceptor.cs b/src/DotNetElements.Core.EntityFramework/EfCore/AuditInterceptor.cs index 9ec0f0c..9b6fffd 100644 --- a/src/DotNetElements.Core.EntityFramework/EfCore/AuditInterceptor.cs +++ b/src/DotNetElements.Core.EntityFramework/EfCore/AuditInterceptor.cs @@ -20,26 +20,39 @@ public override ValueTask> SavingChangesAsync(DbContextE if (eventData.Context is null) return base.SavingChangesAsync(eventData, result, cancellationToken); - IEnumerable> entities = eventData + IEnumerable> addedOrModifiedEntities = eventData .Context .ChangeTracker .Entries() .Where(e => e.State is EntityState.Added or EntityState.Modified); - foreach (EntityEntry entity in entities) + foreach (EntityEntry auditedEntity in addedOrModifiedEntities) { - if (entity.State is EntityState.Added) + if (auditedEntity.State is EntityState.Added) { - entity.Property(nameof(ICreationAuditedEntity.CreationTime)).CurrentValue = timeProvider.GetUtcNow(); - entity.Property(nameof(ICreationAuditedEntity.CreatorId)).CurrentValue = currentUserProvider.GetCurrentUserId(); + auditedEntity.Property(nameof(ICreationAuditedEntity.CreationTime)).CurrentValue = timeProvider.GetUtcNow(); + auditedEntity.Property(nameof(ICreationAuditedEntity.CreatorId)).CurrentValue = currentUserProvider.GetCurrentUserId(); } - else if (entity.State is EntityState.Modified && entity.Entity is IAuditedEntity) + else if (auditedEntity.State is EntityState.Modified && auditedEntity.Entity is IAuditedEntity) { - entity.Property(nameof(IAuditedEntity.LastModificationTime)).CurrentValue = timeProvider.GetUtcNow(); - entity.Property(nameof(IAuditedEntity.LastModifierId)).CurrentValue = currentUserProvider.GetCurrentUserId(); + auditedEntity.Property(nameof(IAuditedEntity.LastModificationTime)).CurrentValue = timeProvider.GetUtcNow(); + auditedEntity.Property(nameof(IAuditedEntity.LastModifierId)).CurrentValue = currentUserProvider.GetCurrentUserId(); } } - return base.SavingChangesAsync(eventData, result, cancellationToken); + IEnumerable> deletedEntities = + eventData + .Context + .ChangeTracker + .Entries() + .Where(e => e.State is EntityState.Deleted); + + foreach (EntityEntry deletionAuditedEntity in deletedEntities) + { + deletionAuditedEntity.State = EntityState.Modified; + deletionAuditedEntity.Entity.SetIsDeletedWithAudit(currentUserProvider.GetCurrentUserId(), timeProvider.GetUtcNow()); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); } } diff --git a/src/DotNetElements.Core.EntityFramework/EfCore/IQueryableExtensions.cs b/src/DotNetElements.Core.EntityFramework/EfCore/IQueryableExtensions.cs index bea5630..0e5fdf5 100644 --- a/src/DotNetElements.Core.EntityFramework/EfCore/IQueryableExtensions.cs +++ b/src/DotNetElements.Core.EntityFramework/EfCore/IQueryableExtensions.cs @@ -8,4 +8,17 @@ public static class IQueryableExtensions { return await query.FirstOrDefaultAsync(entity => entity.Id.Equals(id)); } + + public static IQueryable WithId(this IQueryable query, TKey id) + where TEntity : class, IHasKey + where TKey : notnull, IEquatable + { + return query.Where(entity => entity.Id.Equals(id)); + } + + public static IQueryable EnsureNotDeleted(this IQueryable query) + where TEntity : class, IDeletionAuditedEntity + { + return query.Where(entity => !entity.IsDeleted); + } } diff --git a/src/DotNetElements.Core.EntityFramework/EfCore/ModuleService.cs b/src/DotNetElements.Core.EntityFramework/EfCore/ModuleService.cs index fd972ec..c7e04da 100644 --- a/src/DotNetElements.Core.EntityFramework/EfCore/ModuleService.cs +++ b/src/DotNetElements.Core.EntityFramework/EfCore/ModuleService.cs @@ -1,24 +1,100 @@ namespace DotNetElements.Core.EntityFramework; public abstract class ModuleService - where TDbContext : DbContext + where TDbContext : DbContext { - protected readonly TDbContext DbContext; + protected readonly TDbContext DbContext; - protected ModuleService(TDbContext dbContext) - { - DbContext = dbContext; - } + protected ModuleService(TDbContext dbContext) + { + DbContext = dbContext; + } - protected bool EnsureVersion(TEntity entity, TEntity existingEntity) - where TEntity : class, IEntity, IEntityHasVersion - where TKey : notnull, IEquatable - { - if (entity.Version != existingEntity.Version) - return false; + protected bool EnsureVersion(TEntity entity, TEntity existingEntity) + where TEntity : class, IEntity, IEntityHasVersion + where TKey : notnull, IEquatable + { + if (entity.Version != existingEntity.Version) + return false; - entity.UpdateVersion(); + entity.UpdateVersion(); - return true; - } + return true; + } + + protected async Task> GetCreationAuditedDetailsByEntityId(TKey id) + where TEntity : class, IEntity, ICreationAuditedEntity + where TKey : notnull, IEquatable + { + IQueryable query = DbContext + .Set() + .AsNoTracking() + .WithId(id); + + CreationAuditedModelDetails? details = await query + .Select(entity => + new CreationAuditedModelDetails() + { + CreatorId = entity.CreatorId, + CreatorDisplayName = "", // todo + CreationTime = entity.CreationTime, + }) + .FirstOrDefaultAsync(); + + return CrudResultHelperExtensions.OkIfNotNull(details, CrudError.NotFound); + } + + protected async Task> GetAuditedDetailsByEntityId(TKey id) + where TEntity : class, IEntity, IAuditedEntity + where TKey : notnull, IEquatable + { + IQueryable query = DbContext + .Set() + .AsNoTracking() + .WithId(id); + + AuditedModelDetails? details = await query + .Select(entity => + new AuditedModelDetails() + { + CreatorId = entity.CreatorId, + CreatorDisplayName = "", // todo + CreationTime = entity.CreationTime, + LastModifierId = entity.LastModifierId, + LastModifierDisplayName = "", // todo + LastModificationTime = entity.LastModificationTime + }) + .FirstOrDefaultAsync(); + + return CrudResultHelperExtensions.OkIfNotNull(details, CrudError.NotFound); + } + + protected async Task> GetDeletionAuditedDetailsByEntityId(TKey id) + where TEntity : class, IEntity, IDeletionAuditedEntity + where TKey : notnull, IEquatable + { + IQueryable query = DbContext + .Set() + .AsNoTracking() + .WithId(id); + + DeletionAuditedModelDetails? details = await query + .Select(entity => + new DeletionAuditedModelDetails() + { + CreatorId = entity.CreatorId, + CreatorDisplayName = "", // todo + CreationTime = entity.CreationTime, + LastModifierId = entity.LastModifierId, + LastModifierDisplayName = "", // todo + LastModificationTime = entity.LastModificationTime, + IsDeleted = entity.IsDeleted, + DeleterId = entity.DeleterId, + DeleterDisplayName = "", // todo + DeletionTime = entity.DeletionTime + }) + .FirstOrDefaultAsync(); + + return CrudResultHelperExtensions.OkIfNotNull(details, CrudError.NotFound); + } } diff --git a/src/DotNetElements.Core.EntityFramework/EfCore/SoftDeleteInterceptor.cs b/src/DotNetElements.Core.EntityFramework/EfCore/SoftDeleteInterceptor.cs deleted file mode 100644 index 4ad03dc..0000000 --- a/src/DotNetElements.Core.EntityFramework/EfCore/SoftDeleteInterceptor.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace DotNetElements.Core.EntityFramework; - -public sealed class SoftDeleteInterceptor : SaveChangesInterceptor -{ - private readonly TimeProvider timeProvider; - private readonly ICurrentUserProvider currentUserProvider; - - public SoftDeleteInterceptor(TimeProvider timeProvider, ICurrentUserProvider currentUserProvider) - { - this.timeProvider = timeProvider; - this.currentUserProvider = currentUserProvider; - } - - public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) - { - if (eventData.Context is null) - return base.SavingChangesAsync(eventData, result, cancellationToken); - - IEnumerable> entries = - eventData - .Context - .ChangeTracker - .Entries() - .Where(e => e.State is EntityState.Deleted); - - foreach (EntityEntry softDeletable in entries) - { - softDeletable.State = EntityState.Modified; - softDeletable.Entity.SetIsDeletedWithAudit(currentUserProvider.GetCurrentUserId(), timeProvider.GetUtcNow()); - } - - return base.SavingChangesAsync(eventData, result, cancellationToken); - } -} diff --git a/src/DotNetElements.Core.EntityFramework/Entity/EntityBase.cs b/src/DotNetElements.Core.EntityFramework/Entity/EntityBase.cs index 76723cd..13da3c2 100644 --- a/src/DotNetElements.Core.EntityFramework/Entity/EntityBase.cs +++ b/src/DotNetElements.Core.EntityFramework/Entity/EntityBase.cs @@ -39,7 +39,7 @@ public void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModif } } -public class PersistentEntity : AuditedEntity, IDeletionAuditedEntity +public class DeletionAuditedEntity : AuditedEntity, IDeletionAuditedEntity where TKey : notnull, IEquatable { public bool IsDeleted { get; private set; } diff --git a/src/DotNetElements.Core.EntityFramework/Entity/EntityContracts.cs b/src/DotNetElements.Core.EntityFramework/Entity/EntityContracts.cs index 7d5892f..8bfcde6 100644 --- a/src/DotNetElements.Core.EntityFramework/Entity/EntityContracts.cs +++ b/src/DotNetElements.Core.EntityFramework/Entity/EntityContracts.cs @@ -21,7 +21,7 @@ public interface IAuditedEntity : ICreationAuditedEntity void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModificationTime); } -public interface IDeletionAuditedEntity +public interface IDeletionAuditedEntity : IAuditedEntity { bool IsDeleted { get; } Guid? DeleterId { get; } diff --git a/src/DotNetElements.Core.EntityFramework/Repository/IReadOnlyRepository.cs b/src/DotNetElements.Core.EntityFramework/Repository/IReadOnlyRepository.cs index 5bc1598..ae972b3 100644 --- a/src/DotNetElements.Core.EntityFramework/Repository/IReadOnlyRepository.cs +++ b/src/DotNetElements.Core.EntityFramework/Repository/IReadOnlyRepository.cs @@ -62,8 +62,8 @@ Task> GetAuditedModelDetailsByIdAsync; - Task> GetPersistentModelDetailsByIdAsync( + Task> GetPersistentModelDetailsByIdAsync( TKey id, CancellationToken cancellationToken = default) - where TPersistentEntity : PersistentEntity; + where TPersistentEntity : DeletionAuditedEntity; } \ No newline at end of file diff --git a/src/DotNetElements.Core.EntityFramework/Repository/ReadOnlyRepository.cs b/src/DotNetElements.Core.EntityFramework/Repository/ReadOnlyRepository.cs index 648a7a9..0d8792e 100644 --- a/src/DotNetElements.Core.EntityFramework/Repository/ReadOnlyRepository.cs +++ b/src/DotNetElements.Core.EntityFramework/Repository/ReadOnlyRepository.cs @@ -29,7 +29,7 @@ public virtual async Task> GetByIdAsync(TKey id, Cancellatio { TEntity? entity = await Entities.AsNoTracking().FirstOrDefaultAsync(WithId(id), cancellationToken); - return CrudResult.OkIfNotNull(entity, CrudError.NotFound); + return CrudResultHelperExtensions.OkIfNotNull(entity, CrudError.NotFound); } public async Task> GetByIdFilteredAsync( @@ -44,7 +44,7 @@ public async Task> GetByIdFilteredAsync( TEntity? entity = await entityQuery.FirstOrDefaultAsync(WithId(id), cancellationToken); - return CrudResult.OkIfNotNull(entity, CrudError.NotFound); + return CrudResultHelperExtensions.OkIfNotNull(entity, CrudError.NotFound); } public async Task> GetFilteredWithProjectionAsync( @@ -60,7 +60,7 @@ public async Task> GetFilteredWithProjectionAsync> GetByIdWithProjectionAsync( @@ -75,7 +75,7 @@ public async Task> GetByIdWithProjectionAsync> GetAllAsync(CancellationToken cancellationToken = default) @@ -173,19 +173,19 @@ public async Task> GetAuditedModelDetailsByIdAsy } ).FirstOrDefaultAsync(cancellationToken); - return CrudResult.OkIfNotNull(entity, CrudError.NotFound); + return CrudResultHelperExtensions.OkIfNotNull(entity, CrudError.NotFound); } - public async Task> GetPersistentModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) - where TPersistentEntity : PersistentEntity + public async Task> GetPersistentModelDetailsByIdAsync(TKey id, CancellationToken cancellationToken = default) + where TPersistentEntity : DeletionAuditedEntity { DbSet localDbSet = DbContext.Set(); - PersistentModelDetails? entity = await localDbSet + DeletionAuditedModelDetails? entity = await localDbSet .AsNoTracking() .Where(WithId(id)) .Select(entity => - new PersistentModelDetails() + new DeletionAuditedModelDetails() { CreatorId = entity.CreatorId, CreatorDisplayName = "Felix", // todo get from user @@ -200,6 +200,6 @@ public async Task> GetPersistentModelDetailsB } ).FirstOrDefaultAsync(cancellationToken); - return CrudResult.OkIfNotNull(entity, CrudError.NotFound); + return CrudResultHelperExtensions.OkIfNotNull(entity, CrudError.NotFound); } } \ No newline at end of file diff --git a/src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelper.cs b/src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelperExtensions.cs similarity index 92% rename from src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelper.cs rename to src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelperExtensions.cs index e66d998..84da4a6 100644 --- a/src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelper.cs +++ b/src/DotNetElements.Core.EntityFramework/ResultExtensions/CrudResultHelperExtensions.cs @@ -1,6 +1,7 @@ namespace DotNetElements.Core.EntityFramework; -public static partial class CrudResultHelper +// todo move to .Result package +public static partial class CrudResultHelperExtensions { /// /// Creates a result whose success/failure reflects the supplied condition. diff --git a/src/DotNetElements.Core.Shared/DotNetElements.Core.Shared.csproj b/src/DotNetElements.Core.Shared/DotNetElements.Core.Shared.csproj index 1cc3f06..cbe8842 100644 --- a/src/DotNetElements.Core.Shared/DotNetElements.Core.Shared.csproj +++ b/src/DotNetElements.Core.Shared/DotNetElements.Core.Shared.csproj @@ -8,7 +8,7 @@ - +