Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

282-auditableentity-to-audit-table #288

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.4",
"commands": [
"dotnet-ef"
]
}
}
}
4 changes: 4 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Remove UserSecret tags from project files
run: |
find . -name '*.csproj' -type f -exec sed -i 's/<UserSecretsId>.*<\/UserSecretsId>//g' {} \;

- uses: nuget/setup-nuget@v2
with:
nuget-version: '6.x'
Expand Down
26 changes: 0 additions & 26 deletions src/Domain/Common/Base/AuditableEntity.cs

This file was deleted.

2 changes: 1 addition & 1 deletion src/Domain/Common/Base/Entity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/// They can be created within the domain, but not externally.
/// Enforce business rules (i.e. invariants)
/// </summary>
public abstract class Entity<TId> : AuditableEntity
public abstract class Entity<TId>
{
public TId Id { get; set; } = default!;
}
11 changes: 3 additions & 8 deletions src/Domain/Common/Interfaces/IAuditableEntity.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
namespace SSW.CleanArchitecture.Domain.Common.Interfaces;

/// <summary>
/// Marker interface for entities that are auditable.
/// </summary>
public interface IAuditableEntity
{
public DateTimeOffset CreatedAt { get; }
public string? CreatedBy { get; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76)
public DateTimeOffset? UpdatedAt { get; }
public string? UpdatedBy { get; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76)

public void SetCreated(DateTimeOffset createdAt, string? createdBy);

public void SetUpdated(DateTimeOffset updatedAt, string? updatedBy);
}
3 changes: 2 additions & 1 deletion src/Domain/Heroes/Hero.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using Ardalis.GuardClauses;
using SSW.CleanArchitecture.Domain.Common;
using SSW.CleanArchitecture.Domain.Common.Base;
using SSW.CleanArchitecture.Domain.Common.Interfaces;

namespace SSW.CleanArchitecture.Domain.Heroes;

// For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/
public readonly record struct HeroId(Guid Value);

