Skip to content

Commit 0e7d1c0

Browse files
committed
feat(audit): implement persistent IAuditLogStore for all 13 providers (#574)
Add database-backed IAuditLogStore implementations for production use: - EF Core: AuditLogStoreEF with entity configuration - Dapper: AuditLogStoreDapper for SQLite, SQL Server, PostgreSQL, MySQL - ADO.NET: AuditLogStoreADO for SQLite, SQL Server, PostgreSQL, MySQL - MongoDB: AuditLogStoreMongoDB with BSON document and indexes Features: - Opt-in configuration via UseAuditLogStore option - Provider-specific SQL scripts for table creation - Optimized indexes for entity history lookups - Sparse indexes for nullable fields (MongoDB) Tests: - 63 unit tests for all store implementations - 28 guard tests for constructor/method validation - Updated MongoDB options and collection names tests Documentation: - Updated docs/features/audit-tracking.md - Updated CHANGELOG.md with feature details Closes #574
1 parent d098e54 commit 0e7d1c0

60 files changed

Lines changed: 3618 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,57 @@ services.AddSingleton<IAuditLogStore, InMemoryAuditLogStore>();
8282

8383
---
8484

85+
#### Persistent IAuditLogStore Implementations for All 13 Database Providers (#574)
86+
87+
Added database-backed `IAuditLogStore` implementations for all 13 database providers, enabling persistent audit trail storage for production use.
88+
89+
**New Implementations**:
90+
91+
| Store Class | Package | Database |
92+
|-------------|---------|----------|
93+
| `AuditLogStoreEF` | `Encina.EntityFrameworkCore` | SQLite, SQL Server, PostgreSQL, MySQL |
94+
| `AuditLogStoreDapper` | `Encina.Dapper.*` | SQLite, SQL Server, PostgreSQL, MySQL |
95+
| `AuditLogStoreADO` | `Encina.ADO.*` | SQLite, SQL Server, PostgreSQL, MySQL |
96+
| `AuditLogStoreMongoDB` | `Encina.MongoDB` | MongoDB |
97+
98+
**MongoDB Support**:
99+
100+
- New `AuditLogDocument` with BSON serialization attributes
101+
- Optimized indexes for entity history lookups
102+
- Sparse indexes for UserId and CorrelationId fields
103+
104+
**Configuration**:
105+
106+
```csharp
107+
// EF Core
108+
services.AddEncinaEntityFrameworkCore<AppDbContext>(config =>
109+
{
110+
config.UseAuditLogStore = true; // Registers AuditLogStoreEF
111+
});
112+
113+
// MongoDB
114+
services.AddEncinaMongoDB(config =>
115+
{
116+
config.UseAuditLogStore = true; // Registers AuditLogStoreMongoDB
117+
});
118+
119+
// Dapper (auto-registered via UseAuditLogStore in options)
120+
// ADO.NET (auto-registered via UseAuditLogStore in options)
121+
```
122+
123+
**Tests Added**:
124+
125+
| Test Type | Count | Description |
126+
|-----------|-------|-------------|
127+
| Unit Tests | 45+ | All store implementations |
128+
| Guard Tests | 20+ | Constructor and method null checks |
129+
130+
**Documentation**: Updated `docs/features/audit-tracking.md` with production configuration examples.
131+
132+
**Related Issue**: [#574 - Persistent IAuditLogStore Implementations](https://github.com/dlrivada/Encina/issues/574)
133+
134+
---
135+
85136
#### Immutable Records Support for IUnitOfWork and IFunctionalRepository (#572)
86137

87138
Extended immutable record support to `IUnitOfWork` and `IFunctionalRepository` interfaces, providing a consistent API for updating immutable aggregates across all data access patterns.

docs/features/audit-tracking.md

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,12 @@ services.AddEncinaEntityFrameworkCore<AppDbContext>(config =>
363363
};
364364
});
365365

