From c4005a315b5a8ae33cf6906e8f882ab223886387 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 6 Sep 2026 18:10:14 +0300 Subject: [PATCH 1/2] feat: let CapturePendingAuditDiffs filter entities and properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional AuditOptions so a caller can exclude whole entities and individual properties. Applied before the entity state is considered, so an excluded property is absent from an Added snapshot too — otherwise a secret would still be recorded in full on insert. Both filters default to null, so existing callers are unaffected. Also syncs the PgSql copy with the base one, which had drifted to DateTime. --- .../AuditBuilderExtensions.cs | 50 +++++- SW.EfCoreExtensions.UnitTests/AuditTests.cs | 142 ++++++++++++++++++ SW.EfCoreExtensions/AuditBuilderExtensions.cs | 44 +++++- docs/AUDIT.md | 43 +++++- 4 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 SW.EfCoreExtensions.UnitTests/AuditTests.cs diff --git a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs index 8d82cba..39725df 100644 --- a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs +++ b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using SW.PrimitiveTypes; namespace SW.EfCoreExtensions; @@ -34,7 +35,7 @@ public sealed record PropertyDiff( public sealed record GenericAuditDiffJson( string CorrelationId, int Sequence, - DateTime Timestamp, + DateTimeOffset Timestamp, string? UserId, string EntityName, string EntityType, @@ -78,7 +79,7 @@ public sealed class PendingAuditEntry /// /// Gets or sets the UTC timestamp when this change was captured. /// - public DateTime Timestamp { get; init; } + public DateTimeOffset Timestamp { get; init; } /// /// Gets or sets the optional identifier of the user or actor who made the change. @@ -118,6 +119,34 @@ public sealed class PendingAuditEntry } +/// +/// Controls what records. Both filters +/// default to null, which captures every changed entity and every one of its properties — +/// the behaviour callers get when they pass no options at all. +/// +/// +/// is what keeps credentials out of an audit log. It is applied +/// before the entity state is considered, so a property excluded here is absent from an +/// snapshot just as it is from a +/// diff — an exclusion that only covered modifications would still write the secret out in full the +/// first time the row was inserted. +/// +public sealed class AuditOptions +{ + /// + /// Decides whether an entity is audited at all. Return false and no entry is produced for + /// it. Use this to keep high-volume runtime tables out of a log meant to record configuration + /// changes, where one entry per row processed would bury the entries worth reading. + /// + public Func? ShouldAuditEntity { get; init; } + + /// + /// Decides whether a single property is captured. Return false and the property appears + /// in no diff, whatever state its entity is in. + /// + public Func? ShouldAuditProperty { get; init; } +} + /// /// Provides extension methods for building audit trails from Entity Framework Core change tracking. /// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes. @@ -131,6 +160,7 @@ public static class AuditBuilderExtension /// /// The Entity Framework change tracker to capture changes from. /// Optional identifier of the user or actor making the changes. Used for audit accountability. + /// Optional filters narrowing which entities and properties are captured. When omitted, every changed entity and every property is recorded. /// A read-only collection of pending audit entries, each representing a single entity change. /// /// This method performs the following: @@ -139,6 +169,7 @@ public static class AuditBuilderExtension /// Generates a shared correlation ID for all changes in this batch /// Records a UTC timestamp shared by all changes /// Skips entities with no meaningful changes (e.g., only temporary properties changed) + /// Skips entities and properties excluded by /// Captures domain events from entities implementing IGeneratesDomainEvents /// /// Use to convert the results to JSON-serializable format. @@ -151,14 +182,15 @@ public static class AuditBuilderExtension /// /// public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, + AuditOptions? options = null) { changeTracker.DetectChanges(); var audits = new List(); var correlationId = Guid.NewGuid().ToString(); - var timestamp = DateTime.UtcNow; + var timestamp = DateTimeOffset.UtcNow; var sequence = 0; foreach (var entry in changeTracker.Entries() @@ -166,7 +198,10 @@ public static IReadOnlyCollection or EntityState.Modified or EntityState.Deleted)) { - var changes = BuildDiff(entry); + if (options?.ShouldAuditEntity is not null && !options.ShouldAuditEntity(entry)) + continue; + + var changes = BuildDiff(entry, options); if (changes.Count == 0) continue; // nothing meaningful changed @@ -282,7 +317,7 @@ public static IReadOnlyCollection return state; } private static Dictionary - BuildDiff(EntityEntry entry) + BuildDiff(EntityEntry entry, AuditOptions? options) { var diffs = new Dictionary(); @@ -291,6 +326,9 @@ private static Dictionary if (prop.IsTemporary) continue; + if (options?.ShouldAuditProperty is not null && !options.ShouldAuditProperty(entry, prop.Metadata)) + continue; + if (entry.State == EntityState.Added) { diffs[prop.Metadata.Name] = diff --git a/SW.EfCoreExtensions.UnitTests/AuditTests.cs b/SW.EfCoreExtensions.UnitTests/AuditTests.cs new file mode 100644 index 0000000..6af96b5 --- /dev/null +++ b/SW.EfCoreExtensions.UnitTests/AuditTests.cs @@ -0,0 +1,142 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.EfCoreExtensions.UnitTests.Domain; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace SW.EfCoreExtensions.UnitTests +{ + [TestClass] + public class AuditTests + { + static TestServer server; + + [ClassInitialize] + public static void ClassInitialize(TestContext tcontext) + { + server = new TestServer(WebHost.CreateDefaultBuilder() + .UseDefaultServiceProvider((context, options) => { options.ValidateScopes = true; }) + .UseEnvironment("Development") + .UseStartup()); + } + + [ClassCleanup] + public static void ClassCleanup() + { + server.Dispose(); + } + + static Employee NewEmployee() => new Employee + { + UserName = "adam", + Email = "adam@example.com", + FirstName = "Adam", + LastName = "Smith" + }; + + [TestMethod] + public void CapturesEveryPropertyWhenNoOptionsAreGiven() + { + using var scope = server.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Add(NewEmployee()); + + var changes = SingleEmployeeEntry(dbContext.ChangeTracker.CapturePendingAuditDiffs()).Changes; + + Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName))); + Assert.IsTrue(changes.ContainsKey(nameof(Employee.Email))); + } + + /// + /// The case worth guarding: an Added entity is captured as a full snapshot rather than a + /// diff, so an exclusion that only applied to modifications would still write the secret out + /// in full the very first time the row was inserted. + /// + [TestMethod] + public void ExcludedPropertyIsAbsentFromAnAddedSnapshot() + { + using var scope = server.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Add(NewEmployee()); + + var changes = SingleEmployeeEntry( + dbContext.ChangeTracker.CapturePendingAuditDiffs(options: ExcludeEmail)).Changes; + + Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email))); + Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName))); + } + + [TestMethod] + async public Task ExcludedPropertyIsAbsentFromAModifiedDiff() + { + using var scope = server.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var employee = NewEmployee(); + dbContext.Add(employee); + await dbContext.SaveChangesAsync(); + + employee.Email = "adam@changed.com"; + employee.LastName = "Jones"; + + var changes = SingleEmployeeEntry( + dbContext.ChangeTracker.CapturePendingAuditDiffs(options: ExcludeEmail)).Changes; + + Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email))); + Assert.AreEqual("Jones", changes[nameof(Employee.LastName)].New); + Assert.AreEqual("Smith", changes[nameof(Employee.LastName)].Old); + } + + [TestMethod] + public void EntityFilterSkipsUnauditedEntitiesEntirely() + { + using var scope = server.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Add(NewEmployee()); + dbContext.Add(new SomeData { StringArray = new[] { "x" } }); + + var captured = dbContext.ChangeTracker.CapturePendingAuditDiffs(options: new AuditOptions + { + ShouldAuditEntity = entry => entry.Entity is SomeData + }); + + Assert.IsFalse(captured.Any(c => c.EntityType == typeof(Employee).FullName)); + Assert.IsTrue(captured.Any(c => c.EntityType == typeof(SomeData).FullName)); + } + + [TestMethod] + async public Task FinalizeResolvesTheGeneratedPrimaryKey() + { + using var scope = server.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var employee = NewEmployee(); + dbContext.Add(employee); + + var pending = dbContext.ChangeTracker.CapturePendingAuditDiffs(userId: "user-42"); + await dbContext.SaveChangesAsync(); + var finalized = pending.FinalizeAuditDiffJson() + .Single(f => f.EntityType == typeof(Employee).FullName); + + var primaryKey = (IDictionary)finalized.PrimaryKey; + + Assert.AreEqual(employee.Id, primaryKey[nameof(Employee.Id)]); + Assert.AreNotEqual(0, employee.Id); + Assert.AreEqual("user-42", finalized.UserId); + Assert.AreEqual("Added", finalized.State); + } + + static readonly AuditOptions ExcludeEmail = new() + { + ShouldAuditProperty = (entry, property) => property.Name != nameof(Employee.Email) + }; + + static PendingAuditEntry SingleEmployeeEntry(IReadOnlyCollection captured) => + captured.Single(c => c.EntityType == typeof(Employee).FullName); + } +} diff --git a/SW.EfCoreExtensions/AuditBuilderExtensions.cs b/SW.EfCoreExtensions/AuditBuilderExtensions.cs index 4246e4e..39725df 100644 --- a/SW.EfCoreExtensions/AuditBuilderExtensions.cs +++ b/SW.EfCoreExtensions/AuditBuilderExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using SW.PrimitiveTypes; namespace SW.EfCoreExtensions; @@ -118,6 +119,34 @@ public sealed class PendingAuditEntry } +/// +/// Controls what records. Both filters +/// default to null, which captures every changed entity and every one of its properties — +/// the behaviour callers get when they pass no options at all. +/// +/// +/// is what keeps credentials out of an audit log. It is applied +/// before the entity state is considered, so a property excluded here is absent from an +/// snapshot just as it is from a +/// diff — an exclusion that only covered modifications would still write the secret out in full the +/// first time the row was inserted. +/// +public sealed class AuditOptions +{ + /// + /// Decides whether an entity is audited at all. Return false and no entry is produced for + /// it. Use this to keep high-volume runtime tables out of a log meant to record configuration + /// changes, where one entry per row processed would bury the entries worth reading. + /// + public Func? ShouldAuditEntity { get; init; } + + /// + /// Decides whether a single property is captured. Return false and the property appears + /// in no diff, whatever state its entity is in. + /// + public Func? ShouldAuditProperty { get; init; } +} + /// /// Provides extension methods for building audit trails from Entity Framework Core change tracking. /// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes. @@ -131,6 +160,7 @@ public static class AuditBuilderExtension /// /// The Entity Framework change tracker to capture changes from. /// Optional identifier of the user or actor making the changes. Used for audit accountability. + /// Optional filters narrowing which entities and properties are captured. When omitted, every changed entity and every property is recorded. /// A read-only collection of pending audit entries, each representing a single entity change. /// /// This method performs the following: @@ -139,6 +169,7 @@ public static class AuditBuilderExtension /// Generates a shared correlation ID for all changes in this batch /// Records a UTC timestamp shared by all changes /// Skips entities with no meaningful changes (e.g., only temporary properties changed) + /// Skips entities and properties excluded by /// Captures domain events from entities implementing IGeneratesDomainEvents /// /// Use to convert the results to JSON-serializable format. @@ -151,7 +182,8 @@ public static class AuditBuilderExtension /// /// public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, + AuditOptions? options = null) { changeTracker.DetectChanges(); @@ -166,7 +198,10 @@ public static IReadOnlyCollection or EntityState.Modified or EntityState.Deleted)) { - var changes = BuildDiff(entry); + if (options?.ShouldAuditEntity is not null && !options.ShouldAuditEntity(entry)) + continue; + + var changes = BuildDiff(entry, options); if (changes.Count == 0) continue; // nothing meaningful changed @@ -282,7 +317,7 @@ public static IReadOnlyCollection return state; } private static Dictionary - BuildDiff(EntityEntry entry) + BuildDiff(EntityEntry entry, AuditOptions? options) { var diffs = new Dictionary(); @@ -291,6 +326,9 @@ private static Dictionary if (prop.IsTemporary) continue; + if (options?.ShouldAuditProperty is not null && !options.ShouldAuditProperty(entry, prop.Metadata)) + continue; + if (entry.State == EntityState.Added) { diffs[prop.Metadata.Name] = diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 724ffea..ecab675 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -14,6 +14,7 @@ - [DomainEventEnvelope](#domaineventenvelope) - [PendingAuditEntry](#pendingauditentry) - [GenericAuditDiffJson](#genericauditdifffson) + - [AuditOptions](#auditoptions) 3. [Methods](#methods) - [CapturePendingAuditDiffs](#capturependingauditdiffs) - [FinalizeAuditDiffJson](#finalizeauditdifffson) @@ -183,13 +184,51 @@ The **finalized, serializable** audit record. Safe to serialize to JSON and stor --- +### `AuditOptions` + +```csharp +public sealed class AuditOptions +{ + public Func? ShouldAuditEntity { get; init; } + public Func? ShouldAuditProperty { get; init; } +} +``` + +Narrows what `CapturePendingAuditDiffs` records. Both filters are optional; leaving them `null` +captures every changed entity and every property, which is what callers that pass no options get. + +| Property | Type | Description | +|----------|------|-------------| +| `ShouldAuditEntity` | `Func?` | Return `false` to produce no entry for that entity. Use it to keep high-volume runtime tables out of a log meant to record configuration changes. | +| `ShouldAuditProperty` | `Func?` | Return `false` and the property appears in no diff, whatever state its entity is in. | + +**Keeping credentials out of the log:** + +```csharp +var options = new AuditOptions +{ + ShouldAuditEntity = entry => entry.Entity is Order or Customer, + ShouldAuditProperty = (entry, property) => + !(entry.Entity is Customer && property.Name == nameof(Customer.PasswordHash)) +}; + +var pending = ChangeTracker.CapturePendingAuditDiffs(_currentUserId, options); +``` + +> `ShouldAuditProperty` is applied before the entity state is considered. That matters because an +> `Added` entity is captured as a **full snapshot** rather than a diff — an exclusion that only +> covered modifications would still write the secret out in full the first time the row was inserted. + +--- + ## Methods ### `CapturePendingAuditDiffs` ```csharp public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, + AuditOptions? options = null) ``` **Call this BEFORE `SaveChanges`.** @@ -200,6 +239,7 @@ Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `De |----------------|-----------|----------|-------------| | `changeTracker`| `ChangeTracker` | yes (extension) | The EF Core change tracker from your `DbContext`. | | `userId` | `string?` | no | The current user/actor identifier. Pass from your HTTP context, JWT claim, or service identity. Defaults to `null`. | +| `options` | `AuditOptions?` | no | Filters narrowing which entities and properties are captured. See [AuditOptions](#auditoptions). Defaults to `null`, which captures everything. | **Returns:** `IReadOnlyCollection` — one entry per changed entity. Entities with no meaningful property changes (e.g. only EF-internal temporary properties) are excluded. @@ -209,6 +249,7 @@ Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `De - Captures a single `DateTimeOffset.UtcNow` timestamp shared by all entries. - Assigns a `Sequence` counter (1, 2, 3 …) to each entry in the order they were enumerated. - Skips properties where `prop.IsTemporary == true` (e.g. auto-increment PKs before insert). +- Skips entities and properties rejected by `options`, in every entity state. - Captures domain events from entities implementing `IGeneratesDomainEvents`. --- From 1726940555828ef9cbc798f7d66a6e62d9076887 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 6 Sep 2026 19:48:35 +0300 Subject: [PATCH 2/2] fix: keep the published capture signature, and leave PgSql's timestamp alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an optional parameter to CapturePendingAuditDiffs replaced its signature, so an assembly compiled against the old one could no longer bind. The original two-parameter method is back, forwarding to an options-aware overload that takes no defaults of its own — otherwise the two would be ambiguous. Also reverts the PgSql timestamp to DateTime. Syncing the two drifted copies was never needed for this feature and made a breaking change out of an additive one. --- .../AuditBuilderExtensions.cs | 24 ++++++++++++++----- SW.EfCoreExtensions.UnitTests/AuditTests.cs | 6 ++--- SW.EfCoreExtensions/AuditBuilderExtensions.cs | 18 +++++++++++--- docs/AUDIT.md | 14 ++++++++--- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs index 39725df..d26f912 100644 --- a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs +++ b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs @@ -35,7 +35,7 @@ public sealed record PropertyDiff( public sealed record GenericAuditDiffJson( string CorrelationId, int Sequence, - DateTimeOffset Timestamp, + DateTime Timestamp, string? UserId, string EntityName, string EntityType, @@ -79,7 +79,7 @@ public sealed class PendingAuditEntry /// /// Gets or sets the UTC timestamp when this change was captured. /// - public DateTimeOffset Timestamp { get; init; } + public DateTime Timestamp { get; init; } /// /// Gets or sets the optional identifier of the user or actor who made the change. @@ -160,7 +160,6 @@ public static class AuditBuilderExtension /// /// The Entity Framework change tracker to capture changes from. /// Optional identifier of the user or actor making the changes. Used for audit accountability. - /// Optional filters narrowing which entities and properties are captured. When omitted, every changed entity and every property is recorded. /// A read-only collection of pending audit entries, each representing a single entity change. /// /// This method performs the following: @@ -182,15 +181,28 @@ public static class AuditBuilderExtension /// /// public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, - AuditOptions? options = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + => changeTracker.CapturePendingAuditDiffs(userId, null); + + /// + /// The Entity Framework change tracker to capture changes from. + /// Optional identifier of the user or actor making the changes. + /// Filters narrowing which entities and properties are captured. Null captures everything. + /// + /// A separate overload rather than an optional parameter on the one above: optional arguments + /// are baked in at the call site, so adding one to a published method leaves assemblies already + /// compiled against the old signature unable to bind to it. + /// + public static IReadOnlyCollection + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId, + AuditOptions? options) { changeTracker.DetectChanges(); var audits = new List(); var correlationId = Guid.NewGuid().ToString(); - var timestamp = DateTimeOffset.UtcNow; + var timestamp = DateTime.UtcNow; var sequence = 0; foreach (var entry in changeTracker.Entries() diff --git a/SW.EfCoreExtensions.UnitTests/AuditTests.cs b/SW.EfCoreExtensions.UnitTests/AuditTests.cs index 6af96b5..3fce025 100644 --- a/SW.EfCoreExtensions.UnitTests/AuditTests.cs +++ b/SW.EfCoreExtensions.UnitTests/AuditTests.cs @@ -65,7 +65,7 @@ public void ExcludedPropertyIsAbsentFromAnAddedSnapshot() dbContext.Add(NewEmployee()); var changes = SingleEmployeeEntry( - dbContext.ChangeTracker.CapturePendingAuditDiffs(options: ExcludeEmail)).Changes; + dbContext.ChangeTracker.CapturePendingAuditDiffs(null, ExcludeEmail)).Changes; Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email))); Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName))); @@ -85,7 +85,7 @@ async public Task ExcludedPropertyIsAbsentFromAModifiedDiff() employee.LastName = "Jones"; var changes = SingleEmployeeEntry( - dbContext.ChangeTracker.CapturePendingAuditDiffs(options: ExcludeEmail)).Changes; + dbContext.ChangeTracker.CapturePendingAuditDiffs(null, ExcludeEmail)).Changes; Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email))); Assert.AreEqual("Jones", changes[nameof(Employee.LastName)].New); @@ -100,7 +100,7 @@ public void EntityFilterSkipsUnauditedEntitiesEntirely() dbContext.Add(NewEmployee()); dbContext.Add(new SomeData { StringArray = new[] { "x" } }); - var captured = dbContext.ChangeTracker.CapturePendingAuditDiffs(options: new AuditOptions + var captured = dbContext.ChangeTracker.CapturePendingAuditDiffs(null, new AuditOptions { ShouldAuditEntity = entry => entry.Entity is SomeData }); diff --git a/SW.EfCoreExtensions/AuditBuilderExtensions.cs b/SW.EfCoreExtensions/AuditBuilderExtensions.cs index 39725df..64e45d2 100644 --- a/SW.EfCoreExtensions/AuditBuilderExtensions.cs +++ b/SW.EfCoreExtensions/AuditBuilderExtensions.cs @@ -160,7 +160,6 @@ public static class AuditBuilderExtension /// /// The Entity Framework change tracker to capture changes from. /// Optional identifier of the user or actor making the changes. Used for audit accountability. - /// Optional filters narrowing which entities and properties are captured. When omitted, every changed entity and every property is recorded. /// A read-only collection of pending audit entries, each representing a single entity change. /// /// This method performs the following: @@ -182,8 +181,21 @@ public static class AuditBuilderExtension /// /// public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, - AuditOptions? options = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + => changeTracker.CapturePendingAuditDiffs(userId, null); + + /// + /// The Entity Framework change tracker to capture changes from. + /// Optional identifier of the user or actor making the changes. + /// Filters narrowing which entities and properties are captured. Null captures everything. + /// + /// A separate overload rather than an optional parameter on the one above: optional arguments + /// are baked in at the call site, so adding one to a published method leaves assemblies already + /// compiled against the old signature unable to bind to it. + /// + public static IReadOnlyCollection + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId, + AuditOptions? options) { changeTracker.DetectChanges(); diff --git a/docs/AUDIT.md b/docs/AUDIT.md index ecab675..9496d8b 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -227,10 +227,18 @@ var pending = ChangeTracker.CapturePendingAuditDiffs(_currentUserId, options); ```csharp public static IReadOnlyCollection - CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null, - AuditOptions? options = null) + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null) + +public static IReadOnlyCollection + CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId, + AuditOptions? options) ``` +> Two overloads rather than one with an optional `options`: optional arguments are baked in at +> the call site, so adding one to a published method leaves assemblies already compiled against +> the old signature unable to bind to it. It also means `options` can't be passed on its own — +> give `userId` explicitly, `null` if there isn't one. + **Call this BEFORE `SaveChanges`.** Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `Deleted` state and builds a list of `PendingAuditEntry` objects. @@ -239,7 +247,7 @@ Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `De |----------------|-----------|----------|-------------| | `changeTracker`| `ChangeTracker` | yes (extension) | The EF Core change tracker from your `DbContext`. | | `userId` | `string?` | no | The current user/actor identifier. Pass from your HTTP context, JWT claim, or service identity. Defaults to `null`. | -| `options` | `AuditOptions?` | no | Filters narrowing which entities and properties are captured. See [AuditOptions](#auditoptions). Defaults to `null`, which captures everything. | +| `options` | `AuditOptions?` | yes, on the three-parameter overload | Filters narrowing which entities and properties are captured. See [AuditOptions](#auditoptions). `null` captures everything. | **Returns:** `IReadOnlyCollection` — one entry per changed entity. Entities with no meaningful property changes (e.g. only EF-internal temporary properties) are excluded.