├── src ├── EFCore.AuditExtensions.Common │ ├── Assembly.cs │ ├── Interceptors │ │ ├── IUserProvider.cs │ │ ├── EmptyUserProvider.cs │ │ └── BaseUserContextInterceptor.cs │ ├── Migrations │ │ ├── CSharp │ │ │ ├── Operations │ │ │ │ ├── IDependentMigrationOperation.cs │ │ │ │ ├── DropAuditTriggerOperation.cs │ │ │ │ └── CreateAuditTriggerOperation.cs │ │ │ ├── CSharpMigrationsGenerator.cs │ │ │ └── CSharpMigrationOperationGenerator.cs │ │ ├── Sql │ │ │ └── Operations │ │ │ │ ├── DropAuditTriggerSqlGenerator.cs │ │ │ │ └── CreateAuditTriggerSqlGenerator.cs │ │ └── MigrationsModelDiffer.cs │ ├── SharedModels │ │ ├── AuditColumnType.cs │ │ └── AuditedEntityKeyProperty.cs │ ├── Annotations │ │ ├── Table │ │ │ ├── AuditTableIndex.cs │ │ │ ├── AuditTable.cs │ │ │ ├── AuditTableColumn.cs │ │ │ └── AuditTableFactory.cs │ │ ├── Audit.cs │ │ └── Trigger │ │ │ ├── AuditTrigger.cs │ │ │ └── AuditTriggerFactory.cs │ ├── Configuration │ │ ├── AuditOptionsFactory.cs │ │ └── AuditOptions.cs │ ├── Extensions │ │ ├── ModelExtensions.cs │ │ ├── EntityTypeExtensions.cs │ │ ├── ModelBuilderExtensions.cs │ │ ├── AuditSerializationExtensions.cs │ │ ├── ExpressionExtensions.cs │ │ ├── ReadOnlyEntityTypeExtensions.cs │ │ ├── AuditColumnTypeExtensions.cs │ │ ├── MigrationBuilderExtensions.cs │ │ ├── EntityTypeBuilderExtensions.cs │ │ └── DbContextOptionsBuilderExtensions.cs │ ├── EfCore │ │ ├── Models │ │ │ └── CustomAuditTableIndex.cs │ │ ├── DesignTimeServices.cs │ │ ├── ModelCustomizer.cs │ │ ├── EfCoreColumnFactory.cs │ │ ├── EfCoreTableIndexFactory.cs │ │ └── EfCoreTableFactory.cs │ ├── EfCoreExtension │ │ ├── EfCoreAuditExtension.cs │ │ └── EfCoreAuditExtensionInfo.cs │ ├── Constants.cs │ └── EFCore.AuditExtensions.Common.csproj ├── EFCore.AuditExtensions.sln.DotSettings ├── EFCore.AuditExtensions.SqlServer │ ├── EFCore.AuditExtensions.SqlServer.csproj │ ├── Interceptors │ │ └── UserContextInterceptor.cs │ ├── SqlGenerators │ │ ├── Operations │ │ │ ├── DropAuditTriggerSqlGenerator.cs │ │ │ └── CreateAuditTriggerSqlGenerator.cs │ │ └── SqlServerMigrationsSqlGenerator.cs │ └── Installer.cs └── EFCore.AuditExtensions.sln ├── LICENSE ├── .gitignore └── README.md /src/EFCore.AuditExtensions.Common/Assembly.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("EFCore.AuditExtensions.SqlServer")] -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Interceptors/IUserProvider.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Interceptors; 2 | 3 | public interface IUserProvider 4 | { 5 | string GetCurrentUser(); 6 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Interceptors/EmptyUserProvider.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Interceptors; 2 | 3 | internal class EmptyUserProvider : IUserProvider 4 | { 5 | public string GetCurrentUser() => string.Empty; 6 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/CSharp/Operations/IDependentMigrationOperation.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | 3 | public interface IDependentMigrationOperation 4 | { 5 | Type[] DependsOn { get; } 6 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/SharedModels/AuditColumnType.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.SharedModels; 2 | 3 | public enum AuditColumnType 4 | { 5 | Text, 6 | Number, 7 | DateTime, 8 | DecimalNumber, 9 | PrecisionNumber, 10 | Guid, 11 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Table/AuditTableIndex.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Annotations.Table; 2 | 3 | internal class AuditTableIndex 4 | { 5 | public string? Name { get; } 6 | 7 | public AuditTableIndex(string? name) 8 | { 9 | Name = name; 10 | } 11 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/Sql/Operations/DropAuditTriggerSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | namespace EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 5 | 6 | internal interface IDropAuditTriggerSqlGenerator 7 | { 8 | void Generate(DropAuditTriggerOperation operation, MigrationCommandListBuilder builder); 9 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | False -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Configuration/AuditOptionsFactory.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Configuration; 2 | 3 | internal static class AuditOptionsFactory 4 | { 5 | public static AuditOptions GetConfiguredAuditOptions(Action>? configureOptions) where T : class 6 | { 7 | var options = new AuditOptions(); 8 | configureOptions?.Invoke(options); 9 | 10 | return options; 11 | } 12 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Table/AuditTable.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.Annotations.Table; 2 | 3 | internal class AuditTable 4 | { 5 | public string Name { get; } 6 | 7 | public IReadOnlyCollection Columns { get; } 8 | 9 | public AuditTableIndex? Index { get; } 10 | 11 | public AuditTable(string name, IReadOnlyCollection columns, AuditTableIndex? index) 12 | { 13 | Columns = columns; 14 | Index = index; 15 | Name = name; 16 | } 17 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/Sql/Operations/CreateAuditTriggerSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Microsoft.EntityFrameworkCore.Storage; 4 | 5 | namespace EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 6 | 7 | internal interface ICreateAuditTriggerSqlGenerator 8 | { 9 | void Generate(CreateAuditTriggerOperation operation, MigrationCommandListBuilder builder, IRelationalTypeMappingSource typeMappingSource); 10 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Audit.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations.Table; 2 | using EFCore.AuditExtensions.Common.Annotations.Trigger; 3 | 4 | namespace EFCore.AuditExtensions.Common.Annotations; 5 | 6 | internal class Audit 7 | { 8 | public string Name { get; } 9 | 10 | public AuditTable Table { get; } 11 | 12 | public AuditTrigger Trigger { get; } 13 | 14 | public Audit(string name, AuditTable table, AuditTrigger trigger) 15 | { 16 | Name = name; 17 | Table = table; 18 | Trigger = trigger; 19 | } 20 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/SharedModels/AuditedEntityKeyProperty.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common.SharedModels; 2 | 3 | public class AuditedEntityKeyProperty 4 | { 5 | public string ColumnName { get; } 6 | 7 | public AuditColumnType ColumnType { get; } 8 | 9 | public int? MaxLength { get; } 10 | 11 | public AuditedEntityKeyProperty(string columnName, AuditColumnType columnType, int? maxLength = null) 12 | { 13 | ColumnName = columnName; 14 | ColumnType = columnType; 15 | MaxLength = maxLength; 16 | } 17 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/ModelExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata; 2 | 3 | namespace EFCore.AuditExtensions.Common.Extensions; 4 | 5 | internal static class ModelExtensions 6 | { 7 | public static IReadOnlyCollection GetAuditedEntityTypes(this IModel? model) 8 | { 9 | if (model == null) 10 | { 11 | return Array.Empty(); 12 | } 13 | 14 | return model.GetEntityTypes().Where(et => et.GetAnnotations().Any(a => a.Name.StartsWith(Constants.AnnotationPrefix))).ToArray(); 15 | } 16 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/EntityTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Infrastructure; 2 | using Microsoft.EntityFrameworkCore.Metadata; 3 | 4 | namespace EFCore.AuditExtensions.Common.Extensions; 5 | 6 | internal static class EntityTypeExtensions 7 | { 8 | public static IAnnotation GetAuditAnnotation(this IEntityType entityType) => entityType.GetAnnotations().Single(a => a.Name.StartsWith(Constants.AnnotationPrefix)); 9 | 10 | public static bool HasAnnotation(this IMutableEntityType entityType, string annotationName) => entityType.FindAnnotation(annotationName) != null; 11 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/CSharp/Operations/DropAuditTriggerOperation.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Extensions; 2 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 3 | 4 | namespace EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 5 | 6 | public class DropAuditTriggerOperation : MigrationOperation, IDependentMigrationOperation 7 | { 8 | public string TriggerName { get; } 9 | 10 | public Type[] DependsOn { get; } = { typeof(MigrationBuilderExtensions) }; 11 | 12 | public DropAuditTriggerOperation(string triggerName) 13 | { 14 | TriggerName = triggerName; 15 | } 16 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/Models/CustomAuditTableIndex.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Metadata; 3 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 4 | 5 | namespace EFCore.AuditExtensions.Common.EfCore.Models; 6 | 7 | #pragma warning disable EF1001 8 | 9 | public class CustomAuditTableIndex : TableIndex 10 | { 11 | public override string? Filter => MappedIndexes.FirstOrDefault()?.GetFilter(StoreObjectIdentifier.Table(Table.Name, Table.Schema)); 12 | 13 | public CustomAuditTableIndex(string name, Table table, IReadOnlyList columns) : base(name, table, columns, false) 14 | { } 15 | } 16 | 17 | #pragma warning restore EF1001 -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Table/AuditTableColumn.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.SharedModels; 2 | 3 | namespace EFCore.AuditExtensions.Common.Annotations.Table; 4 | 5 | internal class AuditTableColumn 6 | { 7 | public AuditColumnType Type { get; } 8 | 9 | public string Name { get; } 10 | 11 | public bool Nullable { get; } 12 | 13 | public bool AuditedEntityKey { get; } 14 | 15 | public int? MaxLength { get; } 16 | 17 | public AuditTableColumn(AuditColumnType type, string name, bool nullable, bool auditedEntityKey, int? maxLength = null) 18 | { 19 | Type = type; 20 | Name = name; 21 | Nullable = nullable; 22 | AuditedEntityKey = auditedEntityKey; 23 | MaxLength = maxLength; 24 | } 25 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/ModelBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Infrastructure; 3 | using Microsoft.EntityFrameworkCore.Metadata; 4 | 5 | namespace EFCore.AuditExtensions.Common.Extensions; 6 | 7 | internal static class ModelBuilderExtensions 8 | { 9 | public static IReadOnlyCollection<(IMutableEntityType EntityType, IAnnotation Annotation)> GetEntityTypesWithDelayedAuditAnnotation(this ModelBuilder modelBuilder) 10 | => modelBuilder.Model.GetEntityTypes() 11 | .Where(et => et.HasAnnotation(Constants.AddAuditAnnotationName)) 12 | .Select(et => (EntityType: et, Annotation: et.GetAnnotation(Constants.AddAuditAnnotationName))) 13 | .ToArray(); 14 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCoreExtension/EfCoreAuditExtension.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Infrastructure; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace EFCore.AuditExtensions.Common.EfCoreExtension; 5 | 6 | public class EfCoreAuditExtension : IDbContextOptionsExtension 7 | { 8 | private readonly Action _addServices; 9 | 10 | public DbContextOptionsExtensionInfo Info { get; } 11 | 12 | public EfCoreAuditExtension(Action addServices) 13 | { 14 | _addServices = addServices; 15 | Info = new EfCoreAuditExtensionInfo(this); 16 | } 17 | 18 | public void ApplyServices(IServiceCollection services) => _addServices(services); 19 | 20 | public void Validate(IDbContextOptions options) 21 | { } 22 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/EFCore.AuditExtensions.SqlServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCoreExtension/EfCoreAuditExtensionInfo.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Infrastructure; 2 | 3 | namespace EFCore.AuditExtensions.Common.EfCoreExtension; 4 | 5 | public class EfCoreAuditExtensionInfo : DbContextOptionsExtensionInfo 6 | { 7 | public override bool IsDatabaseProvider => false; 8 | 9 | public override string LogFragment => "EfCoreAuditExtension"; 10 | 11 | public EfCoreAuditExtensionInfo(IDbContextOptionsExtension extension) : base(extension) 12 | { } 13 | 14 | public override int GetServiceProviderHashCode() => 0; 15 | 16 | public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => string.Equals(LogFragment, other.LogFragment, StringComparison.Ordinal); 17 | 18 | public override void PopulateDebugInfo(IDictionary debugInfo) 19 | { } 20 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/DesignTimeServices.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Design; 2 | using Microsoft.EntityFrameworkCore.Migrations.Design; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using CSharpMigrationOperationGenerator = EFCore.AuditExtensions.Common.Migrations.CSharp.CSharpMigrationOperationGenerator; 5 | using CSharpMigrationsGenerator = EFCore.AuditExtensions.Common.Migrations.CSharp.CSharpMigrationsGenerator; 6 | 7 | namespace EFCore.AuditExtensions.Common.EfCore; 8 | 9 | public class DesignTimeServices : IDesignTimeServices 10 | { 11 | public void ConfigureDesignTimeServices(IServiceCollection services) 12 | { 13 | services.AddSingleton(); 14 | services.AddSingleton(); 15 | } 16 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/AuditSerializationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | using EFCore.AuditExtensions.Common.Annotations; 4 | 5 | namespace EFCore.AuditExtensions.Common.Extensions; 6 | 7 | internal static class AuditSerializationExtensions 8 | { 9 | private static JsonSerializerOptions JsonSerializerOptions 10 | { 11 | get 12 | { 13 | var settings = new JsonSerializerOptions(JsonSerializerDefaults.General); 14 | settings.Converters.Add(new JsonStringEnumConverter()); 15 | return settings; 16 | } 17 | } 18 | 19 | public static string Serialize(this Audit audit) => JsonSerializer.Serialize(audit, JsonSerializerOptions); 20 | 21 | public static Audit? Deserialize(this string serializedAudit) => JsonSerializer.Deserialize(serializedAudit, JsonSerializerOptions); 22 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace EFCore.AuditExtensions.Common; 2 | 3 | internal static class Constants 4 | { 5 | public static string AnnotationPrefix => "AuditExtensions"; 6 | 7 | public static string AddAuditAnnotationName => "AddAudit"; 8 | 9 | public static string AuditTableNameSuffix => "_Audit"; 10 | 11 | public static string AuditTriggerPrefix => "Audit_"; 12 | 13 | public static class AuditTableColumnNames 14 | { 15 | public static string OldData = nameof(OldData); 16 | public static string NewData = nameof(NewData); 17 | public static string OperationType = nameof(OperationType); 18 | public static string Timestamp = nameof(Timestamp); 19 | public static string User = nameof(User); 20 | } 21 | 22 | public static class AuditTableColumnMaxLengths 23 | { 24 | public static int OperationType = 6; 25 | public static int User = 255; 26 | } 27 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EFCore.AuditExtensions.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | all 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/ExpressionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using System.Reflection; 3 | using EFCore.AuditExtensions.Common.SharedModels; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Metadata; 7 | 8 | namespace EFCore.AuditExtensions.Common.Extensions; 9 | 10 | internal static class ExpressionExtensions 11 | { 12 | public static IReadOnlyCollection GetKeyProperties(this Expression> expression , IReadOnlyEntityType entityType) 13 | { 14 | var memberInfos = expression.GetMemberAccessList().Where(m => m.MemberType == MemberTypes.Property); 15 | 16 | var storeObject = StoreObjectIdentifier.Table(entityType.GetTableName()!); 17 | return memberInfos.Select(entityType.FindProperty).Select(property => new AuditedEntityKeyProperty(property!.GetColumnName(storeObject)!, property!.ClrType.GetAuditColumnType(), property.GetMaxLength())).ToArray(); 18 | } 19 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/CSharp/CSharpMigrationsGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using Microsoft.EntityFrameworkCore.Migrations.Design; 3 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 4 | 5 | namespace EFCore.AuditExtensions.Common.Migrations.CSharp; 6 | 7 | internal class CSharpMigrationsGenerator : Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGenerator 8 | { 9 | public CSharpMigrationsGenerator(MigrationsCodeGeneratorDependencies dependencies, CSharpMigrationsGeneratorDependencies csharpDependencies) : base(dependencies, csharpDependencies) 10 | { } 11 | 12 | protected override IEnumerable GetNamespaces(IEnumerable operations) => base.GetNamespaces(operations).Concat(operations.OfType().SelectMany(GetDependentNamespaces)); 13 | 14 | private static IEnumerable GetDependentNamespaces(IDependentMigrationOperation migrationOperation) => migrationOperation.DependsOn.Select(type => type.Namespace!); 15 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/Interceptors/UserContextInterceptor.cs: -------------------------------------------------------------------------------- 1 | using System.Data.Common; 2 | using EFCore.AuditExtensions.Common.Interceptors; 3 | 4 | namespace EFCore.AuditExtensions.SqlServer.Interceptors; 5 | 6 | internal class UserContextInterceptor : BaseUserContextInterceptor 7 | { 8 | public UserContextInterceptor(IUserProvider userProvider) : base(userProvider) 9 | { } 10 | 11 | protected override void SetUserContext(DbConnection connection, string user) 12 | { 13 | var command = GetDbCommand(connection, user); 14 | command.ExecuteNonQuery(); 15 | } 16 | 17 | private static DbCommand GetDbCommand(DbConnection connection, string user) 18 | { 19 | var command = connection.CreateCommand(); 20 | command.CommandText = "EXEC sp_set_session_context 'user', @User"; 21 | var userParameter = command.CreateParameter(); 22 | userParameter.ParameterName = "@User"; 23 | userParameter.Value = user; 24 | command.Parameters.Add(userParameter); 25 | return command; 26 | } 27 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/ReadOnlyEntityTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.SharedModels; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata; 4 | 5 | namespace EFCore.AuditExtensions.Common.Extensions; 6 | 7 | internal static class ReadOnlyEntityTypeExtensions 8 | { 9 | public static IReadOnlyCollection GetKeyProperties(this IReadOnlyEntityType readOnlyEntityType) 10 | { 11 | var key = readOnlyEntityType.GetKeys().OrderBy(k => k.IsPrimaryKey()).ThenBy(k => k.Properties.Count).FirstOrDefault(); 12 | if (key == null) 13 | { 14 | return Array.Empty(); 15 | } 16 | 17 | var storeObject = StoreObjectIdentifier.Table(readOnlyEntityType.GetTableName()!); 18 | return key.Properties.Select(property => new AuditedEntityKeyProperty( 19 | property.GetColumnName(storeObject)!, 20 | property.ClrType.GetAuditColumnType(), 21 | property.GetMaxLength(storeObject)!)).ToArray(); 22 | } 23 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Interceptors/BaseUserContextInterceptor.cs: -------------------------------------------------------------------------------- 1 | using System.Data.Common; 2 | using Microsoft.EntityFrameworkCore.Diagnostics; 3 | 4 | namespace EFCore.AuditExtensions.Common.Interceptors; 5 | 6 | internal abstract class BaseUserContextInterceptor : DbConnectionInterceptor 7 | { 8 | private readonly IUserProvider _userProvider; 9 | 10 | protected BaseUserContextInterceptor(IUserProvider userProvider) 11 | { 12 | _userProvider = userProvider; 13 | } 14 | 15 | public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) 16 | { 17 | var user = _userProvider.GetCurrentUser(); 18 | if (string.IsNullOrEmpty(user)) 19 | { 20 | return; 21 | } 22 | 23 | SetUserContext(connection, user); 24 | } 25 | 26 | public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = new()) 27 | { 28 | ConnectionOpened(connection, eventData); 29 | return Task.CompletedTask; 30 | } 31 | 32 | protected abstract void SetUserContext(DbConnection connection, string user); 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/AuditColumnTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.SharedModels; 2 | 3 | namespace EFCore.AuditExtensions.Common.Extensions; 4 | 5 | internal static class AuditColumnTypeExtensions 6 | { 7 | private const AuditColumnType DefaultColumnType = AuditColumnType.Text; 8 | 9 | private static readonly (AuditColumnType columnType, Type clrType)[] Mappings = 10 | { 11 | (AuditColumnType.Guid, typeof(Guid)), 12 | (AuditColumnType.DateTime, typeof(DateTime)), 13 | (AuditColumnType.Number, typeof(int)), 14 | (AuditColumnType.DecimalNumber, typeof(double)), 15 | (AuditColumnType.PrecisionNumber, typeof(decimal)), 16 | }; 17 | 18 | private static readonly Type DefaultClrType = typeof(string); 19 | 20 | public static Type GetClrType(this AuditColumnType auditColumnType) 21 | { 22 | var mapping = Mappings.Where(m => m.columnType == auditColumnType).Take(1).ToArray(); 23 | return mapping.Any() ? mapping[0].clrType : DefaultClrType; 24 | } 25 | 26 | public static AuditColumnType GetAuditColumnType(this Type type) 27 | { 28 | var mapping = Mappings.Where(m => m.clrType.IsEquivalentTo(type)).Take(1).ToArray(); 29 | return mapping.Any() ? mapping[0].columnType : DefaultColumnType; 30 | } 31 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/SqlGenerators/Operations/DropAuditTriggerSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | using SmartFormat; 5 | 6 | namespace EFCore.AuditExtensions.SqlServer.SqlGenerators.Operations; 7 | 8 | internal class DropAuditTriggerSqlGenerator : IDropAuditTriggerSqlGenerator 9 | { 10 | private const string BaseSql = @"DROP TRIGGER IF EXISTS {TriggerName}"; 11 | 12 | public void Generate(DropAuditTriggerOperation operation, MigrationCommandListBuilder builder) 13 | { 14 | builder.Append(ReplacePlaceholders(BaseSql, operation)); 15 | builder.EndCommand(); 16 | } 17 | 18 | private static string ReplacePlaceholders(string sql, DropAuditTriggerOperation operation) 19 | { 20 | var parameters = GetSqlParameters(operation); 21 | return Smart.Format(sql, parameters); 22 | } 23 | 24 | private static DropAuditTriggerSqlParameters GetSqlParameters(DropAuditTriggerOperation operation) 25 | => new() 26 | { 27 | TriggerName = operation.TriggerName, 28 | }; 29 | 30 | private class DropAuditTriggerSqlParameters 31 | { 32 | public string? TriggerName { get; init; } 33 | } 34 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/ModelCustomizer.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Extensions; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Metadata; 5 | 6 | namespace EFCore.AuditExtensions.Common.EfCore; 7 | 8 | public class ModelCustomizer : RelationalModelCustomizer 9 | { 10 | public ModelCustomizer(ModelCustomizerDependencies dependencies) : base(dependencies) 11 | { } 12 | 13 | public override void Customize(ModelBuilder modelBuilder, DbContext context) 14 | { 15 | base.Customize(modelBuilder, context); 16 | HandleAuditAnnotations(modelBuilder); 17 | } 18 | 19 | private static void HandleAuditAnnotations(ModelBuilder modelBuilder) 20 | { 21 | var entityTypesWithAuditAnnotations = modelBuilder.GetEntityTypesWithDelayedAuditAnnotation(); 22 | foreach (var (entityType, annotation) in entityTypesWithAuditAnnotations) 23 | { 24 | if (annotation.Value is not Action addAuditAction) 25 | { 26 | throw new InvalidOperationException($"Invalid {annotation.Name} annotation value for entity type: {entityType.Name}"); 27 | } 28 | 29 | addAuditAction(entityType); 30 | entityType.RemoveAnnotation(annotation.Name); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/CSharp/Operations/CreateAuditTriggerOperation.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Extensions; 2 | using EFCore.AuditExtensions.Common.SharedModels; 3 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 4 | 5 | namespace EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 6 | 7 | public class CreateAuditTriggerOperation : MigrationOperation, IDependentMigrationOperation 8 | { 9 | public string TriggerName { get; } 10 | 11 | public string AuditedEntityTableName { get; } 12 | 13 | public AuditedEntityKeyProperty[] AuditedEntityTableKey { get; } 14 | 15 | public string AuditTableName { get; } 16 | 17 | public int UpdateOptimisationThreshold { get; } 18 | 19 | public bool NoKeyChanges { get; } 20 | 21 | public Type[] DependsOn { get; } = { typeof(MigrationBuilderExtensions), typeof(AuditColumnType), typeof(AuditedEntityKeyProperty) }; 22 | 23 | public CreateAuditTriggerOperation(string auditedEntityTableName, string auditTableName, string triggerName, AuditedEntityKeyProperty[] auditedEntityTableKey, int updateOptimisationThreshold, bool noKeyChanges) 24 | { 25 | AuditedEntityTableName = auditedEntityTableName; 26 | AuditTableName = auditTableName; 27 | TriggerName = triggerName; 28 | AuditedEntityTableKey = auditedEntityTableKey; 29 | UpdateOptimisationThreshold = updateOptimisationThreshold; 30 | NoKeyChanges = noKeyChanges; 31 | } 32 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/EfCoreColumnFactory.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations.Table; 2 | using EFCore.AuditExtensions.Common.Extensions; 3 | using Microsoft.EntityFrameworkCore.Metadata; 4 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 5 | using Microsoft.EntityFrameworkCore.Storage; 6 | 7 | namespace EFCore.AuditExtensions.Common.EfCore; 8 | 9 | #pragma warning disable EF1001 10 | 11 | internal static class EfCoreColumnFactory 12 | { 13 | public static Column ToEfCoreColumn(this AuditTableColumn auditTableColumn, Table table, TableMapping tableMapping, EntityType entityType, IRelationalTypeMappingSource relationalTypeMappingSource) 14 | { 15 | var columnClrType = auditTableColumn.Type.GetClrType(); 16 | var columnMaxLength = auditTableColumn.MaxLength; 17 | var columnTypeMapping = relationalTypeMappingSource.FindMapping(type: columnClrType, storeTypeName: null, size: columnMaxLength) ?? throw new ArgumentException("Column type is not supported"); 18 | var tableColumn = new Column(auditTableColumn.Name, columnTypeMapping.StoreType, table) 19 | { 20 | IsNullable = auditTableColumn.Nullable, 21 | }; 22 | var columnMapping = new ColumnMapping(new Property(auditTableColumn.Name, columnClrType, null, null, entityType, ConfigurationSource.Explicit, null), tableColumn, tableMapping); 23 | tableColumn.PropertyMappings.Add(columnMapping); 24 | 25 | return tableColumn; 26 | } 27 | } 28 | 29 | #pragma warning restore EF1001 -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/MigrationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using EFCore.AuditExtensions.Common.SharedModels; 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; 5 | 6 | namespace EFCore.AuditExtensions.Common.Extensions; 7 | 8 | public static class MigrationBuilderExtensions 9 | { 10 | public static OperationBuilder CreateAuditTrigger( 11 | this MigrationBuilder migrationBuilder, 12 | string auditedEntityTableName, 13 | string auditTableName, 14 | string triggerName, 15 | AuditedEntityKeyProperty[] auditedEntityTableKey, 16 | int updateOptimisationThreshold, 17 | bool noKeyChanges) 18 | { 19 | var operation = new CreateAuditTriggerOperation(auditedEntityTableName, auditTableName, triggerName, auditedEntityTableKey, updateOptimisationThreshold, noKeyChanges); 20 | migrationBuilder.Operations.Add(operation); 21 | 22 | return new OperationBuilder(operation); 23 | } 24 | 25 | public static OperationBuilder DropAuditTrigger( 26 | this MigrationBuilder migrationBuilder, 27 | string triggerName) 28 | { 29 | var operation = new DropAuditTriggerOperation(triggerName); 30 | migrationBuilder.Operations.Add(operation); 31 | 32 | return new OperationBuilder(operation); 33 | } 34 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.AuditExtensions.Common", "EFCore.AuditExtensions.Common\EFCore.AuditExtensions.Common.csproj", "{536AA71C-4475-495E-B7F5-892C39957FFA}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.AuditExtensions.SqlServer", "EFCore.AuditExtensions.SqlServer\EFCore.AuditExtensions.SqlServer.csproj", "{90CB60AF-DC15-4CF1-B855-5DB613B425E3}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".", ".", "{0DC620BF-0502-4B1D-BCEA-E09B2766B0F7}" 8 | ProjectSection(SolutionItems) = preProject 9 | ..\README.md = ..\README.md 10 | EndProjectSection 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {536AA71C-4475-495E-B7F5-892C39957FFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {536AA71C-4475-495E-B7F5-892C39957FFA}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {536AA71C-4475-495E-B7F5-892C39957FFA}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {536AA71C-4475-495E-B7F5-892C39957FFA}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {90CB60AF-DC15-4CF1-B855-5DB613B425E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {90CB60AF-DC15-4CF1-B855-5DB613B425E3}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {90CB60AF-DC15-4CF1-B855-5DB613B425E3}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {90CB60AF-DC15-4CF1-B855-5DB613B425E3}.Release|Any CPU.Build.0 = Release|Any CPU 26 | EndGlobalSection 27 | EndGlobal 28 | -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/EfCoreTableIndexFactory.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations.Table; 2 | using EFCore.AuditExtensions.Common.EfCore.Models; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata; 5 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 6 | using Index = Microsoft.EntityFrameworkCore.Metadata.Internal.Index; 7 | 8 | namespace EFCore.AuditExtensions.Common.EfCore; 9 | 10 | #pragma warning disable EF1001 11 | 12 | internal static class EfCoreTableIndexFactory 13 | { 14 | public static CustomAuditTableIndex ToEfCoreCustomTableIndex(this AuditTableIndex auditTableIndex, EntityType auditEntityType, Table table, IEnumerable columns) 15 | { 16 | var columnsArray = columns.ToArray(); 17 | if (!string.IsNullOrEmpty(auditTableIndex.Name)) 18 | { 19 | return new CustomAuditTableIndex(auditTableIndex.Name, table, columnsArray); 20 | } 21 | 22 | var indexName = GetDefaultIndexName(auditEntityType, columnsArray); 23 | return new CustomAuditTableIndex(indexName, table, columnsArray); 24 | } 25 | 26 | private static string GetDefaultIndexName(EntityType auditEntityType, IEnumerable columns) 27 | { 28 | var properties = columns.Select(c => new Property(c.Name, typeof(object), null, null, auditEntityType, ConfigurationSource.Explicit, ConfigurationSource.Explicit)).ToArray(); 29 | var index = new Index(properties, auditEntityType, ConfigurationSource.Explicit); 30 | return index.GetDatabaseName() ?? throw new InvalidOperationException("Failed to determine audit table index name"); 31 | } 32 | } 33 | 34 | #pragma warning restore EF1001 -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/Installer.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Extensions; 2 | using EFCore.AuditExtensions.Common.Interceptors; 3 | using EFCore.AuditExtensions.SqlServer.Interceptors; 4 | using EFCore.AuditExtensions.SqlServer.SqlGenerators; 5 | using EFCore.AuditExtensions.SqlServer.SqlGenerators.Operations; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace EFCore.AuditExtensions.SqlServer; 9 | 10 | public static class Installer 11 | { 12 | /// 13 | /// Adds Audit Extensions to the DbContext. 14 | /// If a custom IUserProvider implementation is available use . 15 | /// 16 | /// DbContextOptionsBuilder for the DBContext. 17 | /// DbContextOptionsBuilder for further chaining. 18 | public static DbContextOptionsBuilder UseSqlServerAudit(this DbContextOptionsBuilder optionsBuilder) 19 | => optionsBuilder.UseAuditExtension(); 20 | 21 | /// 22 | /// Adds Audit Extensions to the DbContext with a custom IUserProvider implementation. 23 | /// 24 | /// DbContextOptionsBuilder for the DBContext. 25 | /// Implementation of IUserProvider 26 | /// DbContextOptionsBuilder for further chaining. 27 | public static DbContextOptionsBuilder UseSqlServerAudit(this DbContextOptionsBuilder optionsBuilder) where TUserProvider : class, IUserProvider 28 | => optionsBuilder.UseAuditExtension(); 29 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/SqlGenerators/SqlServerMigrationsSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 3 | using Microsoft.EntityFrameworkCore.Metadata; 4 | using Microsoft.EntityFrameworkCore.Migrations; 5 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 6 | 7 | namespace EFCore.AuditExtensions.SqlServer.SqlGenerators; 8 | 9 | internal class SqlServerMigrationsSqlGenerator : Microsoft.EntityFrameworkCore.Migrations.SqlServerMigrationsSqlGenerator 10 | { 11 | private readonly ICreateAuditTriggerSqlGenerator _createAuditTriggerSqlGenerator; 12 | 13 | private readonly IDropAuditTriggerSqlGenerator _dropAuditTriggerSqlGenerator; 14 | 15 | public SqlServerMigrationsSqlGenerator(MigrationsSqlGeneratorDependencies dependencies, IRelationalAnnotationProvider migrationsAnnotations, ICreateAuditTriggerSqlGenerator createAuditTriggerSqlGenerator, IDropAuditTriggerSqlGenerator dropAuditTriggerSqlGenerator) : base(dependencies, migrationsAnnotations) 16 | { 17 | _createAuditTriggerSqlGenerator = createAuditTriggerSqlGenerator; 18 | _dropAuditTriggerSqlGenerator = dropAuditTriggerSqlGenerator; 19 | } 20 | 21 | protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder) 22 | { 23 | switch (operation) 24 | { 25 | case CreateAuditTriggerOperation createAuditTriggerOperation: 26 | _createAuditTriggerSqlGenerator.Generate(createAuditTriggerOperation, builder, Dependencies.TypeMappingSource); 27 | break; 28 | 29 | case DropAuditTriggerOperation dropAuditTriggerOperation: 30 | _dropAuditTriggerSqlGenerator.Generate(dropAuditTriggerOperation, builder); 31 | break; 32 | 33 | default: 34 | base.Generate(operation, model, builder); 35 | break; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/EfCore/EfCoreTableFactory.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations.Table; 2 | using Microsoft.EntityFrameworkCore.Metadata; 3 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 4 | using Microsoft.EntityFrameworkCore.Storage; 5 | 6 | namespace EFCore.AuditExtensions.Common.EfCore; 7 | 8 | #pragma warning disable EF1001 9 | 10 | internal static class EfCoreTableFactory 11 | { 12 | public static Table ToEfCoreTable(this AuditTable auditTable, RelationalModel relationalModel, IRelationalTypeMappingSource relationalTypeMappingSource) 13 | { 14 | var table = new Table(auditTable.Name, null, relationalModel); 15 | var model = relationalModel.Model as Model ?? throw new ArgumentException("Invalid Model property in RelationalModel argument", nameof(relationalModel)); 16 | var auditEntityType = new EntityType(auditTable.Name, model, false, ConfigurationSource.Explicit); 17 | var tableMapping = new TableMapping(auditEntityType, table, false) 18 | { 19 | IsSharedTablePrincipal = true, 20 | }; 21 | table.EntityTypeMappings.Add(tableMapping); 22 | 23 | var indexColumns = new List(auditTable.Columns.Count(c => c.AuditedEntityKey)); 24 | foreach (var auditTableColumn in auditTable.Columns) 25 | { 26 | var column = auditTableColumn.ToEfCoreColumn(table, tableMapping, auditEntityType, relationalTypeMappingSource); 27 | table.Columns.Add(column.Name, column); 28 | 29 | if (auditTableColumn.AuditedEntityKey && auditTable.Index != null) 30 | { 31 | indexColumns.Add(column); 32 | } 33 | } 34 | 35 | if (auditTable.Index != null) 36 | { 37 | if (!indexColumns.Any()) 38 | { 39 | throw new InvalidOperationException("Cannot define audit table index because no key columns were found"); 40 | } 41 | 42 | var tableIndex = auditTable.Index.ToEfCoreCustomTableIndex(auditEntityType, table, indexColumns); 43 | table.Indexes.Add(tableIndex.Name, tableIndex); 44 | } 45 | 46 | return table; 47 | } 48 | } 49 | 50 | #pragma warning restore EF1001 -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Trigger/AuditTrigger.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.SharedModels; 2 | 3 | namespace EFCore.AuditExtensions.Common.Annotations.Trigger; 4 | 5 | internal class AuditTrigger 6 | { 7 | public string Name { get; } 8 | 9 | public string TableName { get; } 10 | 11 | public string AuditTableName { get; } 12 | 13 | public AuditedEntityKeyProperty[] KeyProperties { get; } 14 | 15 | public int UpdateOptimisationThreshold { get; } 16 | 17 | public bool NoKeyChanges { get; } 18 | 19 | public AuditTrigger(string name, string tableName, string auditTableName, AuditedEntityKeyProperty[] keyProperties, int updateOptimisationThreshold, bool noKeyChanges) 20 | { 21 | Name = name; 22 | TableName = tableName; 23 | AuditTableName = auditTableName; 24 | KeyProperties = keyProperties; 25 | UpdateOptimisationThreshold = updateOptimisationThreshold; 26 | NoKeyChanges = noKeyChanges; 27 | } 28 | 29 | #region Comparers 30 | 31 | protected bool Equals(AuditTrigger other) 32 | => Name == other.Name 33 | && TableName == other.TableName 34 | && AuditTableName == other.AuditTableName 35 | && UpdateOptimisationThreshold == other.UpdateOptimisationThreshold 36 | && NoKeyChanges == other.NoKeyChanges 37 | && KeyProperties.Length == other.KeyProperties.Length 38 | && KeyProperties.All(p => other.KeyProperties.Any(op => op.ColumnName == p.ColumnName && op.ColumnType == p.ColumnType)); 39 | 40 | public override bool Equals(object? obj) 41 | { 42 | if (ReferenceEquals(null, obj)) 43 | { 44 | return false; 45 | } 46 | 47 | if (ReferenceEquals(this, obj)) 48 | { 49 | return true; 50 | } 51 | 52 | if (obj.GetType() != GetType()) 53 | { 54 | return false; 55 | } 56 | 57 | return Equals((AuditTrigger)obj); 58 | } 59 | 60 | public override int GetHashCode() => HashCode.Combine(Name, TableName, AuditTableName, KeyProperties, UpdateOptimisationThreshold, NoKeyChanges); 61 | 62 | public static bool operator ==(AuditTrigger? left, AuditTrigger? right) => Equals(left, right); 63 | 64 | public static bool operator !=(AuditTrigger? left, AuditTrigger? right) => !Equals(left, right); 65 | 66 | #endregion 67 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Trigger/AuditTriggerFactory.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations.Table; 2 | using EFCore.AuditExtensions.Common.Configuration; 3 | using EFCore.AuditExtensions.Common.SharedModels; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Metadata; 6 | using SmartFormat; 7 | 8 | namespace EFCore.AuditExtensions.Common.Annotations.Trigger; 9 | 10 | internal static class AuditTriggerFactory 11 | { 12 | private const string DefaultTriggerNameFormat = "{AuditPrefix}_{TableName}_{AuditTableName}"; 13 | 14 | public static AuditTrigger CreateFromAuditTableAndEntityType(AuditTable auditTable, IReadOnlyEntityType entityType, AuditOptions options) where T : class 15 | { 16 | var tableName = entityType.GetTableName()!; 17 | var auditTableName = auditTable.Name; 18 | var auditEntityKeyProperties = GetKeyProperties(auditTable); 19 | var updateOptimisationThreshold = GetUpdateOptimisationThreshold(options.AuditTriggerOptions); 20 | var noKeyChanges = GetNoKeyChanges(options.AuditTriggerOptions); 21 | 22 | var triggerName = GetTriggerName(options.AuditTriggerOptions, tableName, auditTableName); 23 | return new AuditTrigger(triggerName, tableName, auditTableName, auditEntityKeyProperties, updateOptimisationThreshold, noKeyChanges); 24 | } 25 | 26 | private static AuditedEntityKeyProperty[] GetKeyProperties(AuditTable auditTable) 27 | => auditTable.Columns.Where(c => c.AuditedEntityKey).Select(c => new AuditedEntityKeyProperty(c.Name, c.Type, c.MaxLength)).ToArray(); 28 | 29 | private static bool GetNoKeyChanges(AuditTriggerOptions options) => options.NoKeyChanges ?? false; 30 | 31 | private static int GetUpdateOptimisationThreshold(AuditTriggerOptions options) => options.UpdateOptimisationThreshold ?? 100; 32 | 33 | private static string GetTriggerName(AuditTriggerOptions options, string tableName, string auditTableName) 34 | { 35 | var format = options.NameFormat ?? DefaultTriggerNameFormat; 36 | var parameters = GetNameParameters(tableName, auditTableName); 37 | 38 | return Smart.Format(format, parameters); 39 | } 40 | 41 | private static AuditTriggerNameParameters GetNameParameters(string tableName, string auditTableName) 42 | => new() 43 | { 44 | AuditPrefix = Constants.AuditTriggerPrefix, 45 | TableName = tableName, 46 | AuditTableName = auditTableName, 47 | }; 48 | 49 | private class AuditTriggerNameParameters 50 | { 51 | public string? AuditPrefix { get; init; } 52 | 53 | public string? TableName { get; init; } 54 | 55 | public string? AuditTableName { get; init; } 56 | } 57 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Configuration/AuditOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace EFCore.AuditExtensions.Common.Configuration; 4 | 5 | public class AuditOptions where TEntity : class 6 | { 7 | /// 8 | /// Name of the audit table. 9 | /// Defaults to [AuditedEntityTableName]_Audit. 10 | /// 11 | public string? AuditTableName { get; set; } 12 | 13 | /// 14 | /// Maximum length of OldData and NewData columns. 15 | /// Defaults to EF Core's defaults for string type. 16 | /// 17 | public int? DataColumnsMaxLength { get; set; } 18 | 19 | /// 20 | /// Settings related to the audit trigger. 21 | /// 22 | public AuditTriggerOptions AuditTriggerOptions { get; } = new(); 23 | 24 | /// 25 | /// Settings related to the audited entity's key. 26 | /// 27 | public AuditKeyOptions AuditedEntityKeyOptions { get; } = new(); 28 | } 29 | 30 | public class AuditKeyOptions where TEntity : class 31 | { 32 | /// 33 | /// Lambda expression specifying the properties that make up the primary key. 34 | /// Defaults to the audited entity's key. 35 | /// 36 | public Expression>? KeySelector { get; set; } 37 | 38 | /// 39 | /// Decides whether an index is created on the columns making up the primary key. 40 | /// Defaults to true if is null and false otherwise. 41 | /// 42 | public bool? Index { get; set; } 43 | 44 | /// 45 | /// Name of the audit index. 46 | /// Defaults to EF Core's default name for index (IX_[TableName]_[Property1]_[Property2]). 47 | /// 48 | public string? IndexName { get; set; } 49 | } 50 | 51 | public class AuditTriggerOptions 52 | { 53 | /// 54 | /// Format of the audit trigger name. 55 | /// Defaults to Audit_[AuditedEntityTableName]_[AuditTableName]. 56 | /// 57 | public string? NameFormat { get; set; } 58 | 59 | /// 60 | /// Threshold over which indexed table variables will be used when handling UPDATE statements. 61 | /// Defaults to 100. 62 | /// 63 | public int? UpdateOptimisationThreshold { get; set; } 64 | 65 | /// 66 | /// Decides whether to use simpler but more performant UPDATE statement handling. 67 | /// Setting this to true is a declaration that the entity's key values will not change. 68 | /// If set to true and an entity's key is changed, some information about the UPDATE will be lost. 69 | /// Defaults to false. 70 | /// 71 | public bool? NoKeyChanges { get; set; } 72 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/CSharp/CSharpMigrationOperationGenerator.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 2 | using EFCore.AuditExtensions.Common.SharedModels; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Migrations.Design; 5 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 6 | using SmartFormat; 7 | 8 | namespace EFCore.AuditExtensions.Common.Migrations.CSharp; 9 | 10 | internal class CSharpMigrationOperationGenerator : Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationOperationGenerator 11 | { 12 | private const string BaseCreateAuditTriggerCSharp = 13 | $@".CreateAuditTrigger( 14 | auditedEntityTableName: ""{{AuditedEntityTableName}}"", 15 | auditTableName: ""{{AuditTableName}}"", 16 | triggerName: ""{{TriggerName}}"", 17 | auditedEntityTableKey: new {nameof(AuditedEntityKeyProperty)}[] 18 | \{{ 19 | {{AuditedEntityTableKey:list: new(columnName: ""{{ColumnName}}"", columnType: {nameof(AuditColumnType)}.{{ColumnType}}{{MaxLength:isnull:|, maxLength: {{}}}})|,\n}} 20 | \}}, 21 | updateOptimisationThreshold: {{UpdateOptimisationThreshold}}, 22 | noKeyChanges: {{NoKeyChanges.ToString.ToLower}})"; 23 | 24 | private const string BaseDropAuditTriggerCSharp = @".DropAuditTrigger(triggerName: ""{TriggerName}"")"; 25 | 26 | public CSharpMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) : base(dependencies) 27 | { } 28 | 29 | protected override void Generate(MigrationOperation operation, IndentedStringBuilder builder) 30 | { 31 | if (operation == null) 32 | { 33 | throw new ArgumentNullException(nameof(operation)); 34 | } 35 | 36 | if (builder == null) 37 | { 38 | throw new ArgumentNullException(nameof(builder)); 39 | } 40 | 41 | switch (operation) 42 | { 43 | case CreateAuditTriggerOperation createAuditTriggerOperation: 44 | Generate(createAuditTriggerOperation, builder); 45 | break; 46 | 47 | case DropAuditTriggerOperation dropAuditTriggerOperation: 48 | Generate(dropAuditTriggerOperation, builder); 49 | break; 50 | 51 | default: 52 | base.Generate(operation, builder); 53 | break; 54 | } 55 | } 56 | 57 | private static void Generate(CreateAuditTriggerOperation operation, IndentedStringBuilder builder) 58 | { 59 | var csharpCode = Smart.Format(BaseCreateAuditTriggerCSharp, operation); 60 | builder.AppendLines(csharpCode, true); 61 | } 62 | 63 | private static void Generate(DropAuditTriggerOperation operation, IndentedStringBuilder builder) 64 | { 65 | var csharpCode = Smart.Format(BaseDropAuditTriggerCSharp, operation); 66 | builder.AppendLines(csharpCode, true); 67 | } 68 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/EntityTypeBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations; 2 | using EFCore.AuditExtensions.Common.Annotations.Table; 3 | using EFCore.AuditExtensions.Common.Annotations.Trigger; 4 | using EFCore.AuditExtensions.Common.Configuration; 5 | using Microsoft.EntityFrameworkCore.Metadata; 6 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 7 | 8 | namespace EFCore.AuditExtensions.Common.Extensions; 9 | 10 | public static class EntityTypeBuilderExtensions 11 | { 12 | /// 13 | /// Instructs Entity Framework to create the infrastructure necessary for logging entity changes. 14 | /// 15 | /// EntityTypeBuilder of the entity that should be audited. 16 | /// Action over AuditOptions allowing for customisation of the auditing infrastructure. 17 | /// Type of the entity that should be audited. 18 | /// EntityTypeBuilder for further chaining. 19 | public static EntityTypeBuilder IsAudited(this EntityTypeBuilder entityTypeBuilder, Action>? configureOptions = null) 20 | where T : class 21 | { 22 | entityTypeBuilder.AddDelayedAuditAnnotation( 23 | entityType => 24 | { 25 | var auditOptions = AuditOptionsFactory.GetConfiguredAuditOptions(configureOptions); 26 | var auditTable = AuditTableFactory.CreateFromEntityType(entityType, auditOptions); 27 | var auditTrigger = AuditTriggerFactory.CreateFromAuditTableAndEntityType(auditTable, entityType, auditOptions); 28 | var auditName = $"{Constants.AnnotationPrefix}:{typeof(T).Name}"; 29 | var audit = new Audit(auditName, auditTable, auditTrigger); 30 | entityTypeBuilder.AddAuditAnnotation(audit); 31 | }); 32 | 33 | return entityTypeBuilder; 34 | } 35 | 36 | private static EntityTypeBuilder AddDelayedAuditAnnotation(this EntityTypeBuilder entityTypeBuilder, Action addAuditAction) 37 | where T : class 38 | { 39 | entityTypeBuilder.GetEntityType().AddAnnotation(Constants.AddAuditAnnotationName, addAuditAction); 40 | 41 | return entityTypeBuilder; 42 | } 43 | 44 | private static IMutableEntityType GetEntityType(this EntityTypeBuilder entityTypeBuilder) where T : class 45 | { 46 | var entityType = entityTypeBuilder.Metadata.Model.FindEntityType(typeof(T).FullName!); 47 | if (entityType == null) 48 | { 49 | throw new InvalidOperationException("Entity type is missing from model"); 50 | } 51 | 52 | return entityType; 53 | } 54 | 55 | private static EntityTypeBuilder AddAuditAnnotation(this EntityTypeBuilder entityTypeBuilder, Audit audit) where T : class 56 | { 57 | var entityType = entityTypeBuilder.GetEntityType(); 58 | entityType.AddAnnotation(audit.Name, audit.Serialize()); 59 | return entityTypeBuilder; 60 | } 61 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Annotations/Table/AuditTableFactory.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Configuration; 2 | using EFCore.AuditExtensions.Common.Extensions; 3 | using EFCore.AuditExtensions.Common.SharedModels; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Metadata; 6 | 7 | namespace EFCore.AuditExtensions.Common.Annotations.Table; 8 | 9 | internal static class AuditTableFactory 10 | { 11 | public static AuditTable CreateFromEntityType(IReadOnlyEntityType entityType, AuditOptions options) where T : class 12 | { 13 | var columns = GetColumnsForEntityType(entityType, options); 14 | var name = GetNameForEntityType(entityType, options); 15 | var index = GetIndexFromOptions(options); 16 | 17 | return new AuditTable(name, columns, index); 18 | } 19 | 20 | private static AuditTableColumn[] GetDefaultColumns(int? dataColumnsMaxLength) => new[] 21 | { 22 | new AuditTableColumn(AuditColumnType.Text, Constants.AuditTableColumnNames.OldData, true, false, dataColumnsMaxLength), 23 | new AuditTableColumn(AuditColumnType.Text, Constants.AuditTableColumnNames.NewData, true, false, dataColumnsMaxLength), 24 | new AuditTableColumn(AuditColumnType.Text, Constants.AuditTableColumnNames.OperationType, false, false, Constants.AuditTableColumnMaxLengths.OperationType), 25 | new AuditTableColumn(AuditColumnType.Text, Constants.AuditTableColumnNames.User, false, false, Constants.AuditTableColumnMaxLengths.User), 26 | new AuditTableColumn(AuditColumnType.DateTime, Constants.AuditTableColumnNames.Timestamp, false, false), 27 | }; 28 | 29 | private static AuditTableColumn[] GetKeyColumns(IReadOnlyEntityType entityType, AuditOptions options) where T : class 30 | { 31 | IEnumerable keyProperties; 32 | 33 | if (options.AuditedEntityKeyOptions.KeySelector == null) 34 | { 35 | keyProperties = entityType.GetKeyProperties(); 36 | if (!keyProperties.Any()) 37 | { 38 | throw new InvalidOperationException("Audited entity must either have a simple Key or the AuditedEntityKeySelector must be provided"); 39 | } 40 | } 41 | else 42 | { 43 | keyProperties = options.AuditedEntityKeyOptions.KeySelector.GetKeyProperties(entityType); 44 | if (!keyProperties.Any()) 45 | { 46 | throw new InvalidOperationException("AuditedEntityKeySelector must point to valid properties"); 47 | } 48 | } 49 | 50 | return keyProperties.Select(property => new AuditTableColumn(property.ColumnType, property.ColumnName, false, true, property.MaxLength)).ToArray(); 51 | } 52 | 53 | private static IReadOnlyCollection GetColumnsForEntityType(IReadOnlyEntityType entityType, AuditOptions options) where T : class 54 | { 55 | var columns = new List(); 56 | columns.AddRange(GetKeyColumns(entityType, options)); 57 | columns.AddRange(GetDefaultColumns(options.DataColumnsMaxLength)); 58 | 59 | return columns.ToArray(); 60 | } 61 | 62 | private static string GetNameForEntityType(IReadOnlyEntityType entityType, AuditOptions options) where T : class 63 | => string.IsNullOrEmpty(options.AuditTableName) ? $"{entityType.GetTableName()}{Constants.AuditTableNameSuffix}" : options.AuditTableName; 64 | 65 | private static AuditTableIndex? GetIndexFromOptions(AuditOptions options) where T : class 66 | { 67 | if (options.AuditedEntityKeyOptions.KeySelector == null) 68 | { 69 | if (options.AuditedEntityKeyOptions.Index == false) 70 | { 71 | return null; 72 | } 73 | 74 | return new AuditTableIndex(options.AuditedEntityKeyOptions.IndexName); 75 | } 76 | 77 | if (options.AuditedEntityKeyOptions.Index is null or false) 78 | { 79 | return null; 80 | } 81 | 82 | return new AuditTableIndex(options.AuditedEntityKeyOptions.IndexName); 83 | } 84 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Extensions/DbContextOptionsBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.EfCoreExtension; 2 | using EFCore.AuditExtensions.Common.Interceptors; 3 | using EFCore.AuditExtensions.Common.Migrations; 4 | using EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.EntityFrameworkCore.Diagnostics; 7 | using Microsoft.EntityFrameworkCore.Infrastructure; 8 | using Microsoft.EntityFrameworkCore.Migrations; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using ModelCustomizer = EFCore.AuditExtensions.Common.EfCore.ModelCustomizer; 11 | 12 | namespace EFCore.AuditExtensions.Common.Extensions; 13 | 14 | internal static class DbContextOptionsBuilderExtensions 15 | { 16 | internal static DbContextOptionsBuilder UseAuditExtension(this DbContextOptionsBuilder optionsBuilder) 17 | where TUserProvider : class, IUserProvider where TUserContextInterceptor : BaseUserContextInterceptor where TCreateAuditTriggerSqlGenerator : class, ICreateAuditTriggerSqlGenerator where TDropAuditTriggerSqlGenerator : class, IDropAuditTriggerSqlGenerator where TMigrationsSqlGenerator : MigrationsSqlGenerator 18 | { 19 | ((IDbContextOptionsBuilderInfrastructure)optionsBuilder) 20 | .AddOrUpdateExtension( 21 | new EfCoreAuditExtension( 22 | services => 23 | { 24 | AddUserContextInterceptor(services); 25 | AddCommonServices(services); 26 | })); 27 | 28 | return optionsBuilder.UseAuditExtension(); 29 | } 30 | 31 | internal static DbContextOptionsBuilder UseAuditExtension(this DbContextOptionsBuilder optionsBuilder) 32 | where TCreateAuditTriggerSqlGenerator : class, ICreateAuditTriggerSqlGenerator where TDropAuditTriggerSqlGenerator : class, IDropAuditTriggerSqlGenerator where TMigrationsSqlGenerator : MigrationsSqlGenerator 33 | { 34 | ((IDbContextOptionsBuilderInfrastructure)optionsBuilder) 35 | .AddOrUpdateExtension( 36 | new EfCoreAuditExtension( 37 | AddCommonServices)); 38 | 39 | return optionsBuilder.UseAuditExtension(); 40 | } 41 | 42 | private static DbContextOptionsBuilder UseAuditExtension(this DbContextOptionsBuilder optionsBuilder) where TMigrationsSqlGenerator : MigrationsSqlGenerator 43 | { 44 | optionsBuilder.ReplaceService(); 45 | optionsBuilder.ReplaceService(); 46 | 47 | return optionsBuilder; 48 | } 49 | 50 | private static void AddUserContextInterceptor(this IServiceCollection services) where TUserProvider : class, IUserProvider where TUserContextInterceptor : BaseUserContextInterceptor 51 | { 52 | services.AddScoped( 53 | provider => 54 | { 55 | var applicationServiceProvider = provider 56 | .GetService()? 57 | .FindExtension()? 58 | .ApplicationServiceProvider; 59 | if (applicationServiceProvider == null) 60 | { 61 | return new EmptyUserProvider(); 62 | } 63 | 64 | var userProvider = ActivatorUtilities.GetServiceOrCreateInstance(applicationServiceProvider); 65 | return userProvider; 66 | }); 67 | services.AddScoped(); 68 | } 69 | 70 | private static void AddCommonServices(this IServiceCollection services) where TCreateAuditTriggerSqlGenerator : class, ICreateAuditTriggerSqlGenerator where TDropAuditTriggerSqlGenerator : class, IDropAuditTriggerSqlGenerator 71 | { 72 | services.AddLogging(); 73 | services.AddScoped(); 74 | services.AddScoped(); 75 | services.AddSingleton(); 76 | } 77 | } -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.Common/Migrations/MigrationsModelDiffer.cs: -------------------------------------------------------------------------------- 1 | using EFCore.AuditExtensions.Common.Annotations; 2 | using EFCore.AuditExtensions.Common.Annotations.Trigger; 3 | using EFCore.AuditExtensions.Common.EfCore; 4 | using EFCore.AuditExtensions.Common.Extensions; 5 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; 8 | using Microsoft.EntityFrameworkCore.Metadata; 9 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 10 | using Microsoft.EntityFrameworkCore.Migrations; 11 | using Microsoft.EntityFrameworkCore.Migrations.Operations; 12 | using Microsoft.EntityFrameworkCore.Storage; 13 | using Microsoft.EntityFrameworkCore.Update; 14 | using Microsoft.EntityFrameworkCore.Update.Internal; 15 | using Microsoft.Extensions.Logging; 16 | 17 | namespace EFCore.AuditExtensions.Common.Migrations; 18 | 19 | #pragma warning disable EF1001 20 | 21 | public class MigrationsModelDiffer : Microsoft.EntityFrameworkCore.Migrations.Internal.MigrationsModelDiffer 22 | { 23 | private readonly ILogger _logger; 24 | 25 | public MigrationsModelDiffer( 26 | IRelationalTypeMappingSource typeMappingSource, 27 | IMigrationsAnnotationProvider migrationsAnnotations, 28 | IChangeDetector changeDetector, 29 | IUpdateAdapterFactory updateAdapterFactory, 30 | CommandBatchPreparerDependencies commandBatchPreparerDependencies, 31 | ILogger logger) 32 | : base(typeMappingSource, migrationsAnnotations, changeDetector, updateAdapterFactory, commandBatchPreparerDependencies) 33 | { 34 | _logger = logger; 35 | } 36 | 37 | public override IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) 38 | { 39 | var sourceAuditedEntityTypes = GetAuditedEntityTypes(source); 40 | var targetAuditedEntityTypes = GetAuditedEntityTypes(target); 41 | 42 | var diffContext = new DiffContext(); 43 | var auditMigrationOperations = Diff(sourceAuditedEntityTypes, targetAuditedEntityTypes, diffContext); 44 | var baseMigrationOperations = base.GetDifferences(source, target); 45 | 46 | return Sort(auditMigrationOperations, baseMigrationOperations); 47 | } 48 | 49 | private static IReadOnlyList Sort(IEnumerable auditMigrationOperations, IEnumerable otherOperations) 50 | { 51 | var auditMigrationOperationsArray = auditMigrationOperations as MigrationOperation[] ?? auditMigrationOperations.ToArray(); 52 | var dropAuditTriggerOperations = auditMigrationOperationsArray.OfType().ToArray(); 53 | var dropIndexOperations = auditMigrationOperationsArray.OfType().ToArray(); 54 | var firstOperations = dropAuditTriggerOperations.Concat(dropIndexOperations).ToArray(); 55 | var leftoverAuditOperations = auditMigrationOperationsArray.Except(firstOperations); 56 | 57 | return firstOperations.Concat(otherOperations).Concat(leftoverAuditOperations).ToList(); 58 | } 59 | 60 | #region Diff - AuditedEntityType 61 | 62 | private IEnumerable Diff( 63 | IEnumerable source, 64 | IEnumerable target, 65 | DiffContext diffContext) 66 | => DiffCollection(source, target, diffContext, Diff, Add, Remove, CompareAuditedEntityTypes); 67 | 68 | private IEnumerable Diff(AuditedEntityType source, AuditedEntityType target, DiffContext diffContext) 69 | { 70 | var sourceTable = source.Audit.Table.ToEfCoreTable((RelationalModel)source.EntityType.Model.GetRelationalModel(), TypeMappingSource); 71 | var targetTable = target.Audit.Table.ToEfCoreTable((RelationalModel)target.EntityType.Model.GetRelationalModel(), TypeMappingSource); 72 | var tableOperations = Diff(sourceTable, targetTable, diffContext); 73 | foreach (var operation in tableOperations) 74 | { 75 | yield return operation; 76 | } 77 | 78 | var triggerOperations = Diff(source.Audit.Trigger, target.Audit.Trigger, diffContext); 79 | foreach (var operation in triggerOperations) 80 | { 81 | yield return operation; 82 | } 83 | } 84 | 85 | private IEnumerable Add(AuditedEntityType target, DiffContext diffContext) 86 | { 87 | var targetTable = target.Audit.Table.ToEfCoreTable((RelationalModel)target.EntityType.Model.GetRelationalModel(), TypeMappingSource); 88 | foreach (var operation in Add(targetTable, diffContext)) 89 | { 90 | yield return operation; 91 | } 92 | 93 | foreach (var operation in Add(target.Audit.Trigger, diffContext)) 94 | { 95 | yield return operation; 96 | } 97 | } 98 | 99 | private IEnumerable Remove(AuditedEntityType source, DiffContext diffContext) 100 | { 101 | foreach (var operation in Remove(source.Audit.Trigger, diffContext)) 102 | { 103 | yield return operation; 104 | } 105 | 106 | var targetTable = source.Audit.Table.ToEfCoreTable((RelationalModel)source.EntityType.Model.GetRelationalModel(), TypeMappingSource); 107 | foreach (var operation in Remove(targetTable, diffContext)) 108 | { 109 | yield return operation; 110 | } 111 | } 112 | 113 | private static bool CompareAuditedEntityTypes(AuditedEntityType source, AuditedEntityType target, DiffContext diffContext) => source.EntityType.Name == target.EntityType.Name; 114 | 115 | #endregion 116 | 117 | #region Diff - AuditTrigger 118 | 119 | private static IEnumerable Diff(AuditTrigger source, AuditTrigger target, DiffContext diffContext) 120 | { 121 | if (source == target) 122 | { 123 | yield break; 124 | } 125 | 126 | var dropOperations = Remove(source, diffContext); 127 | foreach (var operation in dropOperations) 128 | { 129 | yield return operation; 130 | } 131 | 132 | var addOperations = Add(target, diffContext); 133 | foreach (var operation in addOperations) 134 | { 135 | yield return operation; 136 | } 137 | } 138 | 139 | private static IEnumerable Add(AuditTrigger target, DiffContext diffContext) 140 | { 141 | yield return new CreateAuditTriggerOperation( 142 | target.TableName, 143 | target.AuditTableName, 144 | target.Name, 145 | target.KeyProperties, 146 | target.UpdateOptimisationThreshold, 147 | target.NoKeyChanges); 148 | } 149 | 150 | private static IEnumerable Remove(AuditTrigger source, DiffContext diffContext) 151 | { 152 | yield return new DropAuditTriggerOperation(source.Name); 153 | } 154 | 155 | #endregion 156 | 157 | #region AuditedEntityType 158 | 159 | private IReadOnlyCollection GetAuditedEntityTypes(IRelationalModel? model) 160 | { 161 | var result = new List(); 162 | var entityTypes = model?.Model.GetAuditedEntityTypes() ?? Array.Empty(); 163 | foreach (var entityType in entityTypes) 164 | { 165 | var auditAnnotation = entityType.GetAuditAnnotation(); 166 | if (auditAnnotation.Value is not string serializedAudit) 167 | { 168 | _logger.LogWarning("Invalid Audit Annotation value for Entity Type: {EntityTypeName}", entityType.Name); 169 | continue; 170 | } 171 | 172 | var audit = serializedAudit.Deserialize(); 173 | if (audit == null) 174 | { 175 | _logger.LogWarning("Invalid serialized Audit in Audit Annotation for Entity Type {EntityTypeName}: {SerializedAudit}", entityType.Name, serializedAudit); 176 | continue; 177 | } 178 | 179 | result.Add(new AuditedEntityType(entityType, audit)); 180 | } 181 | 182 | return result.ToArray(); 183 | } 184 | 185 | private class AuditedEntityType 186 | { 187 | public IEntityType EntityType { get; } 188 | 189 | public Audit Audit { get; } 190 | 191 | public AuditedEntityType(IEntityType entityType, Audit audit) 192 | { 193 | EntityType = entityType; 194 | Audit = audit; 195 | } 196 | } 197 | 198 | #endregion 199 | } 200 | 201 | #pragma warning restore EF1001 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # Tye 66 | .tye/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.vspscc 97 | *.vssscc 98 | .builds 99 | *.pidb 100 | *.svclog 101 | *.scc 102 | 103 | # Chutzpah Test files 104 | _Chutzpah* 105 | 106 | # Visual C++ cache files 107 | ipch/ 108 | *.aps 109 | *.ncb 110 | *.opendb 111 | *.opensdf 112 | *.sdf 113 | *.cachefile 114 | *.VC.db 115 | *.VC.VC.opendb 116 | 117 | # Visual Studio profiler 118 | *.psess 119 | *.vsp 120 | *.vspx 121 | *.sap 122 | 123 | # Visual Studio Trace Files 124 | *.e2e 125 | 126 | # TFS 2012 Local Workspace 127 | $tf/ 128 | 129 | # Guidance Automation Toolkit 130 | *.gpState 131 | 132 | # ReSharper is a .NET coding add-in 133 | _ReSharper*/ 134 | *.[Rr]e[Ss]harper 135 | *.DotSettings.user 136 | 137 | # TeamCity is a build add-in 138 | _TeamCity* 139 | 140 | # DotCover is a Code Coverage Tool 141 | *.dotCover 142 | 143 | # AxoCover is a Code Coverage Tool 144 | .axoCover/* 145 | !.axoCover/settings.json 146 | 147 | # Coverlet is a free, cross platform Code Coverage Tool 148 | coverage*.json 149 | coverage*.xml 150 | coverage*.info 151 | 152 | # Visual Studio code coverage results 153 | *.coverage 154 | *.coveragexml 155 | 156 | # NCrunch 157 | _NCrunch_* 158 | .*crunch*.local.xml 159 | nCrunchTemp_* 160 | 161 | # MightyMoose 162 | *.mm.* 163 | AutoTest.Net/ 164 | 165 | # Web workbench (sass) 166 | .sass-cache/ 167 | 168 | # Installshield output folder 169 | [Ee]xpress/ 170 | 171 | # DocProject is a documentation generator add-in 172 | DocProject/buildhelp/ 173 | DocProject/Help/*.HxT 174 | DocProject/Help/*.HxC 175 | DocProject/Help/*.hhc 176 | DocProject/Help/*.hhk 177 | DocProject/Help/*.hhp 178 | DocProject/Help/Html2 179 | DocProject/Help/html 180 | 181 | # Click-Once directory 182 | publish/ 183 | 184 | # Publish Web Output 185 | *.[Pp]ublish.xml 186 | *.azurePubxml 187 | # Note: Comment the next line if you want to checkin your web deploy settings, 188 | # but database connection strings (with potential passwords) will be unencrypted 189 | *.pubxml 190 | *.publishproj 191 | 192 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 193 | # checkin your Azure Web App publish settings, but sensitive information contained 194 | # in these scripts will be unencrypted 195 | PublishScripts/ 196 | 197 | # NuGet Packages 198 | *.nupkg 199 | # NuGet Symbol Packages 200 | *.snupkg 201 | # The packages folder can be ignored because of Package Restore 202 | **/[Pp]ackages/* 203 | # except build/, which is used as an MSBuild target. 204 | !**/[Pp]ackages/build/ 205 | # Uncomment if necessary however generally it will be regenerated when needed 206 | #!**/[Pp]ackages/repositories.config 207 | # NuGet v3's project.json files produces more ignorable files 208 | *.nuget.props 209 | *.nuget.targets 210 | 211 | # Microsoft Azure Build Output 212 | csx/ 213 | *.build.csdef 214 | 215 | # Microsoft Azure Emulator 216 | ecf/ 217 | rcf/ 218 | 219 | # Windows Store app package directories and files 220 | AppPackages/ 221 | BundleArtifacts/ 222 | Package.StoreAssociation.xml 223 | _pkginfo.txt 224 | *.appx 225 | *.appxbundle 226 | *.appxupload 227 | 228 | # Visual Studio cache files 229 | # files ending in .cache can be ignored 230 | *.[Cc]ache 231 | # but keep track of directories ending in .cache 232 | !?*.[Cc]ache/ 233 | 234 | # Others 235 | ClientBin/ 236 | ~$* 237 | *~ 238 | *.dbmdl 239 | *.dbproj.schemaview 240 | *.jfm 241 | *.pfx 242 | *.publishsettings 243 | orleans.codegen.cs 244 | 245 | # Including strong name files can present a security risk 246 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 247 | #*.snk 248 | 249 | # Since there are multiple workflows, uncomment next line to ignore bower_components 250 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 251 | #bower_components/ 252 | 253 | # RIA/Silverlight projects 254 | Generated_Code/ 255 | 256 | # Backup & report files from converting an old project file 257 | # to a newer Visual Studio version. Backup files are not needed, 258 | # because we have git ;-) 259 | _UpgradeReport_Files/ 260 | Backup*/ 261 | UpgradeLog*.XML 262 | UpgradeLog*.htm 263 | ServiceFabricBackup/ 264 | *.rptproj.bak 265 | 266 | # SQL Server files 267 | *.mdf 268 | *.ldf 269 | *.ndf 270 | 271 | # Business Intelligence projects 272 | *.rdl.data 273 | *.bim.layout 274 | *.bim_*.settings 275 | *.rptproj.rsuser 276 | *- [Bb]ackup.rdl 277 | *- [Bb]ackup ([0-9]).rdl 278 | *- [Bb]ackup ([0-9][0-9]).rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio LightSwitch build output 300 | **/*.HTMLClient/GeneratedArtifacts 301 | **/*.DesktopClient/GeneratedArtifacts 302 | **/*.DesktopClient/ModelManifest.xml 303 | **/*.Server/GeneratedArtifacts 304 | **/*.Server/ModelManifest.xml 305 | _Pvt_Extensions 306 | 307 | # Paket dependency manager 308 | .paket/paket.exe 309 | paket-files/ 310 | 311 | # FAKE - F# Make 312 | .fake/ 313 | 314 | # CodeRush personal settings 315 | .cr/personal 316 | 317 | # Python Tools for Visual Studio (PTVS) 318 | __pycache__/ 319 | *.pyc 320 | 321 | # Cake - Uncomment if you are using it 322 | # tools/** 323 | # !tools/packages.config 324 | 325 | # Tabs Studio 326 | *.tss 327 | 328 | # Telerik's JustMock configuration file 329 | *.jmconfig 330 | 331 | # BizTalk build output 332 | *.btp.cs 333 | *.btm.cs 334 | *.odx.cs 335 | *.xsd.cs 336 | 337 | # OpenCover UI analysis results 338 | OpenCover/ 339 | 340 | # Azure Stream Analytics local run output 341 | ASALocalRun/ 342 | 343 | # MSBuild Binary and Structured Log 344 | *.binlog 345 | 346 | # NVidia Nsight GPU debugger configuration file 347 | *.nvuser 348 | 349 | # MFractors (Xamarin productivity tool) working folder 350 | .mfractor/ 351 | 352 | # Local History for Visual Studio 353 | .localhistory/ 354 | 355 | # BeatPulse healthcheck temp database 356 | healthchecksdb 357 | 358 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 359 | MigrationBackup/ 360 | 361 | # Ionide (cross platform F# VS Code tools) working folder 362 | .ionide/ 363 | 364 | # Fody - auto-generated XML schema 365 | FodyWeavers.xsd 366 | 367 | ## 368 | ## Visual studio for Mac 369 | ## 370 | 371 | 372 | # globs 373 | Makefile.in 374 | *.userprefs 375 | *.usertasks 376 | config.make 377 | config.status 378 | aclocal.m4 379 | install-sh 380 | autom4te.cache/ 381 | *.tar.gz 382 | tarballs/ 383 | test-results/ 384 | 385 | # Mac bundle stuff 386 | *.dmg 387 | *.app 388 | 389 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 390 | # General 391 | .DS_Store 392 | .AppleDouble 393 | .LSOverride 394 | 395 | # Icon must end with two \r 396 | Icon 397 | 398 | 399 | # Thumbnails 400 | ._* 401 | 402 | # Files that might appear in the root of a volume 403 | .DocumentRevisions-V100 404 | .fseventsd 405 | .Spotlight-V100 406 | .TemporaryItems 407 | .Trashes 408 | .VolumeIcon.icns 409 | .com.apple.timemachine.donotpresent 410 | 411 | # Directories potentially created on remote AFP share 412 | .AppleDB 413 | .AppleDesktop 414 | Network Trash Folder 415 | Temporary Items 416 | .apdisk 417 | 418 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 419 | # Windows thumbnail cache files 420 | Thumbs.db 421 | ehthumbs.db 422 | ehthumbs_vista.db 423 | 424 | # Dump file 425 | *.stackdump 426 | 427 | # Folder config file 428 | [Dd]esktop.ini 429 | 430 | # Recycle Bin used on file shares 431 | $RECYCLE.BIN/ 432 | 433 | # Windows Installer files 434 | *.cab 435 | *.msi 436 | *.msix 437 | *.msm 438 | *.msp 439 | 440 | # Windows shortcuts 441 | *.lnk 442 | 443 | # JetBrains Rider 444 | .idea/ 445 | *.sln.iml 446 | 447 | ## 448 | ## Visual Studio Code 449 | ## 450 | .vscode/* 451 | !.vscode/settings.json 452 | !.vscode/tasks.json 453 | !.vscode/launch.json 454 | !.vscode/extensions.json 455 | -------------------------------------------------------------------------------- /src/EFCore.AuditExtensions.SqlServer/SqlGenerators/Operations/CreateAuditTriggerSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using EFCore.AuditExtensions.Common; 3 | using EFCore.AuditExtensions.Common.Extensions; 4 | using EFCore.AuditExtensions.Common.Migrations.CSharp.Operations; 5 | using EFCore.AuditExtensions.Common.Migrations.Sql.Operations; 6 | using EFCore.AuditExtensions.Common.SharedModels; 7 | using Microsoft.EntityFrameworkCore.Migrations; 8 | using Microsoft.EntityFrameworkCore.Storage; 9 | using SmartFormat; 10 | 11 | namespace EFCore.AuditExtensions.SqlServer.SqlGenerators.Operations; 12 | 13 | internal class CreateAuditTriggerSqlGenerator : ICreateAuditTriggerSqlGenerator 14 | { 15 | private const string BaseSql = @" 16 | CREATE TRIGGER [{TriggerName}] ON [{AuditedEntityTableName}] 17 | FOR INSERT, UPDATE, DELETE AS 18 | BEGIN 19 | IF @@ROWCOUNT = 0 RETURN; 20 | SET NOCOUNT ON; 21 | DECLARE @user varchar(255); 22 | SET @user = COALESCE(CAST(SESSION_CONTEXT(N'user') AS VARCHAR(255)), CONCAT(SUSER_NAME(), ' [db]')); 23 | 24 | -- Handle UPDATE statements 25 | IF EXISTS(SELECT * FROM Inserted) AND EXISTS(SELECT * FROM Deleted) 26 | BEGIN 27 | IF @@ROWCOUNT < {UpdateOptimisationThreshold} 28 | BEGIN 29 | INSERT INTO [{AuditTableName}] ({KeyColumnsNamesCsv}, [{OldDataColumnName}], [{NewDataColumnName}], [{OperationTypeColumnName}], [{UserColumnName}], [{TimestampColumnName}]) 30 | SELECT {InsertKeyColumnSql}, (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [{OldDataColumnName}], (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [{NewDataColumnName}], 'UPDATE', @user, GETUTCDATE() 31 | FROM Deleted D {JoinKeyColumnSql} Inserted I ON {KeyColumnsJoinConditionSql}; 32 | END; 33 | ELSE 34 | BEGIN 35 | -- Create table variables with inserted and deleted data 36 | -- and indexes on the key columns that will help with joins 37 | DECLARE @{AuditTableName}_Deleted TABLE ( 38 | {KeyColumnsTableDeclarationSql}, 39 | [{OldDataColumnName}] NVARCHAR(MAX) 40 | PRIMARY KEY CLUSTERED ({KeyColumnsNamesCsv})); 41 | INSERT INTO @{AuditTableName}_Deleted 42 | SELECT {KeyColumnsNamesCsv}, (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [{OldDataColumnName}] 43 | FROM Deleted D; 44 | 45 | DECLARE @{AuditTableName}_Inserted TABLE ( 46 | {KeyColumnsTableDeclarationSql}, 47 | [{NewDataColumnName}] NVARCHAR(MAX) 48 | PRIMARY KEY CLUSTERED ({KeyColumnsNamesCsv})); 49 | INSERT INTO @{AuditTableName}_Inserted 50 | SELECT {KeyColumnsNamesCsv}, (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [{NewDataColumnName}] 51 | FROM Inserted I; 52 | 53 | {CoalesceFullJoinComment} 54 | INSERT INTO [{AuditTableName}] ({KeyColumnsNamesCsv}, [{OldDataColumnName}], [{NewDataColumnName}], [{OperationTypeColumnName}], [{UserColumnName}], [{TimestampColumnName}]) 55 | SELECT {InsertKeyColumnSql}, D.[{OldDataColumnName}], I.[{NewDataColumnName}], 'UPDATE', @user, GETUTCDATE() 56 | FROM @{AuditTableName}_Deleted D {JoinKeyColumnSql} @{AuditTableName}_Inserted I ON {KeyColumnsJoinConditionSql}; 57 | END; 58 | END; 59 | -- Handle INSERT statements 60 | ELSE IF EXISTS(SELECT * FROM Inserted) 61 | BEGIN 62 | INSERT INTO [{AuditTableName}] ({KeyColumnsNamesCsv}, [{OldDataColumnName}], [{NewDataColumnName}], [{OperationTypeColumnName}], [{UserColumnName}], [{TimestampColumnName}]) 63 | SELECT {KeyColumnsNamesCsv}, NULL, (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), 'INSERT', @user, GETUTCDATE() 64 | FROM Inserted I; 65 | END; 66 | -- Handle DELETE statements 67 | ELSE IF EXISTS(SELECT * FROM Deleted) 68 | BEGIN 69 | INSERT INTO [{AuditTableName}] ({KeyColumnsNamesCsv}, [{OldDataColumnName}], [{NewDataColumnName}], [{OperationTypeColumnName}], [{UserColumnName}], [{TimestampColumnName}]) 70 | SELECT {KeyColumnsNamesCsv}, (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), NULL, 'DELETE', @user, GETUTCDATE() 71 | FROM Deleted D; 72 | END; 73 | END;"; 74 | 75 | private static readonly Regex PlaceholderRegex = new("{[A-Za-z]+}", RegexOptions.Compiled); 76 | 77 | public void Generate(CreateAuditTriggerOperation operation, MigrationCommandListBuilder builder, IRelationalTypeMappingSource typeMappingSource) 78 | { 79 | var sqlParameters = GetSqlParameters(operation, typeMappingSource); 80 | foreach (var sqlLine in BaseSql.Split('\n')) 81 | { 82 | builder.Append(ReplacePlaceholders(sqlLine, sqlParameters)); 83 | } 84 | 85 | builder.EndCommand(); 86 | } 87 | 88 | private static string ReplacePlaceholders(string sql, CreateAuditTriggerSqlParameters parameters) 89 | { 90 | var result = sql; 91 | while (PlaceholderRegex.IsMatch(result)) 92 | { 93 | result = Smart.Format(result, parameters); 94 | } 95 | 96 | return result; 97 | } 98 | 99 | private static CreateAuditTriggerSqlParameters GetSqlParameters(CreateAuditTriggerOperation operation, IRelationalTypeMappingSource typeMappingSource) 100 | { 101 | string GetInsertKeyColumnSql(bool noKeyChanges) => noKeyChanges switch 102 | { 103 | true => "{KeyColumns:list:D.[{Name}]|, }", 104 | false => "{KeyColumns:list:COALESCE(D.[{Name}], I.[{Name}])|, }", 105 | }; 106 | 107 | string GetJoinKeyColumnSql(bool noKeyChanges) => noKeyChanges switch 108 | { 109 | true => "INNER JOIN", 110 | false => "FULL OUTER JOIN", 111 | }; 112 | 113 | string GetCoalesceFullJoinComment(bool noKeyChanges) => noKeyChanges switch 114 | { 115 | true => string.Empty, 116 | false => "-- COALESCE and FULL OUTER JOIN prevent loss of data when value of the primary key was changed", 117 | }; 118 | 119 | KeyColumn[] GetKeyColumns(AuditedEntityKeyProperty[] keyProperties) 120 | { 121 | var result = new List(); 122 | foreach (var keyProperty in keyProperties) 123 | { 124 | var keyColumnTypeMapping = typeMappingSource.FindMapping(keyProperty.ColumnType.GetClrType(), storeTypeName: null, size: keyProperty.MaxLength) 125 | ?? throw new ArgumentException("Column type is not supported"); 126 | result.Add(new KeyColumn { Name = keyProperty.ColumnName, Type = keyColumnTypeMapping.StoreType }); 127 | } 128 | 129 | return result.ToArray(); 130 | } 131 | 132 | 133 | return new CreateAuditTriggerSqlParameters 134 | { 135 | TriggerName = operation.TriggerName, 136 | AuditedEntityTableName = operation.AuditedEntityTableName, 137 | AuditTableName = operation.AuditTableName, 138 | KeyColumns = GetKeyColumns(operation.AuditedEntityTableKey), 139 | KeyColumnsNamesCsv = "{KeyColumns:list:[{Name}]|, }", 140 | KeyColumnsJoinConditionSql = "{KeyColumns:list:D.[{Name}] = I.[{Name}]| AND }", 141 | KeyColumnsTableDeclarationSql = "{KeyColumns:list:[{Name}] {Type}|,\n }", 142 | UpdateOptimisationThreshold = operation.UpdateOptimisationThreshold, 143 | InsertKeyColumnSql = GetInsertKeyColumnSql(operation.NoKeyChanges), 144 | JoinKeyColumnSql = GetJoinKeyColumnSql(operation.NoKeyChanges), 145 | CoalesceFullJoinComment = GetCoalesceFullJoinComment(operation.NoKeyChanges), 146 | OldDataColumnName = Constants.AuditTableColumnNames.OldData, 147 | NewDataColumnName = Constants.AuditTableColumnNames.NewData, 148 | OperationTypeColumnName = Constants.AuditTableColumnNames.OperationType, 149 | UserColumnName = Constants.AuditTableColumnNames.User, 150 | TimestampColumnName = Constants.AuditTableColumnNames.Timestamp, 151 | }; 152 | } 153 | 154 | private class CreateAuditTriggerSqlParameters 155 | { 156 | public string? TriggerName { get; init; } 157 | 158 | public string? AuditedEntityTableName { get; init; } 159 | 160 | public string? AuditTableName { get; init; } 161 | 162 | public KeyColumn[]? KeyColumns { get; init; } 163 | 164 | public string? KeyColumnsNamesCsv { get; init; } 165 | 166 | public string? KeyColumnsJoinConditionSql { get; init; } 167 | 168 | public string? KeyColumnsTableDeclarationSql { get; init; } 169 | 170 | public string? OldDataColumnName { get; init; } 171 | 172 | public string? NewDataColumnName { get; init; } 173 | 174 | public string? OperationTypeColumnName { get; init; } 175 | 176 | public string? UserColumnName { get; init; } 177 | 178 | public string? TimestampColumnName { get; init; } 179 | 180 | public int? UpdateOptimisationThreshold { get; init; } 181 | 182 | public string? InsertKeyColumnSql { get; init; } 183 | 184 | public string? JoinKeyColumnSql { get; init; } 185 | 186 | public string? CoalesceFullJoinComment { get; init; } 187 | } 188 | 189 | private class KeyColumn 190 | { 191 | public string? Name { get; init; } 192 | 193 | public string? Type { get; init; } 194 | } 195 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EFCore Audit Extensions 2 | 3 | The aim of this extension is an easy setup of auditing infrastructure for your entities. All changes (insert, update, 4 | delete) to the entities are logged into a separate table (called _Audit Table_) with the following data: 5 | 6 | * _Id_ - identifier of the modified entity [1](#id-column) 7 | * _OldData_ - state of the entity before the change (serialized) 8 | * _NewData_ - state of the entity after the change (serialized) 9 | * _OperationType_ - the type of the change (insert, update, delete) 10 | * _Timestamp_ - when the change happened 11 | * _User_ - information on who made the change 12 | 13 | 1 If the identifier (key) is made of multiple columns, all of these columns will we replicated in the Audit Table. 14 | 15 | All information is logged using a database trigger on the original table. Everything needed for the extension to work is 16 | created and later managed by it through EF Migrations. 17 | 18 | ## Installation (SQL Server) 19 | 20 | 1. Add `EFCore.AuditExtensions.SqlServer` reference to your project. 21 | 2. Add the following attribute to your **startup/data** project: 22 | 23 | ```csharp 24 | [assembly: DesignTimeServicesReference("EFCore.AuditExtensions.Common.EfCore.DesignTimeServices, EFCore.AuditExtensions.Common")] 25 | ``` 26 | 27 | 3. Use the `.UseSqlServerAudit()` extension of the `DbContextOptionsBuilder`, e.g.: 28 | 29 | ```csharp 30 | var host = Host.CreateDefaultBuilder(args) 31 | .ConfigureServices( 32 | services => 33 | { 34 | services.AddDbContext( 35 | options => 36 | options.UseSqlServer("") 37 | .UseSqlServerAudit()); 38 | }).Build(); 39 | ``` 40 | 41 | 4. Use the `IsAudited(...)` extension of the `EntityTypeBuilder` to select the entities which should be audited: 42 | 43 | ```csharp 44 | public class ApplicationDbContext : DbContext 45 | { 46 | public DbSet Products { get; set; } 47 | 48 | public ApplicationDbContext(DbContextOptions options) : base(options) 49 | { } 50 | 51 | protected override void OnModelCreating(ModelBuilder modelBuilder) 52 | => modelBuilder.Entity().IsAudited(); 53 | } 54 | ``` 55 | 56 | 5. Create a migration and update the database. 57 | 58 | And that's all 🥳 59 | 60 | ## Configuration 61 | 62 | The `IsAudited(...)` extension method allows some customisations through its `Action>? configureOptions` 63 | parameter. 64 | 65 | ### Audit Table Name 66 | 67 | By default, the _Audit Table_ is named using `_Audit`. This can be changed using 68 | the `AuditOptions.AuditTableName` property: 69 | 70 | ```csharp 71 | modelBuilder.Entity().IsAudited(options => options.AuditTableName = "ProductAudit"); 72 | ``` 73 | 74 | Given the `ApplicationDbContext` shown above, this will change the _Audit Table_'s name from `Products_Audit` 75 | to `ProductAudit`. 76 | 77 | ### Audited Entity Key Options 78 | 79 | The audited entity **must** have a key. By default, that's what the extension will use. 80 | Primary keys are given priority over other keys. To select specific properties, 81 | the `AuditedEntityKeyOptions.KeySelector` option can be used: 82 | 83 | ```csharp 84 | modelBuilder.Entity().IsAudited( 85 | options => 86 | { 87 | options.AuditedEntityKeyOptions.KeySelector = p => new { p.EAN }; 88 | }); 89 | ``` 90 | 91 | There are two additional options regarding the Audited Entity Key columns: 92 | * `AuditedEntityKeyOptions.Index` - if `true`, an index will be created on the column. This defaults to `true` when no `KeySelector` is specified, and to `false` when it is. 93 | * `AuditedEntityKeyOptions.IndexName` - name for the index. Defaults to default Entity Framework index name convention (`IX_{TableName}_{Column1Name}_{Column2Name}`). 94 | 95 | 96 | ### Audit Trigger Options 97 | #### Name Format 98 | 99 | By default, the trigger name will be generated using the following pattern: 100 | 101 | ``` 102 | {AuditPrefix}_{TableName}_{AuditTableName} 103 | ``` 104 | 105 | This can be changed using the `AuditTriggerNameFormat` option: 106 | 107 | ```csharp 108 | modelBuilder.Entity().IsAudited(options => options.AuditTriggerOptions.NameFormat = "TRIGGER_{TableName}"); 109 | ``` 110 | 111 | The above configuration would change the trigger name from `Audit__Products_Products_Auditt` 112 | to `TRIGGER_Products`. 113 | 114 | #### UPDATE Optimization Threshold (SQL Server) 115 | 116 | In SQL Server the `Inserted` and `Deleted` tables do not have any indexes on them which makes any joins between them really painful. 117 | This can be helped by using table variables with indexes on the key columns. As that comes with a performance overhead of its own, 118 | this setting decides the minimum number of updated rows for which the table variable approach will be used. Defaults to `100`. 119 | ```csharp 120 | modelBuilder.Entity().IsAudited(options => options.AuditTriggerOptions.UpdateOptimisationThreshold = 500); 121 | ``` 122 | 123 | #### No Key Changes 124 | 125 | To prevent any data loss when logging entity updates a `FULL OUTER JOIN` is made between the `Inserted` and `Deleted` tables. 126 | If it is guaranteed that the entity's keys will never change, the `AuditTriggerOptions.NoKeyChanges` property can be set to `true`. 127 | This will result in `INNER JOIN` being used as well as fewer `COALESCE()` calls. Defaults to `false`. 128 | 129 | ```csharp 130 | modelBuilder.Entity().IsAudited(options => options.AuditTriggerOptions.NoKeyChanges = true); 131 | ``` 132 | 133 | ### User Provider 134 | 135 | By default, the _User_ column will be populated with the database user's name (with ` [db]` postfix, e.g. `sa [db]`). In 136 | many cases that will not be enough. To provide more meaningful user information, implement the `IUserProvider` 137 | interface: 138 | 139 | ```csharp 140 | public class UserProvider : IUserProvider 141 | { 142 | private readonly IHttpContextAccessor _httpContext; 143 | 144 | public UserProvider(IHttpContextAccessor httpContext) 145 | { 146 | _httpContext = httpContext; 147 | } 148 | 149 | public string GetCurrentUser() => _httpContext.HttpContext?.User.GetUserId() ?? "Anonymous"; 150 | } 151 | ``` 152 | 153 | And use the `UseSqlServerAudit()` extension of `DbContextOptionsBuilder`: 154 | 155 | ```csharp 156 | var host = Host.CreateDefaultBuilder(args) 157 | .ConfigureServices( 158 | services => 159 | { 160 | services.AddAuditUserProvider(); 161 | services.AddDbContext( 162 | options => 163 | options.UseSqlServer("") 164 | .UseSqlServerAudit()); 165 | }).Build(); 166 | ``` 167 | 168 | ## Compatibility 169 | 170 | The extension is compatible with Entity Framework Core 6 ([main branch](https://github.com/mzwierzchlewski/EFCore.AuditExtensions/tree/main)) and Entity Framework Core 7 ([ef7 branch](https://github.com/mzwierzchlewski/EFCore.AuditExtensions/tree/ef7)). 171 | 172 | Currently, only SQL Server database is supported. This will probably stay that way. To add support for other database 173 | providers, use [this blog post](https://maciejz.dev/ef-core-audit-extensions/#adding-support-for-other-database-engines) and the `EFCore.AuditExtensions.SqlServer` project as your guide. 174 | 175 | ## Sample trigger SQL 176 | ```sql 177 | CREATE TRIGGER [dbo].[Audit__Products_Products_Audit] ON [dbo].[Products] 178 | FOR INSERT, UPDATE, DELETE AS 179 | BEGIN 180 | IF @@ROWCOUNT = 0 RETURN; 181 | SET NOCOUNT ON; 182 | DECLARE @user varchar(255); 183 | SET @user = COALESCE(CAST(SESSION_CONTEXT(N'user') AS VARCHAR(255)), CONCAT(SUSER_NAME(), ' [db]')); 184 | 185 | -- Handle UPDATE statements 186 | IF EXISTS(SELECT * FROM Inserted) AND EXISTS(SELECT * FROM Deleted) 187 | BEGIN 188 | IF @@ROWCOUNT < 100 189 | BEGIN 190 | INSERT INTO [Products_Audit] ([ProductId], [OldData], [NewData], [OperationType], [User], [Timestamp]) 191 | SELECT COALESCE(D.[ProductId], I.[ProductId]), (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [OldData], (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [NewData], 'UPDATE', @user, GETUTCDATE() 192 | FROM Deleted D FULL OUTER JOIN Inserted I ON D.[ProductId] = I.[ProductId]; 193 | END; 194 | ELSE 195 | BEGIN 196 | -- Create table variables with inserted and deleted data 197 | -- and indexes on the key columns that will help with joins 198 | DECLARE @Products_Audit_Deleted TABLE ( 199 | [ProductId] int, 200 | [OldData] NVARCHAR(MAX) 201 | PRIMARY KEY CLUSTERED ([ProductId])); 202 | INSERT INTO @Products_Audit_Deleted 203 | SELECT [ProductId], (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [OldData] 204 | FROM Deleted D; 205 | 206 | DECLARE @Products_Audit_Inserted TABLE ( 207 | [ProductId] int, 208 | [NewData] NVARCHAR(MAX) 209 | PRIMARY KEY CLUSTERED ([ProductId])); 210 | INSERT INTO @Products_Audit_Inserted 211 | SELECT [ProductId], (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [NewData] 212 | FROM Inserted I; 213 | 214 | -- COALESCE and FULL OUTER JOIN prevent loss of data when value of the primary key was changed 215 | INSERT INTO [Products_Audit] ([ProductId], [OldData], [NewData], [OperationType], [User], [Timestamp]) 216 | SELECT COALESCE(D.[ProductId], I.[ProductId]), D.[OldData], I.[NewData], 'UPDATE', @user, GETUTCDATE() 217 | FROM @Products_Audit_Deleted D FULL OUTER JOIN @Products_Audit_Inserted I ON D.[ProductId] = I.[ProductId]; 218 | END; 219 | END; 220 | -- Handle INSERT statements 221 | ELSE IF EXISTS(SELECT * FROM Inserted) 222 | BEGIN 223 | INSERT INTO [Products_Audit] ([ProductId], [OldData], [NewData], [OperationType], [User], [Timestamp]) 224 | SELECT [ProductId], NULL, (SELECT I.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), 'INSERT', @user, GETUTCDATE() 225 | FROM Inserted I; 226 | END; 227 | -- Handle DELETE statements 228 | ELSE IF EXISTS(SELECT * FROM Deleted) 229 | BEGIN 230 | INSERT INTO [Products_Audit] ([ProductId], [OldData], [NewData], [OperationType], [User], [Timestamp]) 231 | SELECT [ProductId], (SELECT D.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), NULL, 'DELETE', @user, GETUTCDATE() 232 | FROM Deleted D; 233 | END; 234 | END; 235 | ``` 236 | 237 | ## Guarantees 238 | 239 | No guarantees are provided - use at your own risk. 240 | --------------------------------------------------------------------------------