366-
// Optional: Register audit log store for detailed history
367-
// InMemoryAuditLogStore is for TESTING ONLY - not for production
368-
services.AddSingleton<IAuditLogStore, InMemoryAuditLogStore>();
366+
// Register audit log store for detailed history
367+
// Option 1: Use built-in database-backed store (recommended for production)
368+
config.UseAuditLogStore = true; // Auto-registers AuditLogStoreEF
369369
370-
// For production, use database-backed stores (see Issue #574)
371-
// services.AddScoped<IAuditLogStore, AuditLogStoreEF>(); // EF Core
372-
// services.AddScoped<IAuditLogStore, AuditLogStoreDapperSqlServer>(); // Dapper
370+
// Option 2: Use InMemoryAuditLogStore for TESTING ONLY
371+
// services.AddSingleton<IAuditLogStore, InMemoryAuditLogStore>();
373372
```
374373

375374
---
@@ -534,12 +533,31 @@ public class ModifierOnly : IModifiedBy
534533

535534
### What about persistent audit log storage?
536535

537-
Currently, only `InMemoryAuditLogStore` is provided (for testing purposes). Persistent database-backed implementations for all 13 providers are tracked in [Issue #574](https://github.com/dlrivada/Encina/issues/574).
536+
Encina provides persistent database-backed `IAuditLogStore` implementations for all 13 database providers:
538537

539-
For production use with `LogChangesToStore = true`, you can:
538+
- **EF Core**: `AuditLogStoreEF` - works with SQLite, SQL Server, PostgreSQL, MySQL
539+
- **Dapper**: `AuditLogStoreDapper` - provider-specific implementations for all 4 databases
540+
- **ADO.NET**: `AuditLogStoreADO` - provider-specific implementations for all 4 databases
541+
- **MongoDB**: `AuditLogStoreMongoDB` - with optimized indexes for efficient history lookups
540542

541-
1. **Wait for #574**: Database-backed stores for EF Core, Dapper, ADO.NET, and MongoDB
542-
2. **Implement your own**: Create a class implementing `IAuditLogStore` for your database
543+
Enable persistent audit logging:
544+
545+
```csharp
546+
// EF Core
547+
services.AddEncinaEntityFrameworkCore<AppDbContext>(config =>
548+
{
549+
config.UseAuditing = true;
550+
config.AuditingOptions.LogChangesToStore = true;
551+
config.UseAuditLogStore = true; // Registers AuditLogStoreEF
552+
});
553+
554+
// MongoDB
555+
services.AddEncinaMongoDB(config =>
556+
{
557+
config.UseAuditLogStore = true; // Registers AuditLogStoreMongoDB
558+
config.CreateIndexes = true; // Creates optimized indexes
559+
});
560+
```
543561

544562
### How do I query soft-deleted entities?
545563

@@ -590,10 +608,10 @@ services.AddScoped<IRequestContext, HttpRequestContext>();
590608
| Store | Package | Status | Use Case |
591609
|-------|---------|--------|----------|
592610
| `InMemoryAuditLogStore` | `Encina.DomainModeling` | ✅ Available | **Testing only** |
593-
| `AuditLogStoreEF` | `Encina.EntityFrameworkCore` | 🔜 Planned (#574) | EF Core (all 4 providers) |
594-
| `AuditLogStoreDapper*` | `Encina.Dapper.*` | 🔜 Planned (#574) | Dapper (4 providers) |
595-
| `AuditLogStoreAdo*` | `Encina.ADO.*` | 🔜 Planned (#574) | ADO.NET (4 providers) |
596-
| `AuditLogStoreMongo` | `Encina.MongoDB` | 🔜 Planned (#574) | MongoDB |
611+
| `AuditLogStoreEF` | `Encina.EntityFrameworkCore` | ✅ Available | EF Core (all 4 providers) |
612+
| `AuditLogStoreDapper` | `Encina.Dapper.*` | ✅ Available | Dapper (SQLite, SqlServer, PostgreSQL, MySQL) |
613+
| `AuditLogStoreADO` | `Encina.ADO.*` | ✅ Available | ADO.NET (SQLite, SqlServer, PostgreSQL, MySQL) |
614+
| `AuditLogStoreMongoDB` | `Encina.MongoDB` | ✅ Available | MongoDB |
597615

598616
### Custom Implementation
599617

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System.Data;
2+
using Encina.DomainModeling.Auditing;
3+
using Encina.Messaging;
4+
using MySqlConnector;
5+
6+
namespace Encina.ADO.MySQL.Auditing;
7+
8+
/// <summary>
9+
/// ADO.NET implementation of <see cref="IAuditLogStore"/> for MySQL/MariaDB.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// This implementation uses raw MySqlCommand and MySqlDataReader for maximum performance.
14+
/// SQL statements use MySQL-specific syntax with backtick identifier quoting.
15+
/// </para>
16+
/// <para>
17+
/// Each call to <see cref="LogAsync"/> immediately persists the audit entry to the database.
18+
/// </para>
19+
/// </remarks>
20+
public sealed class AuditLogStoreADO : IAuditLogStore
21+
{
22+
private readonly IDbConnection _connection;
23+
private readonly string _tableName;
24+
private readonly string _insertSql;
25+
private readonly string _selectSql;
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="AuditLogStoreADO"/> class.
29+
/// </summary>
30+
/// <param name="connection">The database connection.</param>
31+
/// <param name="tableName">The audit log table name (default: AuditLogs).</param>
32+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="connection"/> is null.</exception>
33+
public AuditLogStoreADO(IDbConnection connection, string tableName = "AuditLogs")
34+
{
35+
ArgumentNullException.ThrowIfNull(connection);
36+
_connection = connection;
37+
_tableName = SqlIdentifierValidator.ValidateTableName(tableName);
38+
39+
// Build and cache SQL statements with backtick quoting
40+
_insertSql = $@"
41+
INSERT INTO `{_tableName}`
42+
(`Id`, `EntityType`, `EntityId`, `Action`, `UserId`, `TimestampUtc`, `OldValues`, `NewValues`, `CorrelationId`)
43+
VALUES
44+
(@Id, @EntityType, @EntityId, @Action, @UserId, @TimestampUtc, @OldValues, @NewValues, @CorrelationId)";
45+
46+
_selectSql = $@"
47+
SELECT `Id`, `EntityType`, `EntityId`, `Action`, `UserId`, `TimestampUtc`, `OldValues`, `NewValues`, `CorrelationId`
48+
FROM `{_tableName}`
49+
WHERE `EntityType` = @EntityType AND `EntityId` = @EntityId
50+
ORDER BY `TimestampUtc` DESC";
51+
}
52+
53+
/// <inheritdoc/>
54+
public async Task LogAsync(AuditLogEntry entry, CancellationToken cancellationToken = default)
55+
{
56+
ArgumentNullException.ThrowIfNull(entry);
57+
58+
using var command = _connection.CreateCommand();
59+
command.CommandText = _insertSql;
60+
AddParameter(command, "@Id", entry.Id);
61+
AddParameter(command, "@EntityType", entry.EntityType);
62+
AddParameter(command, "@EntityId", entry.EntityId);
63+
AddParameter(command, "@Action", (int)entry.Action);
64+
AddParameter(command, "@UserId", entry.UserId);
65+
AddParameter(command, "@TimestampUtc", entry.TimestampUtc);
66+
AddParameter(command, "@OldValues", entry.OldValues);
67+
AddParameter(command, "@NewValues", entry.NewValues);
68+
AddParameter(command, "@CorrelationId", entry.CorrelationId);
69+
70+
if (_connection.State != ConnectionState.Open)
71+
await OpenConnectionAsync(cancellationToken);
72+
73+
await ExecuteNonQueryAsync(command, cancellationToken);
74+
}
75+
76+
/// <inheritdoc/>
77+
public async Task<IEnumerable<AuditLogEntry>> GetHistoryAsync(
78+
string entityType,
79+
string entityId,
80+
CancellationToken cancellationToken = default)
81+
{
82+
ArgumentNullException.ThrowIfNull(entityType);
83+
ArgumentNullException.ThrowIfNull(entityId);
84+
85+
using var command = _connection.CreateCommand();
86+
command.CommandText = _selectSql;
87+
AddParameter(command, "@EntityType", entityType);
88+
AddParameter(command, "@EntityId", entityId);
89+
90+
var entries = new List<AuditLogEntry>();
91+
92+
if (_connection.State != ConnectionState.Open)
93+
await OpenConnectionAsync(cancellationToken);
94+
95+
using var reader = await ExecuteReaderAsync(command, cancellationToken);
96+
while (await ReadAsync(reader, cancellationToken))
97+
{
98+
entries.Add(new AuditLogEntry(
99+
Id: reader.GetString(reader.GetOrdinal("Id")),
100+
EntityType: reader.GetString(reader.GetOrdinal("EntityType")),
101+
EntityId: reader.GetString(reader.GetOrdinal("EntityId")),
102+
Action: (AuditAction)reader.GetInt32(reader.GetOrdinal("Action")),
103+
UserId: reader.IsDBNull(reader.GetOrdinal("UserId"))
104+
? null
105+
: reader.GetString(reader.GetOrdinal("UserId")),
106+
TimestampUtc: reader.GetDateTime(reader.GetOrdinal("TimestampUtc")),
107+
OldValues: reader.IsDBNull(reader.GetOrdinal("OldValues"))
108+
? null
109+
: reader.GetString(reader.GetOrdinal("OldValues")),
110+
NewValues: reader.IsDBNull(reader.GetOrdinal("NewValues"))
111+
? null
112+
: reader.GetString(reader.GetOrdinal("NewValues")),
113+
CorrelationId: reader.IsDBNull(reader.GetOrdinal("CorrelationId"))
114+
? null
115+
: reader.GetString(reader.GetOrdinal("CorrelationId"))));
116+
}
117+
118+
return entries;
119+
}
120+
121+
private static void AddParameter(IDbCommand command, string name, object? value)
122+
{
123+
var parameter = command.CreateParameter();
124+
parameter.ParameterName = name;
125+
parameter.Value = value ?? DBNull.Value;
126+
command.Parameters.Add(parameter);
127+
}
128+
129+
private static Task OpenConnectionAsync(CancellationToken cancellationToken)
130+
{
131+
cancellationToken.ThrowIfCancellationRequested();
132+
return Task.CompletedTask;
133+
}
134+
135+
private static async Task<IDataReader> ExecuteReaderAsync(IDbCommand command, CancellationToken cancellationToken)
136+
{
137+
if (command is MySqlCommand sqlCommand)
138+
return await sqlCommand.ExecuteReaderAsync(cancellationToken);
139+
140+
return await Task.Run(command.ExecuteReader, cancellationToken);
141+
}
142+
143+
private static async Task<int> ExecuteNonQueryAsync(IDbCommand command, CancellationToken cancellationToken)
144+
{
145+
if (command is MySqlCommand sqlCommand)
146+
return await sqlCommand.ExecuteNonQueryAsync(cancellationToken);
147+
148+
return await Task.Run(command.ExecuteNonQuery, cancellationToken);
149+
}
150+
151+
private static async Task<bool> ReadAsync(IDataReader reader, CancellationToken cancellationToken)
152+
{
153+
if (reader is MySqlDataReader sqlReader)
154+
return await sqlReader.ReadAsync(cancellationToken);
155+
156+
return await Task.Run(reader.Read, cancellationToken);
157+
}
158+
}

src/Encina.ADO.MySQL/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
#nullable enable
2+
Encina.ADO.MySQL.Auditing.AuditLogStoreADO
3+
Encina.ADO.MySQL.Auditing.AuditLogStoreADO.AuditLogStoreADO(System.Data.IDbConnection! connection, string! tableName = "AuditLogs") -> void
4+
Encina.ADO.MySQL.Auditing.AuditLogStoreADO.LogAsync(Encina.DomainModeling.Auditing.AuditLogEntry! entry, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
5+
Encina.ADO.MySQL.Auditing.AuditLogStoreADO.GetHistoryAsync(string! entityType, string! entityId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<Encina.DomainModeling.Auditing.AuditLogEntry!>!>!
26
Encina.ADO.MySQL.Inbox.InboxMessage
37
Encina.ADO.MySQL.Inbox.InboxMessage.ErrorMessage.get -> string?
48
Encina.ADO.MySQL.Inbox.InboxMessage.ErrorMessage.set -> void
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- =============================================
2+
-- Create AuditLogs table for MySQL/MariaDB
3+
-- For audit trail tracking
4+
-- =============================================
5+
6+
CREATE TABLE IF NOT EXISTS `AuditLogs`
7+
(
8+
`Id` CHAR(36) NOT NULL,
9+
`EntityType` VARCHAR(256) NOT NULL,
10+
`EntityId` VARCHAR(256) NOT NULL,
11+
`Action` INT NOT NULL,
12+
`UserId` VARCHAR(256) NULL,
13+
`TimestampUtc` DATETIME(6) NOT NULL,
14+
`OldValues` LONGTEXT NULL,
15+
`NewValues` LONGTEXT NULL,
16+
`CorrelationId` VARCHAR(256) NULL,
17+
18+
PRIMARY KEY (`Id`),
19+
20+
INDEX `IX_AuditLogs_Entity` (`EntityType`, `EntityId`),
21+
INDEX `IX_AuditLogs_Timestamp` (`TimestampUtc`),
22+
INDEX `IX_AuditLogs_UserId` (`UserId`),
23+
INDEX `IX_AuditLogs_CorrelationId` (`CorrelationId`)
24+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

src/Encina.ADO.MySQL/ServiceCollectionExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
using System.Data;
2+
using Encina.ADO.MySQL.Auditing;
23
using Encina.ADO.MySQL.Health;
34
using Encina.ADO.MySQL.Inbox;
45
using Encina.ADO.MySQL.Outbox;
56
using Encina.ADO.MySQL.Sagas;
67
using Encina.ADO.MySQL.Scheduling;
8+
using Encina.DomainModeling.Auditing;
79
using Encina.Messaging;
810
using Encina.Messaging.Health;
911
using Microsoft.Extensions.DependencyInjection;
@@ -44,6 +46,12 @@ public static IServiceCollection AddEncinaADO(
4446
ScheduledMessageFactory,
4547
OutboxProcessor>(config);
4648

49+
// Register audit log store if enabled
50+
if (config.UseAuditLogStore)
51+
{
52+
services.AddScoped<IAuditLogStore, AuditLogStoreADO>();
53+
}
54+
4755
// Register provider health check if enabled
4856
if (config.ProviderHealthCheck.Enabled)
4957
{

0 commit comments

Comments
 (0)