Skip to content

Commit 23d0a40

Browse files
Copilotascott18
andauthored
feat: #515 Enable stored procedures by default for audit logging (#588)
--------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ascott18 <5017521+ascott18@users.noreply.github.com> Co-authored-by: Andrew Scott <andrew.scott@intellitect.com>
1 parent 9f30815 commit 23d0a40

10 files changed

Lines changed: 447 additions & 22 deletions

File tree

.github/copilot-instructions.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ The required tools and dependencies are automatically installed via the GitHub A
1212

1313
## Instructions
1414

15-
- Format PR titles with Semantic Commits. The work item number should follow the colon after the commit type like `feat: #12345 added ...`
16-
- Always update the documentation when making changes or adding features that will affect developers who use Coalesce.
17-
- Always add an entry to CHANGELOG.md when adding new features or fixing non-trivial bugs.
18-
- Avoid making breaking changes if not necessary. A less obvious example of a breaking change would be changing an existing CSS class name.
15+
- YOU MUST Format PR titles with Semantic Commits. The work item number should follow the colon after the commit type like `feat: #12345 added ...`
16+
- YOU MUST update the documentation when making changes or adding features that will affect developers who use Coalesce.
17+
- YOU MUST add an entry to CHANGELOG.md when adding new features or fixing non-trivial bugs. Be concise and factual. The changelog is not a marketing document - you don't have to convince users of the value of the feature, or explain how to use it or configure it.
18+
- YOU MUST Avoid making breaking changes if not necessary. A less obvious example of a breaking change would be changing an existing CSS class name.
1919
- Consider adding or updating example files in `playground\Coalesce.Web.Vue3\src\examples` when making changes to coalesce-vue-vuetify.
2020

2121
## Validation Checklist

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- The types generated for inheritance hierarchies have changed significantly. If two or more models in a type hierarchy (i.e. a base type and a derived type) are both exposed by Coalesce, that relationship is now reflected throughout the generated DTOs, generated TypeScript, and admin pages. The generated ViewModels classes for abstract classes are now just proxies intended to be used only for loading one of the concrete implementation types.
77
- `StandardBehaviors.AfterDelete` is now `AfterDeleteAsync` and has a different signature and semantics. Instead of modifying the resulting `item` and `includeTree` with `ref` parameters, these values can be optionally overridden by returning an ItemResult with its `Object` and `IncludeTree` properties populated with non-null values.
88
- `ViewModel.$getErrors` now returns a `string[]` instead of a `Generator<string>`.
9+
- `IntelliTect.Coalesce.AuditLogging` now uses stored procedures by default to upsert audit log entries. You can disable this (e.g. if your application lacks permission to create/update stored procedures) by chaining `.WithStoredProcedures(false)` when you configure audit logging.
910
- The CommonJS build of coalesce-vue has been dropped - only the ESM build remains. Most projects should be unaffected.
1011

1112
## Features

