diff --git a/README.md b/README.md index 54dee64..49a03ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Audentity [![latest version](https://img.shields.io/nuget/v/Audentity)](https://www.nuget.org/packages/Audentity) -### Auditing library for Entity Framework +**Auditing library for Entity Framework** The Audentity project is designed to enhance data integrity and accountability within applications utilizing the Entity Framework @@ -14,57 +14,27 @@ applications. ## Usage -To collect traces, you want to catch the state of the `Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker` -before saving changes. That can be done simply by overriding the `SaveChanges()` & `SaveChangesAsync(CancellationToken)` -methods in your `Microsoft.EntityFrameworkCore.DbContext` implementation. +To read more about how to use Audentity, please refer to the [documentation](src/Audentity/README.md#usage) link. -```csharp -public class Database : DbContext -{ - public override async Task SaveChangesAsync(CancellationToken cancellationToken = new()) - { - ImmutableList traces = ChangeTracker.Entries() - .Select(EntityTrace.FromEntry) - .ToImmutableList(); - - int result = await base.SaveChangesAsync(cancellationToken); - // Process traces... - return result; - } -} -``` +## Benchmark -### Shadow Entries +| Method | Count | Mean | Error | StdDev | Gen0 | Allocated | +|-----------|-------|---------:|---------:|---------:|-------:|----------:| +| FromEntry | 1 | 46.55 ns | 0.080 ns | 0.067 ns | 0.0051 | 32 B | +| FromEntry | 10 | 46.82 ns | 0.082 ns | 0.073 ns | 0.0051 | 32 B | +| FromEntry | 100 | 46.89 ns | 0.041 ns | 0.036 ns | 0.0051 | 32 B | +| FromEntry | 1000 | 46.66 ns | 0.160 ns | 0.150 ns | 0.0051 | 32 B | -If you have many-to-many relationships in your database model, Entity Framework will generate a shadow entity that -represents a reference between two entities - unless you have defined such an entity yourself. +### Legend +- Count : Value of the 'Count' parameter +- Mean : Arithmetic mean of all measurements +- Error : Half of 99.9% confidence interval +- StdDev : Standard deviation of all measurements +- Gen0 : GC Generation 0 collects per 1000 operations +- Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B) +- 1 ns : 1 Nanosecond (0.000000001 sec) -```csharp -public class Project -{ - public Guid Id { get; set; } - public IEnumerable Users { get; set; } -} -public class User -{ - public Guid Id { get; set; } - public IEnumerable Projects { get; set; } -} +## License -// Shadow entity generated by Entity Framework: -public class ProjectUser -{ - public Guid ProjectId { get; set; } - public Guid UserId { get; set; } -} -``` - -Those entities, even if they are not defined in the code itself, will still end up in our trace collection. -To exclude them from traces, you can filter all entries by their CLR type before collecting traces. - -```csharp -ChangeTracker.Entries() - .Where(e => e.Metadata.ClrType != typeof(Dictionary)) - .Select(Entity.FromEntry); -``` \ No newline at end of file +This project is licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/src/Audentity.Benchmarks/Executor.cs b/src/Audentity.Benchmarks/Executor.cs index f90e286..04e9e61 100644 --- a/src/Audentity.Benchmarks/Executor.cs +++ b/src/Audentity.Benchmarks/Executor.cs @@ -56,6 +56,9 @@ public Executor() [Benchmark] public void FromEntry() { - _ = _database.ChangeTracker.Entries().Select(Entity.FromEntry); + foreach (EntityTrace _ in _database.ChangeTracker.Entries().Select(EntityTrace.FromEntry)) + { + // Ignore. + } } } \ No newline at end of file diff --git a/src/Audentity.Tests/Audentity.Tests.csproj b/src/Audentity.Tests/Audentity.Tests.csproj index d3041c0..9a21fff 100644 --- a/src/Audentity.Tests/Audentity.Tests.csproj +++ b/src/Audentity.Tests/Audentity.Tests.csproj @@ -33,4 +33,16 @@ + + + EntityTraceTests.cs + + + EntityTraceTests.cs + + + EntityTraceTests.cs + + + diff --git a/src/Audentity.Tests/EntityTests.cs b/src/Audentity.Tests/EntityTraceTests.cs similarity index 73% rename from src/Audentity.Tests/EntityTests.cs rename to src/Audentity.Tests/EntityTraceTests.cs index 6b08532..736349d 100644 --- a/src/Audentity.Tests/EntityTests.cs +++ b/src/Audentity.Tests/EntityTraceTests.cs @@ -1,7 +1,7 @@ namespace Audentity.Tests; [UsesVerify] -public class EntityTests +public class EntityTraceTests { private readonly Database _database = new(); private readonly Tenant _tenant = Seeding.Seed().First(); @@ -10,10 +10,10 @@ public class EntityTests public Task FromEntry_AddEntity_Collects() { _database.Add(_tenant); - IEnumerable traces = _database + IEnumerable traces = _database .ChangeTracker .Entries() - .Select(Entity.FromEntry); + .Select(EntityTrace.FromEntry); return Verify(traces); } @@ -26,10 +26,10 @@ public Task FromEntry_ModifyEntity_Collects() _tenant.Name += "Updated"; _tenant.Users.First().Name += "Updated"; _tenant.Projects.First().Name += "Updated"; - IEnumerable traces = _database + IEnumerable traces = _database .ChangeTracker .Entries() - .Select(Entity.FromEntry); + .Select(EntityTrace.FromEntry); return Verify(traces); } @@ -39,9 +39,9 @@ public Task FromEntry_DeleteEntity_Collects() _database.Add(_tenant); _database.SaveChanges(); _database.Remove(_tenant); - IEnumerable traces = _database.ChangeTracker + IEnumerable traces = _database.ChangeTracker .Entries() - .Select(Entity.FromEntry); + .Select(EntityTrace.FromEntry); return Verify(traces); } } \ No newline at end of file diff --git a/src/Audentity/Audentity.csproj b/src/Audentity/Audentity.csproj index 55d39c0..b7c42f6 100644 --- a/src/Audentity/Audentity.csproj +++ b/src/Audentity/Audentity.csproj @@ -27,8 +27,8 @@ - - + + diff --git a/src/Audentity/Entity.cs b/src/Audentity/Entity.cs deleted file mode 100644 index 007824a..0000000 --- a/src/Audentity/Entity.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Immutable; - -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace Audentity; - -public record Entity( - Type Type, - EntityState State, - IReadOnlyCollection Properties, - IReadOnlyCollection References) -{ - public static Entity FromEntry(EntityEntry entry) - { - ImmutableList properties = - entry - .Properties - .Select(Property.FromEntry) - .ToImmutableList(); - - ImmutableList references = - entry - .Navigations - .SelectMany(Reference.FromEntry) - .ToImmutableList(); - - return new Entity(entry.Entity.GetType(), entry.State, properties, references); - } -} \ No newline at end of file diff --git a/src/Audentity/EntityTrace.cs b/src/Audentity/EntityTrace.cs new file mode 100644 index 0000000..6ccba38 --- /dev/null +++ b/src/Audentity/EntityTrace.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Audentity; + +public record EntityTrace( + Type Type, + EntityState State, + IReadOnlyCollection Properties, + IReadOnlyCollection References) +{ + public static EntityTrace FromEntry(EntityEntry entry) + { + IReadOnlyCollection properties = + entry + .Properties + .Select(PropertyTrace.FromEntry) + .ToArray(); + + IReadOnlyCollection references = + entry + .Navigations + .SelectMany(ReferenceTrace.FromEntry) + .ToArray(); + + return new EntityTrace(entry.Entity.GetType(), entry.State, properties, references); + } +} \ No newline at end of file diff --git a/src/Audentity/Link.cs b/src/Audentity/LinkTrace.cs similarity index 50% rename from src/Audentity/Link.cs rename to src/Audentity/LinkTrace.cs index cc5d492..ee53310 100644 --- a/src/Audentity/Link.cs +++ b/src/Audentity/LinkTrace.cs @@ -3,17 +3,17 @@ namespace Audentity; -public record Link(string Name, string Value) +public record LinkTrace(string Name, string Value) { - internal static Link FromProperty(IProperty property, object entity) + internal static LinkTrace FromProperty(IProperty property, object entity) { string value = property.PropertyInfo?.GetValue(entity)?.ToString() ?? String.Empty; - return new Link(property.Name, value); + return new LinkTrace(property.Name, value); } - internal static Link FromEntry(PropertyEntry entry) + internal static LinkTrace FromEntry(PropertyEntry entry) { string value = entry.CurrentValue?.ToString() ?? String.Empty; - return new Link(entry.Metadata.Name, value); + return new LinkTrace(entry.Metadata.Name, value); } } \ No newline at end of file diff --git a/src/Audentity/Property.cs b/src/Audentity/PropertyTrace.cs similarity index 54% rename from src/Audentity/Property.cs rename to src/Audentity/PropertyTrace.cs index f5e91a7..abcbd43 100644 --- a/src/Audentity/Property.cs +++ b/src/Audentity/PropertyTrace.cs @@ -2,13 +2,13 @@ namespace Audentity; -public record Property(string Name, string? CurrentValue, string? OriginalValue) +public record PropertyTrace(string Name, string? CurrentValue, string? OriginalValue) { - internal static Property FromEntry(PropertyEntry entry) + internal static PropertyTrace FromEntry(PropertyEntry entry) { string? currentValue = entry.CurrentValue?.ToString(); string? originalValue = entry.OriginalValue?.ToString(); string name = entry.Metadata.Name; - return new Property(name, currentValue, originalValue); + return new PropertyTrace(name, currentValue, originalValue); } } \ No newline at end of file diff --git a/src/Audentity/README.md b/src/Audentity/README.md new file mode 100644 index 0000000..f084a64 --- /dev/null +++ b/src/Audentity/README.md @@ -0,0 +1,60 @@ +# Audentity + +**Auditing library for Entity Framework** + +## Usage + +Collect traces by catching the state of the `Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker` +before saving changes. That can be done simply by overriding the `SaveChanges()` & `SaveChangesAsync(CancellationToken)` +methods in your `Microsoft.EntityFrameworkCore.DbContext` implementation. + +```csharp +public class MyDbContext : DbContext +{ + public override async Task SaveChangesAsync(CancellationToken cancellationToken = new()) + { + ImmutableList traces = ChangeTracker.Entries() + .Select(EntityTrace.FromEntry) + .ToList(); + + int result = await base.SaveChangesAsync(cancellationToken); + // Process traces... + return result; + } +} +``` + +### Shadow Entries + +If you have many-to-many relationships in your database model, Entity Framework will generate a shadow entity that +represents a reference between two entities - unless you have defined such an entity yourself. + +```csharp +public class Project +{ + public Guid Id { get; set; } + public IEnumerable Users { get; set; } +} + +public class User +{ + public Guid Id { get; set; } + public IEnumerable Projects { get; set; } +} + +// Shadow entity generated by Entity Framework: +public class ProjectUser +{ + public Guid ProjectId { get; set; } + public Guid UserId { get; set; } +} +``` + +Those entities, even if they are not defined in the code itself, will still end up in our trace collection. +To exclude them from traces, you can filter all entries by their CLR type before collecting traces. + +```csharp +ChangeTracker.Entries() + .Where(e => e.Metadata.ClrType != typeof(Dictionary)) + .Select(EntityTrace.FromEntry); +``` \ No newline at end of file diff --git a/src/Audentity/Reference.cs b/src/Audentity/Reference.cs deleted file mode 100644 index 10334ca..0000000 --- a/src/Audentity/Reference.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Immutable; - -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace Audentity; - -public record Reference(string Name, Type Type, IReadOnlyCollection Links) -{ - internal static IEnumerable FromEntry(NavigationEntry navigation) - { - string name = navigation.Metadata.Name; - Type type = navigation.Metadata.TargetEntityType.ClrType; - Reference result = new(name, type, ImmutableList.Empty); - - switch (navigation) - { - case ReferenceEntry { TargetEntry: not null } reference: - { - yield return result with - { - Links = reference.TargetEntry.Properties - .Where(p => p.Metadata.IsPrimaryKey()) - .Select(Link.FromEntry) - .ToImmutableList() - }; - break; - } - - case CollectionEntry { CurrentValue: not null } collection: - { - IEnumerable properties = collection.Metadata.TargetEntityType.GetProperties().ToArray(); - - foreach (object entity in collection.CurrentValue) - { - yield return result with - { - Links = properties.Where(p => p.IsPrimaryKey()) - .Select(p => Link.FromProperty(p, entity)) - .ToImmutableList() - }; - } - - break; - } - - default: - yield return result; - break; - } - } -} \ No newline at end of file diff --git a/src/Audentity/ReferenceTrace.cs b/src/Audentity/ReferenceTrace.cs new file mode 100644 index 0000000..7f0c612 --- /dev/null +++ b/src/Audentity/ReferenceTrace.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Audentity; + +public record ReferenceTrace(string Name, Type Type, IReadOnlyCollection Links) +{ + internal static IEnumerable FromEntry(NavigationEntry navigation) + { + string name = navigation.Metadata.Name; + Type type = navigation.Metadata.TargetEntityType.ClrType; + + switch (navigation) + { + case ReferenceEntry { TargetEntry: not null } reference: + { + IReadOnlyCollection links = reference + .TargetEntry + .Properties + .Where(p => p.Metadata.IsPrimaryKey()) + .Select(LinkTrace.FromEntry) + .ToArray(); + + yield return new ReferenceTrace(name, type, links); + break; + } + + case CollectionEntry { CurrentValue: not null } collection: + { + IReadOnlyCollection primaryKeys = collection + .Metadata + .TargetEntityType + .GetProperties() + .Where(p => p.IsPrimaryKey()) + .ToArray(); + + foreach (object entity in collection.CurrentValue) + { + IReadOnlyCollection links = primaryKeys + .Select(p => LinkTrace.FromProperty(p, entity)) + .ToArray(); + + yield return new ReferenceTrace(name, type, links); + } + + break; + } + + default: + yield return new ReferenceTrace(name, type, []); + break; + } + } +} \ No newline at end of file diff --git a/img/icon.png b/src/Audentity/icon.png similarity index 100% rename from img/icon.png rename to src/Audentity/icon.png