Skip to content

Commit d8632d3

Browse files
authored
Merge pull request #19 from PandaTechAM/development
modernized and changed dependencies
2 parents 2c1687a + a27f565 commit d8632d3

5 files changed

Lines changed: 331 additions & 221 deletions

File tree

.github/workflows/main.yml

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,73 @@
1-
name: Deploy NuGet Package
1+
name: Publish NuGet Package
22

33
env:
4-
PROJECT_PATH: './src/EFCore.AuditBase/EFCore.AuditBase.csproj'
5-
OUTPUT_DIR: 'nupkgs'
6-
NUGET_SOURCE: 'https://api.nuget.org/v3/index.json'
7-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
4+
PROJECT_PATH: src/EFCore.AuditBase/EFCore.AuditBase.csproj
5+
OUTPUT_DIR: nupkgs
6+
NUGET_SOURCE: https://api.nuget.org/v3/index.json
87

98
on:
109
push:
11-
branches:
12-
- main
10+
branches: [main]
11+
12+
permissions:
13+
contents: read
14+
1315
jobs:
14-
deploy:
16+
publish:
1517
runs-on: ubuntu-latest
1618

1719
steps:
1820
- name: Checkout
19-
uses: actions/checkout@v6
21+
uses: actions/checkout@v4
2022

21-
- name: Setup .NET Core
22-
uses: actions/setup-dotnet@v5
23+
- name: Setup .NET
24+
uses: actions/setup-dotnet@v4
2325
with:
2426
global-json-file: global.json
2527

28+
- name: Restore
29+
run: dotnet restore ${{ env.PROJECT_PATH }}
30+
2631
- name: Build
27-
run: dotnet build ${{ env.PROJECT_PATH }}
32+
run: dotnet build ${{ env.PROJECT_PATH }} --no-restore --configuration Release
33+
34+
- name: Test
35+
run: dotnet test --no-build --configuration Release --verbosity normal
2836

2937
- name: Pack
30-
run: dotnet pack ${{ env.PROJECT_PATH }} --output ${{ env.OUTPUT_DIR }}
38+
run: dotnet pack ${{ env.PROJECT_PATH }} --no-build --configuration Release --output ${{ env.OUTPUT_DIR }}
3139

