diff --git a/docs/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md b/docs/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md index 91cc31abc..5e6fc74d6 100644 --- a/docs/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md +++ b/docs/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md @@ -18,7 +18,7 @@ const router = new Router({ path: '/admin/audit-logs', component: CAdminAuditLogPage, props: { - type: 'ObjectChange' + type: 'AuditLog' } }, ] @@ -29,7 +29,7 @@ const router = new Router({ -The PascalCase name of your `IObjectChange` implementation. +The PascalCase name of your `IAuditLog` implementation. @@ -42,10 +42,10 @@ A Vuetify color name to be applied to the toolbar at the top of the page. ## Slots - + A slot that can be used to replace the entire content of the Detail column on the page. - + A slot that can be used to append additional content to the Detail column on the page. \ No newline at end of file diff --git a/docs/topics/audit-logging.md b/docs/topics/audit-logging.md index 27c79380f..12d92b7a8 100644 --- a/docs/topics/audit-logging.md +++ b/docs/topics/audit-logging.md @@ -6,6 +6,8 @@ Coalesce provides a package `IntelliTect.Coalesce.AuditLogging` that adds an eas ## Setup +In this setup process, we're going to add an additional Coalesce Nuget package, define a custom entity to hold our audit logs and any extra properties, install the audit logging extension into our `DbContext`, and add a pre-made interface on the frontend to view our logs. + ### 1. Add the NuGet package Add a reference to the Nuget package `IntelliTect.Coalesce.AuditLogging` to your data project: @@ -25,7 +27,7 @@ Define the entity type that will hold the audit records in your database: using IntelliTect.Coalesce.AuditLogging; [Read(Roles = "Administrator")] -public class ObjectChange : DefaultObjectChange +public class AuditLog : DefaultAuditLog { public string? UserId { get; set; } public AppUser? User { get; set; } @@ -34,26 +36,26 @@ public class ObjectChange : DefaultObjectChange } ``` -This entity only needs to implement `IObjectChange`, but a default implementation of this interface `DefaultObjectChange` is provided for your convenience. `DefaultObjectChange` contains additional properties `ClientIp`, `Referrer`, and `Endpoint` for recording information about the HTTP request (if available), and also has attributes to disable Create, Edit, and Delete APIs. +This entity only needs to implement `IAuditLog`, but a default implementation of this interface `DefaultAuditLog` is provided for your convenience. `DefaultAuditLog` contains additional properties `ClientIp`, `Referrer`, and `Endpoint` for recording information about the HTTP request (if available), and also has attributes to disable Create, Edit, and Delete APIs. -You should further augment this type with any additional fields that you would like to track on each change record. A property to track the user who performed the change should be added, since it is not provided by the default implementation so that you can declare it yourself with the correct type for the foreign key and navigation property. +You should further augment this type with any additional properties that you would like to track on each change record. A property to track the user who performed the change should be added, since it is not provided by the default implementation so that you can declare it yourself with the correct type for the foreign key and navigation property. You should also apply security to restrict reading of these records to only the most privileged users with a [Read Attribute](/modeling/model-components/attributes/security-attribute.md#read) (as in the example above) and/or a [custom Default Data Source](/modeling/model-components/data-sources.md#defining-data-sources). ### 3. Configure your `DbContext` -On your `DbContext`, implement the `IAuditLogContext` interface using the class you just created as the type parameter. Then register the Coalesce audit logging extension in your `DbContext`'s `OnConfiguring` method so that saves will be intercepted and audit log entries created. +On your `DbContext`, implement the `IAuditLogContext` interface using the class you just created as the type parameter. Then register the Coalesce audit logging extension in your `DbContext`'s `OnConfiguring` method so that saves will be intercepted and audit log entries created. ``` c# [Coalesce] -public class AppDbContext : DbContext, IAuditLogContext +public class AppDbContext : DbContext, IAuditLogContext { - public DbSet ObjectChanges { get; set; } - public DbSet ObjectChangeProperties { get; set; } + public DbSet AuditLogs { get; set; } + public DbSet AuditLogProperties { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseCoalesceAuditLogging(x => x + optionsBuilder.UseCoalesceAuditLogging(x => x .WithAugmentation() ); } @@ -62,15 +64,15 @@ public class AppDbContext : DbContext, IAuditLogContext You could also perform this setup in your web project when calling `.AddDbContext()`. -The above code also contains a reference to a class `OperationContext`. This is the service that will allow you to populate additional custom fields on your audit entries. You'll want to define it as follows: +The above code also contains a reference to a class `OperationContext`. This is the service that will allow you to populate additional custom properties on your audit entries. You'll want to define it as follows: ``` c# -public class OperationContext : DefaultAuditOperationContext +public class OperationContext : DefaultAuditOperationContext { // Inject any additional desired services in the constructor: public OperationContext(IHttpContextAccessor accessor) : base(accessor) { } - public override void Populate(ObjectChange auditEntry, EntityEntry changedEntity) + public override void Populate(AuditLog auditEntry, EntityEntry changedEntity) { base.Populate(auditEntry, changedEntity); @@ -80,7 +82,7 @@ public class OperationContext : DefaultAuditOperationContext } ``` -When you're inheriting from `DefaultObjectChange` for your `IObjectChange` implementation, you'll want to similarly inherit from `DefaultAuditOperationContext<>` for your operation context. It will take care of populating the HTTP request tracking fields on the `ObjectChange` record. +When you're inheriting from `DefaultAuditLog` for your `IAuditLog` implementation, you'll want to similarly inherit from `DefaultAuditOperationContext<>` for your operation context. It will take care of populating the HTTP request tracking fields on the `AuditLog` record. If you want a totally custom implementation, you only need to implement the `IAuditOperationContext` interface. The operation context class passed to `WithAugmentation` will be injected from the application service provider if available; otherwise, a new instance will be constructed using dependencies from the application service provider. To make an injected dependency optional, make the constructor parameter nullable with a default value of `null`, or create [alternate constructors](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#multiple-constructor-discovery-rules). @@ -94,7 +96,7 @@ import { CAdminAuditLogPage } from 'coalesce-vue-vuetify3'; { path: '/admin/audit-logs', component: CAdminAuditLogPage, - props: { type: 'ObjectChange' } + props: { type: 'AuditLog' } } ``` @@ -106,7 +108,7 @@ You can turn audit logging on or off for individual operations by implementing t ``` c# [Coalesce] -public class AppDbContext : DbContext, IAuditLogContext +public class AppDbContext : DbContext, IAuditLogContext { ... public bool SuppressAudit { get; set; } @@ -117,14 +119,14 @@ public class AppDbContext : DbContext, IAuditLogContext Coalesce's audit logging is built on top of [Entity Framework Plus](https://entityframework-plus.net/ef-core-audit) and can be configured using all of its [configuration](https://entityframework-plus.net/ef-core-audit#scenarios), including [includes/excludes](https://entityframework-plus.net/ef-core-audit-exclude-include-entity) and [custom property formatting](https://entityframework-plus.net/ef-core-audit-format-value). -While Coalesce will respect EF Plus's global configuration, it is recommended that you instead use Coalesce's configuration extensions which allow for more targeted configuration that does not rely on a global static singleton. For example: +Coalesce will not use EF Plus's `AuditManager.DefaultConfiguration` global singleton instance. You must use Coalesce's configuration extensions which allow for more targeted configuration per context that does not rely on a global static singleton. For example: ``` c# -public class AppDbContext : DbContext, IAuditLogContext +public class AppDbContext : DbContext, IAuditLogContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseCoalesceAuditLogging(x => x + optionsBuilder.UseCoalesceAuditLogging(x => x .WithAugmentation() .ConfigureAudit(c => c .Exclude() @@ -137,14 +139,21 @@ public class AppDbContext : DbContext, IAuditLogContext } ``` -If you use `ConfigureAudit`, `AuditManager.DefaultConfiguration` will not be used. +### Property Descriptions + +The `AuditLogProperty` children of your `IAuditLog` implementation have two properties `OldValueDescription` and `NewValueDescription` that can be used to hold a description of the old and new values. By default, Coalesce will populate the descriptions of foreign key properties with the [List Text](/modeling/model-components/attributes/list-text.md) of the referenced principal entity. This greatly improves the usability of the audit logs, which would otherwise only show meaningless numbers or GUIDs for foreign keys that changed. + +This feature will load principal entities into the `DbContext` if they are not already loaded, which could inflict subtle differences in application functionality in rare edge cases if your application is making assumptions about navigation properties not being loaded. Typically though, this will not be an issue and will not lead unintentional information disclosure to clients as along as [IncludeTree](/concepts/include-tree.md)s are used correctly. + +This feature may be disabled by calling `.WithPropertyDescriptions(PropertyDescriptionMode.None)` inside your call to `.UseCoalesceAuditLogging(...)` in your DbContext configuration. You may also populate these descriptions in your `IAuditOperationContext` implementation that was provided to `.WithAugmentation()`. + ## Merging When using a supported database provider (currently only SQL Server), audit records for changes to the same entity will be merged together when the change is identical in all aspects to the previous audit record for that entity, with the sole exception of the old/new property values. In other words, if the same user is making repeated changes to the same property on the same entity from the same page, then those changes will merge together into one audit record. -This merging only happens together if the existing audit record is recent; the default cutoff for this is 30 seconds, but can be configured with `.WithMergeWindow(TimeSpan.FromSeconds(15))` when calling `UseCoalesceAuditLogging`. It can also be turned off by setting this value to `TimeSpan.Zero`. The merging logic respects all custom properties you add to your `IObjectChange` implementation, requiring their values to match between the existing and new audit records for a merge to occur. +This merging only happens together if the existing audit record is recent; the default cutoff for this is 30 seconds, but can be configured with `.WithMergeWindow(TimeSpan.FromSeconds(15))` when calling `UseCoalesceAuditLogging`. It can also be turned off by setting this value to `TimeSpan.Zero`. The merging logic respects all custom properties you add to your `IAuditLog` implementation, requiring their values to match between the existing and new audit records for a merge to occur. ## Caveats Only changes that are tracked by the `DbContext`'s `ChangeTracker` can be audited. Changes that are made with raw SQL, or changes that are made with bulk update functions like [`ExecuteUpdate` or `ExecuteDelete`](https://learn.microsoft.com/en-us/ef/core/performance/efficient-updating?tabs=ef7) will not be audited using this package. diff --git a/playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.Designer.cs b/playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.Designer.cs similarity index 98% rename from playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.Designer.cs rename to playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.Designer.cs index 8d3659cb8..4d5477f94 100644 --- a/playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.Designer.cs +++ b/playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.Designer.cs @@ -12,7 +12,7 @@ namespace Coalesce.Domain.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20231023220647_AddAuditLogging")] + [Migration("20231024221657_AddAuditLogging")] partial class AddAuditLogging { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -324,9 +324,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("NewValue") .HasColumnType("nvarchar(max)"); + b.Property("NewValueDescription") + .HasColumnType("nvarchar(max)"); + b.Property("OldValue") .HasColumnType("nvarchar(max)"); + b.Property("OldValueDescription") + .HasColumnType("nvarchar(max)"); + b.Property("ParentId") .HasColumnType("bigint"); diff --git a/playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.cs b/playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.cs similarity index 95% rename from playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.cs rename to playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.cs index 2dbfaf38e..37fc065f5 100644 --- a/playground/Coalesce.Domain/Migrations/20231023220647_AddAuditLogging.cs +++ b/playground/Coalesce.Domain/Migrations/20231024221657_AddAuditLogging.cs @@ -64,7 +64,9 @@ protected override void Up(MigrationBuilder migrationBuilder) ParentId = table.Column(type: "bigint", nullable: false), PropertyName = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), OldValue = table.Column(type: "nvarchar(max)", nullable: true), - NewValue = table.Column(type: "nvarchar(max)", nullable: true) + OldValueDescription = table.Column(type: "nvarchar(max)", nullable: true), + NewValue = table.Column(type: "nvarchar(max)", nullable: true), + NewValueDescription = table.Column(type: "nvarchar(max)", nullable: true) }, constraints: table => { diff --git a/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs b/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs index b1ee19f22..070e9cd14 100644 --- a/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs +++ b/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs @@ -322,9 +322,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("NewValue") .HasColumnType("nvarchar(max)"); + b.Property("NewValueDescription") + .HasColumnType("nvarchar(max)"); + b.Property("OldValue") .HasColumnType("nvarchar(max)"); + b.Property("OldValueDescription") + .HasColumnType("nvarchar(max)"); + b.Property("ParentId") .HasColumnType("bigint"); diff --git a/playground/Coalesce.Domain/Person.cs b/playground/Coalesce.Domain/Person.cs index eaff42e74..24df14067 100644 --- a/playground/Coalesce.Domain/Person.cs +++ b/playground/Coalesce.Domain/Person.cs @@ -115,7 +115,7 @@ public Person() /// [ListText] [NotMapped] - public string Name => $"{Title} {FirstName} {LastName}"; + public string Name => $"{Title} {FirstName} {LastName}".Trim(); /// /// Company ID this person is employed by diff --git a/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogPropertyDto.g.cs b/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogPropertyDto.g.cs index 932d4a38b..5f59aa1a2 100644 --- a/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogPropertyDto.g.cs +++ b/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogPropertyDto.g.cs @@ -16,7 +16,9 @@ public AuditLogPropertyDtoGen() { } private long? _ParentId; private string _PropertyName; private string _OldValue; + private string _OldValueDescription; private string _NewValue; + private string _NewValueDescription; public long? Id { @@ -38,11 +40,21 @@ public string OldValue get => _OldValue; set { _OldValue = value; Changed(nameof(OldValue)); } } + public string OldValueDescription + { + get => _OldValueDescription; + set { _OldValueDescription = value; Changed(nameof(OldValueDescription)); } + } public string NewValue { get => _NewValue; set { _NewValue = value; Changed(nameof(NewValue)); } } + public string NewValueDescription + { + get => _NewValueDescription; + set { _NewValueDescription = value; Changed(nameof(NewValueDescription)); } + } /// /// Map from the domain object to the properties of the current DTO instance. @@ -56,7 +68,9 @@ public override void MapFrom(IntelliTect.Coalesce.AuditLogging.AuditLogProperty this.ParentId = obj.ParentId; this.PropertyName = obj.PropertyName; this.OldValue = obj.OldValue; + this.OldValueDescription = obj.OldValueDescription; this.NewValue = obj.NewValue; + this.NewValueDescription = obj.NewValueDescription; } /// @@ -72,7 +86,9 @@ public override void MapTo(IntelliTect.Coalesce.AuditLogging.AuditLogProperty en if (ShouldMapTo(nameof(ParentId))) entity.ParentId = (ParentId ?? entity.ParentId); if (ShouldMapTo(nameof(PropertyName))) entity.PropertyName = PropertyName; if (ShouldMapTo(nameof(OldValue))) entity.OldValue = OldValue; + if (ShouldMapTo(nameof(OldValueDescription))) entity.OldValueDescription = OldValueDescription; if (ShouldMapTo(nameof(NewValue))) entity.NewValue = NewValue; + if (ShouldMapTo(nameof(NewValueDescription))) entity.NewValueDescription = NewValueDescription; } /// @@ -91,7 +107,9 @@ public override IntelliTect.Coalesce.AuditLogging.AuditLogProperty MapToNew(IMap if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(ParentId))) entity.ParentId = (ParentId ?? entity.ParentId); if (ShouldMapTo(nameof(OldValue))) entity.OldValue = OldValue; + if (ShouldMapTo(nameof(OldValueDescription))) entity.OldValueDescription = OldValueDescription; if (ShouldMapTo(nameof(NewValue))) entity.NewValue = NewValue; + if (ShouldMapTo(nameof(NewValueDescription))) entity.NewValueDescription = NewValueDescription; return entity; } diff --git a/playground/Coalesce.Web.Vue3/src/metadata.g.ts b/playground/Coalesce.Web.Vue3/src/metadata.g.ts index 54cf67c60..6397af90f 100644 --- a/playground/Coalesce.Web.Vue3/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue3/src/metadata.g.ts @@ -287,12 +287,24 @@ export const AuditLogProperty = domain.types.AuditLogProperty = { type: "string", role: "value", }, + oldValueDescription: { + name: "oldValueDescription", + displayName: "Old Value Description", + type: "string", + role: "value", + }, newValue: { name: "newValue", displayName: "New Value", type: "string", role: "value", }, + newValueDescription: { + name: "newValueDescription", + displayName: "New Value Description", + type: "string", + role: "value", + }, }, methods: { }, diff --git a/playground/Coalesce.Web.Vue3/src/models.g.ts b/playground/Coalesce.Web.Vue3/src/models.g.ts index 0daefde2e..1c97860e7 100644 --- a/playground/Coalesce.Web.Vue3/src/models.g.ts +++ b/playground/Coalesce.Web.Vue3/src/models.g.ts @@ -81,7 +81,9 @@ export interface AuditLogProperty extends Model implements $models.AuditLogProperty { diff --git a/src/IntelliTect.Coalesce.AuditLogging.Tests/AuditTests.cs b/src/IntelliTect.Coalesce.AuditLogging.Tests/AuditTests.cs index 3a660c634..0d0677fcc 100644 --- a/src/IntelliTect.Coalesce.AuditLogging.Tests/AuditTests.cs +++ b/src/IntelliTect.Coalesce.AuditLogging.Tests/AuditTests.cs @@ -1,4 +1,3 @@ -using IntelliTect.Coalesce.AuditLogging; using IntelliTect.Coalesce.Utilities; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -7,7 +6,6 @@ using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using System.Collections.Generic; using System.Runtime.CompilerServices; using Z.EntityFramework.Plus; @@ -205,6 +203,209 @@ await RunBasicTest(db, async: true, ); } + + [Theory] + [InlineData(PropertyDescriptionMode.None, null)] + [InlineData(PropertyDescriptionMode.FkListText, "ListTextA")] + public async Task PropertyDesc_RespectsConfig(PropertyDescriptionMode mode, string? expected) + { + // Arrange + using var db = BuildDbContext(b => b + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + .WithPropertyDescriptions(mode) + )); + + var user = new AppUser { Name = "bob", Parent1 = new() { CustomListTextField = "ListTextA" } }; + db.Add(user); + await db.SaveChangesAsync(); + + var log = db.AuditLogs.Include(l => l.Properties).Single(e => e.Type == nameof(AppUser)); + var typeChangeProp = Assert.Single(log.Properties!.Where(p => p.PropertyName == nameof(AppUser.Parent1Id))); + + Assert.Equal(expected, typeChangeProp.NewValueDescription); + } + + [Fact] + public async Task PropertyDesc_PopulatesValuesForMappedListText() + { + // Arrange + using var db = BuildDbContext(b => b + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + )); + + db.SuppressAudit = true; + db.Add(new ParentWithMappedListText { Id = "A", CustomListTextField = "ListTextA" }); + db.Add(new ParentWithMappedListText { Id = "B", CustomListTextField = "ListTextB" }); + db.SaveChanges(); + db.SuppressAudit = false; + + // Act/Assert: Insert + Cleanup(); + var user = new AppUser { Name = "bob", Parent1Id = "A" }; + db.Add(user); + await db.SaveChangesAsync(); + + var typeChangeProp = GetAuditLogProp(); + Assert.Null(typeChangeProp.OldValueDescription); + Assert.Equal("ListTextA", typeChangeProp.NewValueDescription); + + + // Act/Assert: Update + Cleanup(); + user = db.Users.Single(); + user.Parent1Id = "B"; + await db.SaveChangesAsync(); + + typeChangeProp = GetAuditLogProp(); + Assert.Equal("ListTextA", typeChangeProp.OldValueDescription); + Assert.Equal("ListTextB", typeChangeProp.NewValueDescription); + + + // Act/Assert: Delete + Cleanup(); + db.Remove(user); + await db.SaveChangesAsync(); + + typeChangeProp = GetAuditLogProp(); + Assert.Equal("ListTextB", typeChangeProp.OldValueDescription); + Assert.Null(typeChangeProp.NewValueDescription); + + void Cleanup() + { + db.AuditLogs.Delete(); + db.ChangeTracker.Clear(); + } + AuditLogProperty GetAuditLogProp() + { + var log = db.AuditLogs.Include(l => l.Properties).Single(e => e.Type == nameof(AppUser)); + return Assert.Single(log.Properties!.Where(p => p.PropertyName == nameof(AppUser.Parent1Id))); + } + } + + [Fact] + public void PropertyDesc_PopulatesValuesCorrectlyWhenPrincipalAlsoChanges() + { + // Arrange + using var db = BuildDbContext(b => b + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + )); + + db.SuppressAudit = true; + var parentA = new ParentWithMappedListText { Id = "A", CustomListTextField = "ListTextA" }; + db.Add(parentA); + var parentB = new ParentWithMappedListText { Id = "B", CustomListTextField = "ListTextB" }; + db.Add(parentB); + var user = new AppUser { Name = "bob", Parent1Id = "A" }; + db.Add(user); + db.SaveChanges(); + db.SuppressAudit = false; + + // Act/Assert: Insert + user.Parent1Id = "B"; + parentA.CustomListTextField = "NewListTextA"; + parentB.CustomListTextField = "NewListTextB"; + db.SaveChanges(); + + var typeChangeProp = GetAuditLogProp(); + Assert.Equal("ListTextA", typeChangeProp.OldValueDescription); + Assert.Equal("NewListTextB", typeChangeProp.NewValueDescription); + + AuditLogProperty GetAuditLogProp() + { + var log = db.AuditLogs.Include(l => l.Properties).Single(e => e.Type == nameof(AppUser)); + return Assert.Single(log.Properties!.Where(p => p.PropertyName == nameof(AppUser.Parent1Id))); + } + } + + [Fact] + public void PropertyDesc_PopulatesValuesForUnMappedListText() + { + // Arrange + using var db = BuildDbContext(b => b + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + )); + + db.SuppressAudit = true; + db.Add(new ParentWithUnMappedListText { Id = "A", Name = "ThingA" }); + db.Add(new ParentWithUnMappedListText { Id = "B", Name = "ThingB" }); + db.SaveChanges(); + db.SuppressAudit = false; + + // Act/Assert: Insert + Cleanup(); + var user = new AppUser { Name = "bob", Parent2Id = "A" }; + db.Add(user); + db.SaveChanges(); + + var typeChangeProp = GetAuditLogProp(); + Assert.Null(typeChangeProp.OldValueDescription); + Assert.Equal("Name:ThingA", typeChangeProp.NewValueDescription); + + + // Act/Assert: Update + Cleanup(); + user = db.Users.Single(); + user.Parent2Id = "B"; + db.SaveChanges(); + + typeChangeProp = GetAuditLogProp(); + Assert.Equal("Name:ThingA", typeChangeProp.OldValueDescription); + Assert.Equal("Name:ThingB", typeChangeProp.NewValueDescription); + + + // Act/Assert: Delete + Cleanup(); + db.Remove(user); + db.SaveChanges(); + + typeChangeProp = GetAuditLogProp(); + Assert.Equal("Name:ThingB", typeChangeProp.OldValueDescription); + Assert.Null(typeChangeProp.NewValueDescription); + + void Cleanup() + { + db.AuditLogs.Delete(); + db.ChangeTracker.Clear(); + } + AuditLogProperty GetAuditLogProp() + { + var log = db.AuditLogs.Include(l => l.Properties).Single(e => e.Type == nameof(AppUser)); + return Assert.Single(log.Properties!.Where(p => p.PropertyName == nameof(AppUser.Parent2Id))); + } + } + + [Fact] + public async Task PropertyDesc_OnlyLoadsPrincipalWhenChanged() + { + // Arrange + using var db = BuildDbContext(b => b + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + )); + + db.SuppressAudit = true; + var user = new AppUser { Name = "bob", Parent1 = new() { CustomListTextField = "ListTextA" } }; + db.Add(user); + await db.SaveChangesAsync(); + db.SuppressAudit = false; + + // Act + db.ChangeTracker.Clear(); + user = db.Users.Single(); + user.Name = "bob2"; + await db.SaveChangesAsync(); + + // Assert + // Navigation prop should not be loaded because it wasn't changed. + // This ensures we don't waste database calls loading principal entities for no reason. + Assert.Empty(db.ParentWithMappedListTexts.Local); + Assert.Null(user.Parent1); + } + private WebApplicationBuilder CreateAppBuilder() { var builder = WebApplication.CreateBuilder(); diff --git a/src/IntelliTect.Coalesce.AuditLogging.Tests/TestDbContext.cs b/src/IntelliTect.Coalesce.AuditLogging.Tests/TestDbContext.cs index 0e719ad54..c6d85ec21 100644 --- a/src/IntelliTect.Coalesce.AuditLogging.Tests/TestDbContext.cs +++ b/src/IntelliTect.Coalesce.AuditLogging.Tests/TestDbContext.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.EntityFrameworkCore; namespace IntelliTect.Coalesce.AuditLogging.Tests; @@ -9,6 +10,9 @@ public TestDbContext(DbContextOptions options) : base(options) } public DbSet Users => Set(); + public DbSet ParentWithMappedListTexts => Set(); + public DbSet ParentWithUnMappedListTexts => Set(); + public DbSet AuditLogs => Set(); public DbSet AuditLogProperties => Set(); @@ -20,6 +24,30 @@ class AppUser public string Id { get; set; } = Guid.NewGuid().ToString(); public string? Name { get; set; } public string? Title { get; set; } + + public string? Parent1Id { get; set; } + public ParentWithMappedListText? Parent1 { get; set; } + + public string? Parent2Id { get; set; } + public ParentWithUnMappedListText? Parent2 { get; set; } +} + +class ParentWithMappedListText +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [ListText] + public string CustomListTextField { get; set; } = null!; +} + +class ParentWithUnMappedListText +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + public string? Name { get; set; } + + [ListText] + public string CustomListTextField => "Name:" + Name; } internal class TestAuditLog : DefaultAuditLog diff --git a/src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditOptions.cs b/src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditOptions.cs index 847868172..3431fa99d 100644 --- a/src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditOptions.cs +++ b/src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditOptions.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.ChangeTracking; +using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.EntityFrameworkCore.ChangeTracking; using System; using Z.EntityFramework.Plus; @@ -20,13 +21,40 @@ public class AuditOptions /// /// The default is 30 seconds. /// - public TimeSpan MergeWindow { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan MergeWindow { get; internal set; } = TimeSpan.FromSeconds(30); public Type? OperationContextType { get; internal set; } + public PropertyDescriptionMode PropertyDescriptions { get; internal set; } = PropertyDescriptionMode.FkListText; + /// /// Internal so that it cannot be modified in a way that breaks the caching assumptions /// that we make in CoalesceAuditLoggingBuilder. /// internal AuditConfiguration? AuditConfiguration { get; set; } = null; +} + +/// +/// Controls how and +/// are populated by the framework. +/// +[Flags] +public enum PropertyDescriptionMode +{ + /// + /// Properties will not be populated by the framework. + /// + None = 0, + + // Core modes: + /// + /// Descriptions for foreign key properties will be populating from the List Text property of the principal entity. + /// The list text of an entity can be customized by placing on a property, + /// and defaults to a property named "Name" if one exists. + /// + FkListText = 1 << 0, + + // Extra options: bits 20+ + // Future: untrack entities that we had to load because they weren't already in the context? + //AutoUntrack = 1 << 20, } \ No newline at end of file diff --git a/src/IntelliTect.Coalesce.AuditLogging/Configuration/CoalesceAuditLoggingBuilder.cs b/src/IntelliTect.Coalesce.AuditLogging/Configuration/CoalesceAuditLoggingBuilder.cs index 76425438d..87fdd5e62 100644 --- a/src/IntelliTect.Coalesce.AuditLogging/Configuration/CoalesceAuditLoggingBuilder.cs +++ b/src/IntelliTect.Coalesce.AuditLogging/Configuration/CoalesceAuditLoggingBuilder.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Caching.Memory; +using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.Extensions.Caching.Memory; using System; using System.Collections.Concurrent; using System.Reflection; @@ -38,6 +39,21 @@ public CoalesceAuditLoggingBuilder WithMergeWindow(TimeSpan timeSpan) return this; } + /// + /// + /// Control how and + /// are populated by the framework. + /// + /// + /// The default behavior, , will result foreign key properties + /// being described by the list text (as defined by ) of their referenced principal entity. + /// + /// + public CoalesceAuditLoggingBuilder WithPropertyDescriptions(PropertyDescriptionMode mode) + { + options.PropertyDescriptions = mode; + return this; + } private static readonly MemoryCache _auditConfigTransforms = new(new MemoryCacheOptions { SizeLimit = 512 }); diff --git a/src/IntelliTect.Coalesce.AuditLogging/Configuration/DbContextOptionsBuilderExtensions.cs b/src/IntelliTect.Coalesce.AuditLogging/Configuration/DbContextOptionsBuilderExtensions.cs index c179a2792..a9483454b 100644 --- a/src/IntelliTect.Coalesce.AuditLogging/Configuration/DbContextOptionsBuilderExtensions.cs +++ b/src/IntelliTect.Coalesce.AuditLogging/Configuration/DbContextOptionsBuilderExtensions.cs @@ -18,7 +18,13 @@ public static DbContextOptionsBuilder UseCoalesceAuditLogging( { var options = new AuditOptions(); - configure?.Invoke(new(options)); + CoalesceAuditLoggingBuilder auditBuilder = new(options); + auditBuilder = auditBuilder.ConfigureAudit(c => + { + c.Exclude(); + c.Exclude(); + }); + configure?.Invoke(auditBuilder); ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new AuditExtension(options)); diff --git a/src/IntelliTect.Coalesce.AuditLogging/Internal/AuditingInterceptor.cs b/src/IntelliTect.Coalesce.AuditLogging/Internal/AuditingInterceptor.cs index a7cddbba0..91216da26 100644 --- a/src/IntelliTect.Coalesce.AuditLogging/Internal/AuditingInterceptor.cs +++ b/src/IntelliTect.Coalesce.AuditLogging/Internal/AuditingInterceptor.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; using System.Data; using System.Linq; using System.Reflection; @@ -21,7 +21,7 @@ internal sealed class AuditingInterceptor : SaveChangesInterceptor { private readonly AuditOptions _options; - private Audit? _audit; + private CoalesceAudit? _audit; private IAuditLogContext GetContext(DbContextEventData data) => (IAuditLogContext)(data.Context ?? throw new InvalidOperationException("DbContext unavailable.")); @@ -47,19 +47,24 @@ private void AttachConfig(Audit audit) m_auditConfiguration.SetValue(audit, configLazy); } - public override ValueTask> SavingChangesAsync( + public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { _audit = null; - if (GetContext(eventData).SuppressAudit) return ValueTask.FromResult(result); - - _audit = new Audit(); + if (GetContext(eventData).SuppressAudit) return result; + + _audit = new CoalesceAudit(); AttachConfig(_audit); _audit.PreSaveChanges(eventData.Context); - return ValueTask.FromResult(result); + if (_options.PropertyDescriptions.HasFlag(PropertyDescriptionMode.FkListText)) + { + await _audit.PopulateOldDescriptions(eventData.Context!, true); + } + + return result; } public override InterceptionResult SavingChanges( @@ -69,10 +74,17 @@ public override InterceptionResult SavingChanges( _audit = null; if (GetContext(eventData).SuppressAudit) return result; - _audit = new Audit(); + _audit = new CoalesceAudit(); AttachConfig(_audit); _audit.PreSaveChanges(eventData.Context); + if (_options.PropertyDescriptions.HasFlag(PropertyDescriptionMode.FkListText)) + { +#pragma warning disable CS4014 // Executes synchonously when async: false + _audit.PopulateOldDescriptions(eventData.Context!, async: false); +#pragma warning restore CS4014 + } + return result; } @@ -83,7 +95,9 @@ public override int SavedChanges(SaveChangesCompletedEventData eventData, int re { if (_audit is null) return result; +#pragma warning disable CS4014 // Executes synchonously when async: false SaveAudit(eventData.Context!, _audit, async: false); +#pragma warning restore CS4014 return result; } @@ -136,10 +150,9 @@ static string BuildSqlServerSql(IModel model) var customProps = props.ExceptBy(basePropNames, p => p.PropertyInfo?.Name); var cursorProps = customProps.Concat(props.Where(p => p.Name is nameof(IAuditLog.State) or nameof(IAuditLog.Date) or nameof(IAuditLog.Type) or nameof(IAuditLog.KeyValue))); - string GetColName(string propName, IEntityType table) => table + string GetColName(string propName, IEntityType table) => GetPropColName(table .GetDeclaredProperties() - .Single(p => p.Name == propName) - .GetColumnName(StoreObjectIdentifier.Create(table, StoreObjectType.Table)!.Value)!; + .Single(p => p.Name == propName), table); string GetPropColName(IProperty prop, IEntityType table) => prop .GetColumnName(StoreObjectIdentifier.Create(table, StoreObjectType.Table)!.Value)!; @@ -152,6 +165,11 @@ string GetPropColName(IProperty prop, IEntityType table) => prop string propFkCol = GetColName(nameof(AuditLogProperty.ParentId), propEntityType); string propPkCol = GetColName(nameof(AuditLogProperty.Id), propEntityType); + string propNameCol = GetColName(nameof(AuditLogProperty.PropertyName), propEntityType); + string propOldCol = GetColName(nameof(AuditLogProperty.OldValue), propEntityType); + string propOldDescCol = GetColName(nameof(AuditLogProperty.OldValueDescription), propEntityType); + string propNewCol = GetColName(nameof(AuditLogProperty.NewValue), propEntityType); + string propNewDescCol = GetColName(nameof(AuditLogProperty.NewValueDescription), propEntityType); return $""" SET NOCOUNT ON; @@ -231,17 +249,19 @@ ELSE 0 MERGE {propTableName} USING ( - SELECT PropertyName, NewValue + SELECT PropertyName, NewValue, NewValueDescription FROM OPENJSON(@Properties) with ( PropertyName [nvarchar](max) '$.PropertyName', OldValue [nvarchar](max) '$.OldValue', - NewValue [nvarchar](max) '$.NewValue' + OldValueDescription [nvarchar](max) '$.OldValueDescription', + NewValue [nvarchar](max) '$.NewValue', + NewValueDescription [nvarchar](max) '$.NewValueDescription' ) ) AS Source - ON {propFkCol} = @mostRecentAuditLogId AND {propTableName}.[PropertyName] = Source.PropertyName + ON {propFkCol} = @mostRecentAuditLogId AND {propTableName}.{propNameCol} = Source.PropertyName WHEN MATCHED THEN -- DO NOT UPDATE OldValue! It stays the same so we represent the transition from the original OldValue to the new NewValue. - UPDATE SET NewValue = Source.NewValue + UPDATE SET {propNewCol} = Source.NewValue, {propNewDescCol} = Source.NewValueDescription ; END; ELSE @@ -255,12 +275,14 @@ INSERT INTO {tableName} SET @mostRecentAuditLogId = SCOPE_IDENTITY(); INSERT INTO {propTableName} - ({propFkCol}, PropertyName, OldValue, NewValue) - SELECT @mostRecentAuditLogId, PropertyName, OldValue, NewValue + ({propFkCol}, {propNameCol}, {propOldCol}, {propOldDescCol}, {propNewCol}, {propNewDescCol}) + SELECT @mostRecentAuditLogId, PropertyName, OldValue, OldValueDescription, NewValue, NewValueDescription FROM OPENJSON (@Properties) WITH ( PropertyName [nvarchar](max) '$.PropertyName', OldValue [nvarchar](max) '$.OldValue', - NewValue [nvarchar](max) '$.NewValue' + OldValueDescription [nvarchar](max) '$.OldValueDescription', + NewValue [nvarchar](max) '$.NewValue', + NewValueDescription [nvarchar](max) '$.NewValueDescription' ); END; @@ -272,10 +294,15 @@ NewValue [nvarchar](max) '$.NewValue' } } - private Task SaveAudit(DbContext db, Audit audit, bool async) + private async ValueTask SaveAudit(DbContext db, CoalesceAudit audit, bool async) { audit.PostSaveChanges(); + if (_options.PropertyDescriptions.HasFlag(PropertyDescriptionMode.FkListText)) + { + await audit.PopulateNewDescriptions(db, async); + } + var serviceProvider = new EntityFrameworkServiceProvider(db); var operationContext = _options.OperationContextType is null ? null @@ -301,17 +328,19 @@ private Task SaveAudit(DbContext db, Audit audit, bool async) auditLog.Type = e.EntityTypeName; auditLog.KeyValue = keyProperties is null ? null : string.Join(";", keyProperties.Select(p => e.Entry.CurrentValues[p])); auditLog.Properties = e.Properties - .Where(property => property.OldValueFormatted != property.NewValueFormatted) .Select(property => { - var prop = new AuditLogProperty + return new AuditLogProperty { PropertyName = property.PropertyName, OldValue = property.OldValueFormatted, - NewValue = property.NewValueFormatted + OldValueDescription = audit.OldValueDescriptions?.GetValueOrDefault((e, property.PropertyName)), + NewValue = property.NewValueFormatted, + NewValueDescription = audit.NewValueDescriptions?.GetValueOrDefault((e, property.PropertyName)) }; - return prop; - }).ToList(); + }) + .Where(property => property.OldValue != property.NewValue || property.OldValueDescription != property.NewValueDescription) + .ToList(); operationContext?.Populate(auditLog, e.Entry); @@ -321,7 +350,7 @@ private Task SaveAudit(DbContext db, Audit audit, bool async) if (auditLogs.Count == 0) { - return Task.CompletedTask; + return; } if (_options.MergeWindow > TimeSpan.Zero && db.Database.ProviderName == "Microsoft.EntityFrameworkCore.SqlServer") @@ -346,12 +375,11 @@ private Task SaveAudit(DbContext db, Audit audit, bool async) if (async) { - return db.Database.ExecuteSqlRawAsync(cmd.CommandText, jsonParam, mergeWindowParam); + await db.Database.ExecuteSqlRawAsync(cmd.CommandText, jsonParam, mergeWindowParam); } else { db.Database.ExecuteSqlRaw(cmd.CommandText, jsonParam, mergeWindowParam); - return Task.CompletedTask; } } else @@ -363,12 +391,11 @@ private Task SaveAudit(DbContext db, Audit audit, bool async) if (async) { - return db.SaveChangesAsync(); + await db.SaveChangesAsync(); } else { db.SaveChanges(); - return Task.CompletedTask; } } } diff --git a/src/IntelliTect.Coalesce.AuditLogging/Internal/CoalesceAudit.cs b/src/IntelliTect.Coalesce.AuditLogging/Internal/CoalesceAudit.cs new file mode 100644 index 000000000..7ac00439f --- /dev/null +++ b/src/IntelliTect.Coalesce.AuditLogging/Internal/CoalesceAudit.cs @@ -0,0 +1,172 @@ +using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Z.EntityFramework.Plus; + +using DescriptionStore = System.Collections.Generic.Dictionary<(Z.EntityFramework.Plus.AuditEntry, string), string?>; + +namespace IntelliTect.Coalesce.AuditLogging.Internal; + +internal class CoalesceAudit : Audit +{ + internal DescriptionStore? OldValueDescriptions; + internal DescriptionStore? NewValueDescriptions; + private HashSet<(AuditEntry, INavigationBase)>? HasChanged; + + internal async ValueTask PopulateOldDescriptions(DbContext db, bool async) + { + OldValueDescriptions = []; + foreach (var entry in Entries) + { + if (entry.State == Z.EntityFramework.Plus.AuditEntryState.EntityAdded) continue; + foreach (var refNav in entry.Entry.References) + { + if (entry.State == Z.EntityFramework.Plus.AuditEntryState.EntityModified) + { + if (!refNav.IsModified) continue; + + (HasChanged ??= new()).Add((entry, refNav.Metadata)); + } + + await PopulateDescriptions(db, entry, refNav, OldValueDescriptions, isNew: false, async); + } + } + } + + internal async ValueTask PopulateNewDescriptions(DbContext db, bool async) + { + NewValueDescriptions = []; + foreach (var entry in Entries) + { + if (entry.State == Z.EntityFramework.Plus.AuditEntryState.EntityDeleted) continue; + foreach (var refNav in entry.Entry.References) + { + if (entry.State == Z.EntityFramework.Plus.AuditEntryState.EntityModified) + { + // We're capturing the new descriptions, but the navigation wasn't modified. Do nothing. + if (HasChanged?.Contains((entry, refNav.Metadata)) != true) continue; + } + + await PopulateDescriptions(db, entry, refNav, NewValueDescriptions, isNew: true, async); + } + } + } + + private async ValueTask PopulateDescriptions( + DbContext db, + AuditEntry entry, + ReferenceEntry refNav, + DescriptionStore descriptionStore, + bool isNew, + bool async) + { + var meta = (INavigation)refNav.Metadata; + + if (!meta.ForeignKey.PrincipalKey.IsPrimaryKey()) + { + // There's not a great way to look up entries by values other than their PK. + return; + } + + var auditProp = meta.ForeignKey.Properties + // For composite FKs, take the last part of the FK + // as it is the part that is most likely to be specific + // to the principal entity and not reused. + // The only scenario where we currently would expect a composite FK is + // in a multitenant app where TenantId has been made to be the first part of each FK. + // FUTURE: We could instead prioritize the FK props that are not [InternalUse], + // or the props that belong to the fewest number of FKs (TenantId in this example would always belong to at least 2 FKs). + .Reverse() + .Select(p => p.Name) + .FirstOrDefault(); + + if (auditProp is null) + { + return; + } + + var targetEntityType = meta.TargetEntityType; + var targetClrType = targetEntityType.ClrType; + + var targetClassVm = ReflectionRepository.Global.GetOrAddType(targetClrType).ClassViewModel; + + // If the list text for the target is the PK, + // the description won't be useful as it'll just duplicate the value prop. + if (targetClassVm?.ListTextProperty is not { IsPrimaryKey: false } targetListText) return; + + object[] keyValues = new object[meta.ForeignKey.Properties.Count]; + int i = 0; + foreach (var prop in meta.ForeignKey.Properties) + { + object? propVal = isNew + ? entry.Entry.CurrentValues[prop] + : entry.Entry.OriginalValues[prop]; + if (propVal is null) + { + // Foreign key value is null, so the principal doesn't exist and we can't get ListText from it. + return; + } + + keyValues[i++] = propVal; + } + + descriptionStore[(entry, auditProp)] = await GetListTextValue(db, targetClrType, keyValues, targetListText.PropertyInfo, async); + } + + private static async ValueTask GetListTextValue(DbContext db, Type entityType, object[] keys, PropertyInfo prop, bool async) + { + // Conundrum: While it is super easy to just call .Find(), + // if the entity isn't already tracked then it'll be loaded into the context, + // which in some incredibly rare scenarios might alter application behavior via + // navigation fixup in a way that a dev might not expect. + + // We could determine if the entity was not previously loaded and became newly tracked + // by comparing change tracker length before and after, and then use that information + // to untrack the entity when we're done. + + //var oldCount = set.Local.Count; + var entity = async + ? await db.FindAsync(entityType, keys) + : db.Find(entityType, keys); + //var newCount = set.Local.Count; + if (entity is null) return null; + var entry = db.Entry(entity); + + if (entry is not null) + { + var propMeta = entry.Metadata.FindProperty(prop.Name); + if (propMeta is not null) + { + // In the case where we're getting the post-save value, + // it is still OK to look at OriginalValues since OriginalValues + // will be the same as CurrentValues after a save. + return entry.OriginalValues[propMeta]?.ToString(); + } + + // The property is not mapped with EF. + // It is likely a getter-only C# property. + // We can't really compute it with `OriginalValues`, + // so while this will usually be accurate, there's no guarantee. + + // This calls into user code where it is very possible to have NREs, + // so we must discard any exceptions. + try + { + return prop.GetValue(entity)?.ToString(); + } + catch + { + return null; + } + } + + return null; + } +} diff --git a/src/IntelliTect.Coalesce.AuditLogging/Models/AuditLogProperty.cs b/src/IntelliTect.Coalesce.AuditLogging/Models/AuditLogProperty.cs index 0af7845b3..2ed013e16 100644 --- a/src/IntelliTect.Coalesce.AuditLogging/Models/AuditLogProperty.cs +++ b/src/IntelliTect.Coalesce.AuditLogging/Models/AuditLogProperty.cs @@ -32,10 +32,18 @@ public class AuditLogProperty /// public string? OldValue { get; set; } + /// + /// Additional descriptive information about . For example, a string describing the value of a foreign key. + /// + public string? OldValueDescription { get; set; } + /// /// For add or modify operations, holds the new value of the property. /// public string? NewValue { get; set; } - // FUTURE?: Add additional fields that get filled with the [ListText] of FK values? Could be used for other descriptive purposes too. + /// + /// Additional descriptive information about . For example, a string describing the value of a foreign key. + /// + public string? NewValueDescription { get; set; } } diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs b/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs index 83de9ebd4..b96a35951 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs @@ -15,6 +15,7 @@ using System.Threading; using IntelliTect.Coalesce.Models; using System.Collections.ObjectModel; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace IntelliTect.Coalesce.TypeDefinition { @@ -118,8 +119,19 @@ public void AddTypes(IEnumerable types) public SymbolTypeViewModel GetOrAddType(ITypeSymbol symbol) => GetOrAddType(symbol, () => new SymbolTypeViewModel(this, symbol)); - public ReflectionTypeViewModel GetOrAddType(Type type) => - GetOrAddType(type, () => new ReflectionTypeViewModel(this, type)); + public ReflectionTypeViewModel GetOrAddType(Type type) + { + // Hot path during runtime - perform the cache check before entering GetOrAddType + // which requires allocating a closure. + if (_allTypeViewModels.TryGetValue(type, out var existing)) return (ReflectionTypeViewModel)existing; + + // Avoid closure on fast path by storing state into scoped locals. + // Technique from https://github.com/dotnet/runtime/blob/4aa4d28f951babd9b26c2e4cff99a3203c56aee8/src/libraries/Microsoft.Extensions.Options/src/OptionsManager.cs#L48 + var localThis = this; + var localType = type; + + return GetOrAddType(type, () => new ReflectionTypeViewModel(this, type)); + } public TypeViewModel GetOrAddType(TypeViewModel type) => GetOrAddType(GetCacheKey(type), () => type); diff --git a/src/IntelliTect.Coalesce/TypeDefinition/Symbol/SymbolTypeViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/Symbol/SymbolTypeViewModel.cs index fc1cd141f..34c07e7b4 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/Symbol/SymbolTypeViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/Symbol/SymbolTypeViewModel.cs @@ -53,7 +53,7 @@ internal SymbolTypeViewModel(ReflectionRepository? reflectionRepository, ITypeSy // These are precomputed because they are used for .Equals() and the == operator. FullyQualifiedName = Symbol.ToDisplayString(DefaultDisplayFormat); VerboseFullyQualifiedName = Symbol.ToDisplayString(VerboseDisplayFormat); - } + } internal static SymbolTypeViewModel GetOrCreate(ReflectionRepository? reflectionRepository, ITypeSymbol symbol) { diff --git a/src/coalesce-vue-vuetify3/src/components/admin/c-admin-audit-log-page.vue b/src/coalesce-vue-vuetify3/src/components/admin/c-admin-audit-log-page.vue index 3174e564d..b9765c5ba 100644 --- a/src/coalesce-vue-vuetify3/src/components/admin/c-admin-audit-log-page.vue +++ b/src/coalesce-vue-vuetify3/src/components/admin/c-admin-audit-log-page.vue @@ -111,68 +111,84 @@

                 
 
                 
-                  
Type: 
-
 Key: 
+
+ " title="Entity Key" /> - + - + - +
Change:
{{ propMeta.displayName }}:
@@ -189,18 +205,33 @@ - + - - + + {{ prop.oldValue }} + + + ({{ prop.oldValueDescription }}) + - - + + {{ prop.newValue }} + + + ({{ prop.newValueDescription }}) + @@ -227,11 +258,12 @@ import { ModelApiClient, ModelReferenceNavigationProperty, ModelType, + propDisplay, useBindToQueryString, ViewModel, } from "coalesce-vue"; -interface ObjectChangeBase { +interface AuditLogBase { $metadata: ModelType; id: number | null; type: string | null; @@ -243,15 +275,17 @@ interface ObjectChangeBase { id: number | null; parentId: number | null; oldValue: string | null; + oldValueDescription: string | null; newValue: string | null; + newValueDescription: string | null; }[]; } -type ObjectChangeViewModel = ViewModel & ObjectChangeBase; -type ObjectChangeListViewModel = ListViewModel< - ObjectChangeBase, - ModelApiClient, - ObjectChangeViewModel +type AuditLogViewModel = ViewModel & AuditLogBase; +type AuditLogListViewModel = ListViewModel< + AuditLogBase, + ModelApiClient, + AuditLogViewModel >; const props = withDefaults( @@ -263,7 +297,7 @@ const props = withDefaults( { color: "primary" } ); -let list: ObjectChangeListViewModel; +let list: AuditLogListViewModel; if (props.list) { list = props.list as any; } else { @@ -303,10 +337,7 @@ const otherProps = computed(() => { ); }); -function timeDiff( - current: ObjectChangeViewModel, - older?: ObjectChangeViewModel -) { +function timeDiff(current: AuditLogViewModel, older?: AuditLogViewModel) { if (!older) return ""; let ms = differenceInMilliseconds(current.date!, older.date!); const positive = ms >= 0; @@ -326,10 +357,7 @@ function timeDiff( ); } -function timeDiffClass( - current: ObjectChangeViewModel, - older?: ObjectChangeViewModel -) { +function timeDiffClass(current: AuditLogViewModel, older?: AuditLogViewModel) { if (!older) return ""; const diff = current.date!.valueOf() - (older?.date ?? 0).valueOf(); return diff == 0 ? "grey--text" : diff > 0 ? "text-success" : "text-error";