docs/topics/audit-logging.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,26 @@ public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
139139
}
140140
```
141141

142+
### Stored Procedures
143+
144+
For SQL Server databases, audit logging uses stored procedures by default instead of executing the merge SQL directly. This provides better performance through compiled execution plans and easier monitoring/troubleshooting:
145+
146+
``` c#
147+
public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
148+
{
149+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
150+
{
151+
optionsBuilder.UseCoalesceAuditLogging<AuditLog>(x => x
152+
.WithAugmentation<OperationContext>()
153+
// Stored procedures are enabled by default for SQL Server
154+
// .WithStoredProcedures(false) // Disable if you prefer raw SQL
155+
);
156+
}
157+
}
158+
```
159+
160+
When enabled, Coalesce will automatically create stored procedures with names like `AuditMerge_A1B2C3D4`, where the suffix is a short hash of the SQL content. This prevents conflicts between different application versions that might have different audit log schemas. The stored procedure is created automatically on first use and reused for subsequent operations.
161+
142162
### Property Descriptions
143163

144164
The `AuditLogProperty` children of your `IAuditLog` implementation have two properties `OldValueDescription` and `NewValueDescription` that can be used to hold a description of the old and new values. By default, Coalesce will populate the descriptions of foreign key properties with the [List Text](/modeling/model-components/attributes/list-text.md) of the referenced principal entity. This greatly improves the usability of the audit logs, which would otherwise only show meaningless numbers or GUIDs for foreign keys that changed.

src/IntelliTect.Coalesce.AuditLogging.Tests/SqlServerAuditTests.cs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Data.SqlClient;
22
using Microsoft.EntityFrameworkCore;
3+
using System.Data;
34

45
namespace IntelliTect.Coalesce.AuditLogging.Tests;
56

@@ -123,11 +124,148 @@ public async Task WithSqlServer_CreatesNewRecordForUnmergableChanges()
123124
Assert.Equal(2, db.AuditLogs.Count(l => l.State == AuditEntryState.EntityModified));
124125
}
125126

127+
[SkippableFact]
128+
public async Task WithSqlServer_StoredProceduresWork()
129+
{
130+
// Arrange - use stored procedures
131+
using TestDbContext db = BuildDbContextWithStoredProcedures();
132+
133+
var user = new AppUser { Name = "Bob", Title = "Intern" };
134+
db.Add(user);
135+
await db.SaveChangesAsync();
136+
137+
// Act/Assert
138+
user.Title = "Manager";
139+
await db.SaveChangesAsync();
140+
141+
Assert.Equal(2, db.AuditLogs.Count()); // Two records: EntityAdded, and EntityUpdated
142+
143+
// Act/Assert - second change should merge like raw SQL
144+
user.Title = "CEO";
145+
await db.SaveChangesAsync();
146+
147+
Assert.Equal(2, db.AuditLogs.Count()); // Two records: EntityAdded, and EntityUpdated
148+
149+
// Assert - verify the merge happened correctly
150+
var entry = Assert.Single(db.AuditLogs.Include(c => c.Properties).Where(c => c.State == AuditEntryState.EntityModified));
151+
152+
var propChange = Assert.Single(entry.Properties!);
153+
Assert.Equal(nameof(AppUser.Title), propChange.PropertyName);
154+
Assert.Equal("Intern", propChange.OldValue);
155+
Assert.Equal("CEO", propChange.NewValue);
156+
}
157+
158+
[SkippableFact]
159+
public async Task WithSqlServer_StoredProcedureIsCreated()
160+
{
161+
// Arrange
162+
using TestDbContext db = BuildDbContextWithStoredProcedures();
163+
164+
var user = new AppUser { Name = "Bob", Title = "Intern" };
165+
db.Add(user);
166+
await db.SaveChangesAsync();
167+
168+
// Act - trigger merge operation to create stored procedure
169+
user.Title = "Manager";
170+
await db.SaveChangesAsync();
171+
172+
// Assert - verify stored procedure was created
173+
using (var command = db.Database.GetDbConnection().CreateCommand())
174+
{
175+
command.CommandText = "SELECT COUNT(*) FROM sys.procedures WHERE name LIKE 'AuditMerge_%'";
176+
if (db.Database.GetDbConnection().State != ConnectionState.Open)
177+
await db.Database.GetDbConnection().OpenAsync();
178+
var result = await command.ExecuteScalarAsync();
179+
var procedureCount = Convert.ToInt32(result);
180+
Assert.True(procedureCount > 0, "Expected at least one AuditMerge stored procedure to be created");
181+
}
182+
}
183+
184+
[SkippableFact]
185+
public async Task WithSqlServer_DifferentModelsCreateDifferentStoredProcedures()
186+
{
187+
// This test verifies that different entity models result in different stored procedures
188+
// due to the hash-based naming scheme
189+
190+
// Arrange - Create two different contexts that would generate different SQL
191+
using TestDbContext db1 = BuildDbContextWithStoredProcedures();
192+
193+
var user1 = new AppUser { Name = "Bob" };
194+
db1.Add(user1);
195+
await db1.SaveChangesAsync();
196+
197+
user1.Name = "Robert";
198+
await db1.SaveChangesAsync(); // This should create a stored procedure
199+
200+
// Get count of stored procedures after first context
201+
int initialCount;
202+
using (var command = db1.Database.GetDbConnection().CreateCommand())
203+
{
204+
command.CommandText = "SELECT COUNT(*) FROM sys.procedures WHERE name LIKE 'AuditMerge_%'";
205+
if (db1.Database.GetDbConnection().State != ConnectionState.Open)
206+
await db1.Database.GetDbConnection().OpenAsync();
207+
var result = await command.ExecuteScalarAsync();
208+
initialCount = Convert.ToInt32(result);
209+
}
210+
211+
// The same model should reuse the same stored procedure
212+
using TestDbContext db2 = BuildDbContextWithStoredProcedures();
213+
214+
var user2 = new AppUser { Name = "Alice" };
215+
db2.Add(user2);
216+
await db2.SaveChangesAsync();
217+
218+
user2.Name = "Alicia";
219+
await db2.SaveChangesAsync(); // This should reuse the existing stored procedure
220+
221+
int finalCount;
222+
using (var command = db2.Database.GetDbConnection().CreateCommand())
223+
{
224+
command.CommandText = "SELECT COUNT(*) FROM sys.procedures WHERE name LIKE 'AuditMerge_%'";
225+
if (db2.Database.GetDbConnection().State != ConnectionState.Open)
226+
await db2.Database.GetDbConnection().OpenAsync();
227+
var result = await command.ExecuteScalarAsync();
228+
finalCount = Convert.ToInt32(result);
229+
}
230+
231+
// Should be the same count since the same model is used
232+
Assert.Equal(initialCount, finalCount);
233+
}
234+
126235
private static TestDbContext BuildDbContext()
127236
{
128237
var db = new TestDbContext(new DbContextOptionsBuilder<TestDbContext>()
129238
.UseSqlServer(SqlServerConnString)
130-
.UseCoalesceAuditLogging<TestAuditLog>()
239+
.UseCoalesceAuditLogging<TestAuditLog>(x => x.WithStoredProcedures(false))
240+
.Options);
241+
242+
try
243+
{
244+
db.Database.EnsureDeleted();
245+
db.Database.EnsureCreated();
246+
}
247+
catch (SqlException ex) when (
248+
ex.Number == 53
249+
|| ex.Message.Contains("Could not open a connection to SQL Server")
250+
|| ex.Message.Contains("The server was not found or was not accessible")
251+
)
252+
{
253+
Skip.If(true, ex.Message);
254+
}
255+
catch (PlatformNotSupportedException ex) when (
256+
ex.Message.Contains("LocalDB is not supported on this platform")
257+
)
258+
{
259+
Skip.If(true, ex.Message);
260+
}
261+
return db;
262+
}
263+
264+
private static TestDbContext BuildDbContextWithStoredProcedures()
265+
{
266+
var db = new TestDbContext(new DbContextOptionsBuilder<TestDbContext>()
267+
.UseSqlServer(SqlServerConnString)
268+
.UseCoalesceAuditLogging<TestAuditLog>(x => x.WithStoredProcedures())
131269
.Options);
132270

133271
try

src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditLoggingBuilder.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ public AuditLoggingBuilder<TAuditLog> WithPropertyDescriptions(PropertyDescripti
5555
options.PropertyDescriptions = mode;
5656
return this;
5757
}
58+
59+
/// <inheritdoc cref="AuditOptions.UseStoredProcedures"/>
60+
public AuditLoggingBuilder<TAuditLog> WithStoredProcedures(bool useStoredProcedures = true)
61+
{
62+
options.UseStoredProcedures = useStoredProcedures;
63+
return this;
64+
}
5865
private static readonly MemoryCache _auditConfigTransforms = new(new MemoryCacheOptions { SizeLimit = 512 });
5966

6067
/// <summary>

src/IntelliTect.Coalesce.AuditLogging/Configuration/AuditOptions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ public class AuditOptions
5454
/// </summary>
5555
public PropertyDescriptionMode PropertyDescriptions { get; internal set; } = PropertyDescriptionMode.FkListText;
5656

57+
/// <summary>
58+
/// <para>
59+
/// When enabled, the audit log merge SQL will be wrapped in a stored procedure
60+
/// that is automatically created/updated as needed. The stored procedure name
61+
/// includes a hash of the SQL content to handle version conflicts.
62+
/// </para>
63+
/// <para>
64+
/// This can provide better performance through compiled execution plans and
65+
/// easier monitoring/troubleshooting. Currently only supported for SQL Server.
66+
/// </para>
67+
/// <para>
68+
/// The default is true.
69+
/// </para>
70+
/// </summary>
71+
public bool UseStoredProcedures { get; internal set; } = true;
72+
5773
/// <summary>
5874
/// Internal so that it cannot be modified in a way that breaks the caching assumptions
5975
/// that we make in CoalesceAuditLoggingBuilder.

0 commit comments

Comments
 (0)