3240
- name: Publish
33-
run: dotnet nuget push ${{ env.OUTPUT_DIR }}/*.nupkg -k ${{ env.NUGET_API_KEY }} -s ${{ env.NUGET_SOURCE }}
41+
env:
42+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
43+
shell: bash
44+
run: |
45+
set -euo pipefail
46+
47+
if [ -z "${NUGET_API_KEY:-}" ]; then
48+
echo "NUGET_API_KEY secret is not set."
49+
exit 1
50+
fi
51+
52+
shopt -s nullglob
53+
54+
nupkgs=( "${{ env.OUTPUT_DIR }}"/*.nupkg )
55+
snupkgs=( "${{ env.OUTPUT_DIR }}"/*.snupkg )
56+
57+
if [ ${#nupkgs[@]} -eq 0 ]; then
58+
echo "No .nupkg files found in ${{ env.OUTPUT_DIR }}"
59+
ls -la "${{ env.OUTPUT_DIR }}" || true
60+
exit 1
61+
fi
62+
63+
dotnet nuget push "${nupkgs[@]}" \
64+
--api-key "$NUGET_API_KEY" \
65+
--source "${{ env.NUGET_SOURCE }}" \
66+
--skip-duplicate
67+
68+
if [ ${#snupkgs[@]} -gt 0 ]; then
69+
dotnet nuget push "${snupkgs[@]}" \
70+
--api-key "$NUGET_API_KEY" \
71+
--source "${{ env.NUGET_SOURCE }}" \
72+
--skip-duplicate
73+
fi

README.md

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# Pandatech.EFCore.AuditBase
2+
3+
Auditing base for EF Core entities. Inherit one class and get automatic `CreatedAt`/`UpdatedAt`/`UserId` tracking,
4+
soft delete, optimistic concurrency via row versioning, bulk update/delete helpers, and a `SaveChanges` interceptor
5+
that enforces correct audit method usage at runtime.
6+
7+
Targets **`net8.0`**, **`net9.0`**, and **`net10.0`**.
8+
9+
---
10+
11+
## Table of Contents
12+
13+
1. [Features](#features)
14+
2. [Installation](#installation)
15+
3. [Getting Started](#getting-started)
16+
4. [AuditEntityBase](#auditentitybase)
17+
5. [Registering the Interceptor](#registering-the-interceptor)
18+
6. [Soft Delete Query Filter](#soft-delete-query-filter)
19+
7. [Bulk Operations](#bulk-operations)
20+
8. [Concurrency Handling](#concurrency-handling)
21+
9. [SyncAuditBase](#syncauditbase)
22+
23+
---
24+
25+
## Features
26+
27+
- **Automatic audit fields**`CreatedAt`, `CreatedByUserId`, `UpdatedAt`, `UpdatedByUserId`, `Deleted`, `Version`
28+
maintained on every entity that inherits `AuditEntityBase`
29+
- **Enforced audit methods** — a `SaveChanges` interceptor throws at runtime if a modified entity's `Version` was not
30+
incremented, meaning someone bypassed `MarkAsUpdated`/`MarkAsDeleted`
31+
- **Soft delete**`Deleted` flag with `MarkAsDeleted` and a global query filter that hides deleted rows transparently
32+
- **Optimistic concurrency**`Version` is decorated with `[ConcurrencyCheck]`; EF Core raises a concurrency
33+
exception on conflict automatically
34+
- **Bulk helpers**`ExecuteSoftDeleteAsync` and `ExecuteUpdateAndMarkUpdatedAsync` translate directly to
35+
`ExecuteUpdateAsync` database calls while still maintaining correct audit fields
36+
- **In-memory batch delete**`MarkAsDeleted` overload on `IEnumerable<T>` for cases where entities are already
37+
tracked
38+
39+
---
40+
41+
## Installation
42+
43+
```bash
44+
dotnet add package Pandatech.EFCore.AuditBase
45+
```
46+
47+
---
48+
49+
## Getting Started
50+
51+
Inherit `AuditEntityBase` in your entity:
52+
53+
```csharp
54+
public class Product : AuditEntityBase
55+
{
56+
public long Id { get; set; }
57+
public string Name { get; set; } = string.Empty;
58+
public decimal Price { get; set; }
59+
}
60+
```
61+
62+
That's enough to gain all audit properties. Wire up the interceptor and query filter as shown below.
63+
64+
---
65+
66+
## AuditEntityBase
67+
68+
```
69+
CreatedAt DateTime Set to UtcNow on construction. Never modified after that.
70+
CreatedByUserId long? Required on construction (required init). Never modified after that.
71+
UpdatedAt DateTime? Set by MarkAsUpdated / MarkAsDeleted.
72+
UpdatedByUserId long? Set by MarkAsUpdated / MarkAsDeleted.
73+
Deleted bool Set to true by MarkAsDeleted.
74+
Version int Starts at 1. Incremented by every MarkAsUpdated / MarkAsDeleted call.
75+
```
76+
77+
### MarkAsUpdated
78+
79+
```csharp
80+
product.MarkAsUpdated(userId);
81+
// or with an explicit timestamp:
82+
product.MarkAsUpdated(userId, updatedAt: syncedTime);
83+
84+
await dbContext.SaveChangesAsync(ct);
85+
```
86+
87+
### MarkAsDeleted
88+
89+
```csharp
90+
product.MarkAsDeleted(userId);
91+
await dbContext.SaveChangesAsync(ct);
92+
```
93+
94+
Both methods increment `Version`. The interceptor validates this increment on every `SaveChanges` call — if you modify
95+
an audited entity's properties directly without calling `MarkAsUpdated`, the interceptor throws:
96+
97+
```
98+
InvalidOperationException: Entity 'Product' was modified without calling MarkAsUpdated or MarkAsDeleted.
99+
```
100+
101+
---
102+
103+
## Registering the Interceptor
104+
105+
```csharp
106+
builder.Services.AddDbContextPool<AppDbContext>(options =>
107+
options.UseNpgsql(connectionString)
108+
.UseAuditBaseValidatorInterceptor());
109+
```
110+
111+
`UseAuditBaseValidatorInterceptor` adds `AuditPropertyValidationInterceptor` to the context. It hooks into both
112+
`SavingChanges` and `SavingChangesAsync`.
113+
114+
---
115+
116+
## Soft Delete Query Filter
117+
118+
Apply a global query filter in `OnModelCreating` to exclude soft-deleted rows from all queries automatically:
119+
120+
```csharp
121+
protected override void OnModelCreating(ModelBuilder modelBuilder)
122+
{
123+
base.OnModelCreating(modelBuilder);
124+
modelBuilder.FilterOutDeletedMarkedObjects();
125+
}
126+
```
127+
128+
`FilterOutDeletedMarkedObjects` iterates every entity type that inherits `AuditEntityBase` and applies
129+
`.HasQueryFilter(e => !e.Deleted)` to each one via expression trees.
130+
131+
To include deleted rows in a specific query:
132+
133+
```csharp
134+
var all = await dbContext.Products.IgnoreQueryFilters().ToListAsync(ct);
135+
```
136+
137+
---
138+
139+
## Bulk Operations
140+
141+
### ExecuteSoftDeleteAsync
142+
143+
Soft-deletes all rows matching a query in a single `UPDATE` statement. Does not load entities into memory.
144+
145+
```csharp
146+
await dbContext.Products
147+
.Where(p => p.Price > 100)
148+
.ExecuteSoftDeleteAsync(userId, ct: ct);
149+
```
150+
151+
Translates to:
152+
153+
```sql
154+
UPDATE products
155+
SET deleted = true, updated_at = NOW(), updated_by_user_id = @userId, version = version + 1
156+
WHERE price > 100
157+
```
158+
159+
### ExecuteUpdateAndMarkUpdatedAsync
160+
161+
Updates arbitrary properties while automatically maintaining `UpdatedAt`, `UpdatedByUserId`, and `Version`:
162+
163+
```csharp
164+
await dbContext.Products
165+
.Where(p => p.Price > 100)
166+
.ExecuteUpdateAndMarkUpdatedAsync(
167+
userId,
168+
x => x.SetProperty(p => p.Price, p => p.Price * 0.9m),
169+
ct);
170+
```
171+
172+
### MarkAsDeleted (in-memory batch)
173+
174+
For already-tracked entities where you want to call `SaveChanges` once after marking several:
175+
176+
```csharp
177+
var products = await dbContext.Products.Where(p => p.Price > 100).ToListAsync(ct);
178+
products.MarkAsDeleted(userId);
179+
await dbContext.SaveChangesAsync(ct);
180+
```
181+
182+
> **Optimistic locking note:** `ExecuteSoftDeleteAsync` and `ExecuteUpdateAndMarkUpdatedAsync` bypass EF Core's
183+
> change tracker and do not raise concurrency exceptions. They increment `Version` unconditionally. Use them when
184+
> bulk throughput matters more than per-row conflict detection.
185+
186+
---
187+
188+
## Concurrency Handling
189+
190+
`Version` is decorated with `[ConcurrencyCheck]`. When two requests load the same entity and both call `SaveChanges`,
191+
the second one gets a `DbUpdateConcurrencyException` because the `Version` in the database no longer matches what was
192+
read.
193+
194+
```csharp
195+
try
196+
{
197+
product.MarkAsUpdated(userId);
198+
await dbContext.SaveChangesAsync(ct);
199+
}
200+
catch (DbUpdateConcurrencyException)
201+
{
202+
// Reload and retry, or return a 409 to the caller.
203+
}
204+
```
205+
206+
---
207+
208+
## SyncAuditBase
209+
210+
`SyncAuditBase` copies audit fields from one entity instance to another while bypassing the interceptor. It is
211+
intended for internal synchronization scenarios (e.g., merging detached entity state) and should not be used in
212+
normal update flows.
213+
214+
```csharp
215+
target.SyncAuditBase(source);
216+
await dbContext.SaveChangesAsync(ct);
217+
```
218+
219+
Setting `IgnoreInterceptor = true` via `SyncAuditBase` suppresses validation for all modified entities in that
220+
`SaveChanges` call, so use it only when you are certain the audit state being applied is already correct.
221+
222+
---
223+
224+
## License
225+
226+
MIT

0 commit comments

Comments
 (0)