public class Hero : AggregateRoot<HeroId>
public class Hero : AggregateRoot<HeroId>, IAuditableEntity
{
private readonly List<Power> _powers = [];
public string Name { get; private set; } = null!;
Expand Down
3 changes: 2 additions & 1 deletion src/Domain/Teams/Mission.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using Ardalis.GuardClauses;
using SSW.CleanArchitecture.Domain.Common;
using SSW.CleanArchitecture.Domain.Common.Base;
using SSW.CleanArchitecture.Domain.Common.Interfaces;

namespace SSW.CleanArchitecture.Domain.Teams;

// For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/
public readonly record struct MissionId(Guid Value);

public class Mission : Entity<MissionId>
public class Mission : Entity<MissionId>, IAuditableEntity
{
public string Description { get; private set; } = null!;

Expand Down
3 changes: 2 additions & 1 deletion src/Domain/Teams/Team.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using Ardalis.GuardClauses;
using SSW.CleanArchitecture.Domain.Common.Base;
using SSW.CleanArchitecture.Domain.Common.Interfaces;
using SSW.CleanArchitecture.Domain.Heroes;

namespace SSW.CleanArchitecture.Domain.Teams;

// For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/
public readonly record struct TeamId(Guid Value);

public class Team : AggregateRoot<TeamId>
public class Team : AggregateRoot<TeamId>, IAuditableEntity
{
public string Name { get; private set; } = null!;
public int TotalPowerLevel { get; private set; }
Expand Down
3 changes: 2 additions & 1 deletion src/Domain/TodoItems/TodoItem.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using SSW.CleanArchitecture.Domain.Common.Base;
using SSW.CleanArchitecture.Domain.Common.Interfaces;

namespace SSW.CleanArchitecture.Domain.TodoItems;

// For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/
public readonly record struct TodoItemId(Guid Value);

public class TodoItem : BaseEntity<TodoItemId>
public class TodoItem : BaseEntity<TodoItemId>, IAuditableEntity
{
// NOTE: private setters for behavior we want to encapsulate, and public setters for properties that don't have behavior

Expand Down
19 changes: 19 additions & 0 deletions src/Infrastructure/Audit/AuditDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;

namespace SSW.CleanArchitecture.Infrastructure.Audit;

public class AuditDbContext(DbContextOptions<AuditDbContext> options)
: DbContext(options)
{
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var auditEntryBuilder = modelBuilder.Entity<AuditEntry>().ToTable(nameof(AuditEntry), "audit");
auditEntryBuilder.HasKey(e => e.Id);
auditEntryBuilder.Property(e => e.TableName).IsRequired();
auditEntryBuilder.Property(e => e.AuditType).IsRequired();
auditEntryBuilder.Property(e => e.AuditedAt).IsRequired();
auditEntryBuilder.Property(e => e.UserId).IsRequired(false);
}
}
25 changes: 25 additions & 0 deletions src/Infrastructure/Audit/AuditDbContextInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace SSW.CleanArchitecture.Infrastructure.Audit;

public class AuditDbContextInitializer(
ILogger<AuditDbContextInitializer> logger,
AuditDbContext dbContext)
{
public async Task InitializeAsync()
{
try
{
if (dbContext.Database.IsSqlServer())
{
await dbContext.Database.MigrateAsync();
}
}
catch (Exception e)
{
logger.LogError(e, "An error occurred while migrating or initializing the database");
throw;
}
}
}
98 changes: 98 additions & 0 deletions src/Infrastructure/Audit/AuditEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace SSW.CleanArchitecture.Infrastructure.Audit;

public class AuditEntry
{
public Guid Id { get; set; }
public string TableName { get; set; } = null!;
public AuditType AuditType { get; set; }
public DateTimeOffset AuditedAt { get; set; }
public string? UserId { get; set; }

public string KeyValues { get; set; } = string.Empty;

public string OldValues { get; set; } = string.Empty;

public string NewValues { get; set; } = string.Empty;

public string ChangedColumns { get; set; } = string.Empty;

public AuditEntry()
{
}

public static AuditEntry? From(EntityEntry entry, string? userId, DateTimeOffset now)
{
if (entry.State is EntityState.Detached or EntityState.Unchanged ||
string.IsNullOrWhiteSpace(entry.Metadata.GetTableName()))
{
return null;
}

var auditEntry = new AuditEntry
{
TableName = entry.Metadata.GetTableName()!, UserId = userId, AuditedAt = now,
};

var keyValues = new Dictionary<string, object?>();
var oldValues = new Dictionary<string, object?>();
var newValues = new Dictionary<string, object?>();
var changedColumns = new List<string>();

foreach (var property in entry.Properties)
{
var propertyName = property.Metadata.Name;
var dbColumnName = property.Metadata.GetColumnName();

if (property.Metadata.IsPrimaryKey())
{
keyValues[propertyName] = property.CurrentValue;
continue;
}

switch (entry.State)
{
case EntityState.Added:
newValues[propertyName] = property.CurrentValue;
auditEntry.AuditType = AuditType.Create;
break;

case EntityState.Deleted:
oldValues[propertyName] = property.OriginalValue;
auditEntry.AuditType = AuditType.Delete;
break;

case EntityState.Modified:
if (property.IsModified)
{
changedColumns.Add(dbColumnName);

oldValues[propertyName] = property.OriginalValue;
newValues[propertyName] = property.CurrentValue;
auditEntry.AuditType = AuditType.Update;
}

break;
}
}

auditEntry.KeyValues = JsonSerializer.Serialize(keyValues);
auditEntry.OldValues = JsonSerializer.Serialize(oldValues);
auditEntry.NewValues = JsonSerializer.Serialize(newValues);
auditEntry.ChangedColumns = JsonSerializer.Serialize(changedColumns);

return auditEntry;
}
}

public enum AuditType
{
None = 0,
Create = 1,
Update = 2,
Delete = 3
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions src/Infrastructure/Audit/Migrations/20240415073654_Initial.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace SSW.CleanArchitecture.Infrastructure.Audit.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "audit");

migrationBuilder.CreateTable(
name: "AuditEntry",
schema: "audit",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TableName = table.Column<string>(type: "nvarchar(max)", nullable: false),
AuditType = table.Column<int>(type: "int", nullable: false),
AuditedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UserId = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditEntry", x => x.Id);
});
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditEntry",
schema: "audit");
}
}
}
Loading
Loading