├── 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 |
--------------------------------------------------------------------------------