|
1 | 1 | <div align="center"> |
2 | 2 |
|
3 | | -# EfCoreKit |
| 3 | +# EfCore.Extensions |
4 | 4 |
|
5 | 5 | **EF Core extensions that eliminate boilerplate — so you can focus on building features.** |
6 | 6 |
|
7 | | -[](https://www.nuget.org/packages/EfCoreKit) |
8 | | -[](https://www.nuget.org/packages/EfCoreKit) |
| 7 | +[](https://www.nuget.org/packages/EfCore.Extensions) |
| 8 | +[](https://www.nuget.org/packages/EfCore.Extensions) |
9 | 9 | [](https://github.com/Clifftech123/EfCoreKit/actions) |
10 | 10 | [](LICENSE) |
11 | 11 |
|
|
15 | 15 |
|
16 | 16 | --- |
17 | 17 |
|
18 | | -## Why EfCoreKit? |
| 18 | +## Why EfCore.Extensions? |
19 | 19 |
|
20 | | -Every .NET project with EF Core ends up writing the same plumbing: soft delete filters, audit timestamps, tenant isolation, pagination helpers, bulk imports. EfCoreKit packages all of that into a single `AddEfCoreKit()` call. |
| 20 | +Every .NET project with EF Core ends up writing the same plumbing: soft delete filters, audit timestamps, tenant isolation, pagination helpers, generic repositories, transaction wrappers. EfCore.Extensions packages all of that into a single `AddEfCoreExtensions()` call. |
21 | 21 |
|
22 | 22 | **Design goals:** |
23 | 23 |
|
24 | | -- **Zero lock-in** — EfCoreKit uses standard EF Core interceptors and global query filters. Your entities stay plain C# classes, your `DbContext` stays a normal `DbContext`, and you can remove EfCoreKit at any time without rewriting your data layer. |
| 24 | +- **Zero lock-in** — Uses standard EF Core interceptors and global query filters. Your entities stay plain C# classes, your `DbContext` stays a normal `DbContext`, and you can remove EfCore.Extensions at any time without rewriting your data layer. |
25 | 25 | - **Opt-in everything** — Enable only the features you need. Nothing runs unless you turn it on. |
26 | | -- **No custom ORM** — This is not a repository layer or a replacement for EF Core. It's a set of extensions that plug into the pipeline you already use. |
| 26 | +- **No custom ORM** — This is not a replacement for EF Core. It's a set of extensions that plug into the pipeline you already use. |
27 | 27 |
|
28 | 28 | --- |
29 | 29 |
|
30 | 30 | ## Features |
31 | 31 |
|
32 | | -All core features use EF Core interceptors and global query filters — they work with **any database** EF Core supports. |
| 32 | +| Feature | Description | |
| 33 | +|---------|-------------| |
| 34 | +| **Base Entity Hierarchy** | Ready-made base classes: `BaseEntity`, `AuditableEntity`, `SoftDeletableEntity`, `FullEntity` | |
| 35 | +| **Entity Configuration Bases** | Fluent config base classes that auto-wire keys, indexes, and soft-delete defaults | |
| 36 | +| **Soft Delete** | Mark records as deleted with automatic global query filters; restore or hard-delete on demand | |
| 37 | +| **Audit Trail** | Auto-stamp `CreatedAt/By`, `UpdatedAt/By`; optional field-level `AuditLog` history | |
| 38 | +| **Multi-Tenancy** | Automatic tenant filtering so each tenant only sees their own data | |
| 39 | +| **Repository + Unit of Work** | Generic `IRepository<T>` / `IReadRepository<T>` backed by `IUnitOfWork` | |
| 40 | +| **Specification Pattern** | Composable query specs with `And()` / `Or()` combinators, projection, and multi-column ordering | |
| 41 | +| **Pagination** | Offset (`ToPagedAsync`) and keyset/cursor (`ToKeysetPagedAsync`) pagination with `PagedResult<T>` | |
| 42 | +| **Dynamic Filters** | Apply runtime filter arrays (eq, ne, gt, lt, contains, in, between, isnull…) via `ApplyFilters` | |
| 43 | +| **Query Helpers** | `ExistsAsync`, `GetByIdOrThrowAsync`, `WhereIf`, `OrderByDynamic`, and more | |
| 44 | +| **DbContext Utilities** | `ExecuteInTransactionAsync`, `DetachAll`, `TruncateAsync<T>` | |
| 45 | +| **Slow Query Logging** | Logs warnings for queries exceeding a configurable threshold | |
| 46 | +| **Structured Exceptions** | `ConcurrencyConflictException`, `DuplicateEntityException`, `TenantMismatchException` | |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Installation |
33 | 51 |
|
34 | | -| Feature | Status | Description | |
35 | | -|---------|--------|-------------| |
36 | | -| **Soft Delete** | ✅ | Mark records as deleted with automatic global query filters | |
37 | | -| **Audit Trail** | ✅ | Auto-stamp `CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy` on every save | |
38 | | -| **Multi-Tenancy** | ✅ | Automatic tenant filtering so each tenant only sees their own data | |
39 | | -| **Pagination** | ✅ | Offset-based and keyset/cursor-based pagination with `PagedResult<T>` | |
40 | | -| **Query Helpers** | ✅ | `ExistsAsync`, `GetByIdOrThrowAsync`, `WhereIf`, `OrderByDynamic`, and more | |
41 | | -| **Specification Pattern** | ✅ | Reusable, composable query logic in strongly-typed classes | |
42 | | -| **Slow Query Logging** | ✅ | Logs warnings for queries exceeding a configurable threshold | |
43 | | -| **Bulk Operations** | 🚧 | Insert, update, delete, or upsert thousands of rows in one call *(coming soon)* | |
| 52 | +```bash |
| 53 | +dotnet add package EfCore.Extensions |
| 54 | +``` |
| 55 | + |
| 56 | +Or install only what you need: |
| 57 | + |
| 58 | +| Package | Description | |
| 59 | +|---------|-------------| |
| 60 | +| `EfCore.Extensions` | Meta-package — installs everything | |
| 61 | +| `EfCore.Extensions.Core` | Core implementation (interceptors, repositories, extensions) | |
| 62 | +| `EfCore.Extensions.Abstractions` | Interfaces, base entities, and models only | |
44 | 63 |
|
45 | 64 | --- |
46 | 65 |
|
47 | | -## Quick Look |
| 66 | +## Quick Start |
| 67 | + |
| 68 | +### 1. Register services |
48 | 69 |
|
49 | 70 | ```csharp |
50 | | -// One-line setup — pick only what you need |
51 | | -builder.Services.AddEfCoreKit<AppDbContext>( |
| 71 | +builder.Services.AddEfCoreExtensions<AppDbContext>( |
52 | 72 | options => options.UseSqlServer(connectionString), |
53 | 73 | kit => kit |
54 | 74 | .EnableSoftDelete() |
55 | | - .EnableAuditTrail() |
| 75 | + .EnableAuditTrail() // basic timestamps |
| 76 | + .EnableAuditTrail(fullLog: true) // + field-level AuditLog records |
56 | 77 | .EnableMultiTenancy() |
57 | 78 | .UseUserProvider<HttpContextUserProvider>() |
58 | | -); |
| 79 | + .UseTenantProvider<HttpContextTenantProvider>() |
| 80 | + .LogSlowQueries(TimeSpan.FromSeconds(1))); |
59 | 81 | ``` |
60 | 82 |
|
| 83 | +### 2. Inherit a base entity |
| 84 | + |
61 | 85 | ```csharp |
62 | | -// Query helpers |
63 | | -var exists = await context.Customers.ExistsAsync(x => x.Email == email); |
64 | | -var customer = await context.Customers.GetByIdOrThrowAsync(id); |
65 | | - |
66 | | -// Pagination |
67 | | -var page = await context.Customers |
68 | | - .Where(c => c.IsActive) |
69 | | - .ToPagedAsync(page: 1, pageSize: 20); |
70 | | - |
71 | | -// Conditional filtering + dynamic sort |
72 | | -var results = await context.Orders |
73 | | - .WhereIf(hasStatus, x => x.Status == status) |
74 | | - .OrderByDynamic("CreatedAt", ascending: false) |
75 | | - .ToListAsync(); |
| 86 | +// Plain entity with int PK |
| 87 | +public class Product : BaseEntity { } |
| 88 | + |
| 89 | +// Audited entity with Guid PK |
| 90 | +public class Order : AuditableEntity<Guid> { } |
| 91 | + |
| 92 | +// Soft-deletable + audited |
| 93 | +public class Customer : SoftDeletableEntity { } |
| 94 | + |
| 95 | +// Full — soft-delete + audit + tenant + row version |
| 96 | +public class Invoice : FullEntity { } |
| 97 | +``` |
| 98 | + |
| 99 | +### 3. Configure with base classes |
| 100 | + |
| 101 | +```csharp |
| 102 | +public class CustomerConfiguration : SoftDeletableEntityConfiguration<Customer> |
| 103 | +{ |
| 104 | + protected override void ConfigureEntity(EntityTypeBuilder<Customer> builder) |
| 105 | + { |
| 106 | + builder.Property(c => c.Name).HasMaxLength(200).IsRequired(); |
| 107 | + } |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +### 4. Use the repository |
| 112 | + |
| 113 | +```csharp |
| 114 | +public class OrderService(IRepository<Order> repo, IUnitOfWork uow) |
| 115 | +{ |
| 116 | + public async Task<Order> CreateAsync(Order order) |
| 117 | + { |
| 118 | + await repo.AddAsync(order); |
| 119 | + await uow.CommitAsync(); |
| 120 | + return order; |
| 121 | + } |
| 122 | + |
| 123 | + public async Task<IReadOnlyList<Order>> GetRecentAsync() |
| 124 | + => await repo.FindAsync(o => o.CreatedAt > DateTime.UtcNow.AddDays(-7)); |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +### 5. Use specifications |
| 129 | + |
| 130 | +```csharp |
| 131 | +public class ActiveOrdersSpec : Specification<Order> |
| 132 | +{ |
| 133 | + public ActiveOrdersSpec(int customerId) |
| 134 | + { |
| 135 | + AddCriteria(o => o.CustomerId == customerId && !o.IsDeleted); |
| 136 | + AddInclude(o => o.Items); |
| 137 | + ApplyOrderByDescending(o => o.CreatedAt); |
| 138 | + ApplyPaging(skip: 0, take: 20); |
| 139 | + ApplyAsNoTracking(); |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +// Compose specs with And/Or |
| 144 | +var spec = new ActiveOrdersSpec(customerId).And(new HighValueOrdersSpec(500)); |
| 145 | +var orders = await dbSet.FindAsync(spec); |
76 | 146 | ``` |
77 | 147 |
|
78 | | -### What happens behind the scenes |
| 148 | +--- |
79 | 149 |
|
80 | | -Once configured, EfCoreKit hooks into EF Core's pipeline automatically: |
| 150 | +## What Happens Behind the Scenes |
81 | 151 |
|
82 | | -| You do this | EfCoreKit does this | |
83 | | -|-------------|---------------------| |
| 152 | +| You do this | EfCore.Extensions does this | |
| 153 | +|-------------|------------------------------| |
84 | 154 | | Call `SaveChangesAsync()` | Stamps `CreatedAt`/`UpdatedAt`, sets `CreatedBy`/`UpdatedBy` from your user provider | |
85 | | -| Delete an entity | Converts to a soft delete — sets `IsDeleted`, `DeletedAt`, `DeletedBy` instead of removing the row | |
| 155 | +| Delete an entity implementing `ISoftDeletable` | Converts to a soft delete — sets `IsDeleted`, `DeletedAt`, `DeletedBy` instead of removing the row | |
86 | 156 | | Query any `DbSet` | Automatically filters out soft-deleted rows and scopes to the current tenant | |
87 | 157 | | Add a new tenant entity | Auto-assigns `TenantId` from your tenant provider | |
88 | 158 | | Modify a tenant entity you don't own | Throws `TenantMismatchException` before hitting the database | |
89 | | -| Run a slow query | Logs a warning with the SQL and duration so you can catch performance issues early | |
| 159 | +| Save with a stale row version | Throws `ConcurrencyConflictException` wrapping `DbUpdateConcurrencyException` | |
| 160 | +| Run a slow query | Logs a warning with the SQL and duration | |
| 161 | +| Save `IFullAuditable` entities with `fullLog: true` | Writes an `AuditLog` row for every changed property | |
90 | 162 |
|
91 | 163 | --- |
92 | 164 |
|
93 | | -## Installation |
| 165 | +## Soft Delete Lifecycle |
94 | 166 |
|
95 | | -```bash |
96 | | -dotnet add package EfCoreKit |
| 167 | +```csharp |
| 168 | +// Default queries automatically exclude soft-deleted rows |
| 169 | +var customers = await context.Customers.ToListAsync(); |
| 170 | + |
| 171 | +// Include soft-deleted rows alongside active ones |
| 172 | +var all = await context.Customers.IncludeDeleted().ToListAsync(); |
| 173 | + |
| 174 | +// Only soft-deleted rows |
| 175 | +var trash = await context.Customers.OnlyDeleted().ToListAsync(); |
| 176 | + |
| 177 | +// Restore a soft-deleted record |
| 178 | +context.Customers.Restore(customer); |
| 179 | +await context.SaveChangesAsync(); |
| 180 | + |
| 181 | +// Permanently remove a record (regardless of soft-delete settings) |
| 182 | +context.Customers.HardDelete(customer); |
| 183 | +await context.SaveChangesAsync(); |
97 | 184 | ``` |
98 | 185 |
|
99 | | -Or install only what you need: |
| 186 | +--- |
100 | 187 |
|
101 | | -| Package | Description | |
102 | | -|---------|-------------| |
103 | | -| `EfCoreKit.Core` | Core implementation (interceptors, filters, extensions) | |
104 | | -| `EfCoreKit.Abstractions` | Interfaces and models only | |
| 188 | +## Pagination |
| 189 | + |
| 190 | +```csharp |
| 191 | +// Offset pagination |
| 192 | +var page = await context.Orders |
| 193 | + .Where(o => o.CustomerId == id) |
| 194 | + .OrderBy(o => o.CreatedAt) |
| 195 | + .ToPagedAsync(page: 2, pageSize: 25); |
| 196 | + |
| 197 | +Console.WriteLine($"Page {page.CurrentPage} of {page.TotalPages} ({page.TotalCount} total)"); |
| 198 | + |
| 199 | +// Keyset / cursor pagination (no OFFSET — scales to millions of rows) |
| 200 | +var first = await context.Orders |
| 201 | + .OrderBy(o => o.CreatedAt).ThenBy(o => o.Id) |
| 202 | + .ToKeysetPagedAsync(pageSize: 25, afterId: null); |
| 203 | + |
| 204 | +var next = await context.Orders |
| 205 | + .OrderBy(o => o.CreatedAt).ThenBy(o => o.Id) |
| 206 | + .ToKeysetPagedAsync(pageSize: 25, afterId: first.NextCursor); |
| 207 | +``` |
105 | 208 |
|
106 | 209 | --- |
107 | 210 |
|
108 | | -## Documentation |
| 211 | +## Dynamic Filters |
| 212 | + |
| 213 | +```csharp |
| 214 | +var filters = new[] |
| 215 | +{ |
| 216 | + new FilterDescriptor("Status", "eq", "Active"), |
| 217 | + new FilterDescriptor("CreatedAt", "gte", DateTime.UtcNow.AddDays(-30)), |
| 218 | + new FilterDescriptor("Tags", "in", new[] { "VIP", "Premium" }), |
| 219 | + new FilterDescriptor("Score", "between", new object[] { 10, 100 }), |
| 220 | +}; |
| 221 | + |
| 222 | +var results = await context.Customers |
| 223 | + .ApplyFilters(filters) |
| 224 | + .ToListAsync(); |
| 225 | +``` |
| 226 | + |
| 227 | +Supported operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `isnull`, `isnotnull`, `in`, `between`. |
| 228 | + |
| 229 | +--- |
109 | 230 |
|
110 | | -Each feature has a dedicated guide with full examples and configuration options: |
| 231 | +## DbContext Utilities |
| 232 | + |
| 233 | +```csharp |
| 234 | +// Run work inside a transaction (respects EF Core execution strategy / retry) |
| 235 | +var result = await context.ExecuteInTransactionAsync(async () => |
| 236 | +{ |
| 237 | + await DoWorkA(context); |
| 238 | + await DoWorkB(context); |
| 239 | + return await context.SaveChangesAsync(); |
| 240 | +}); |
| 241 | + |
| 242 | +// Detach all tracked entities (useful after bulk imports) |
| 243 | +context.DetachAll(); |
| 244 | + |
| 245 | +// Truncate a table by entity type (uses EF Core metadata for table name) |
| 246 | +await context.TruncateAsync<AuditLog>(); |
| 247 | +``` |
| 248 | + |
| 249 | +--- |
| 250 | + |
| 251 | +## Documentation |
111 | 252 |
|
112 | 253 | | Guide | What You'll Learn | |
113 | 254 | |-------|-------------------| |
114 | 255 | | [Getting Started](docs/getting-started.md) | Installation, DbContext setup, DI registration | |
115 | | -| [Soft Delete](docs/soft-delete.md) | ISoftDeletable, cascade delete, restoring records | |
116 | | -| [Audit Trail](docs/audit-trail.md) | IAuditable, auto-stamping, CreatedAt/CreatedBy protection | |
| 256 | +| [Base Entities](docs/base-entities.md) | Entity hierarchy, configuration base classes | |
| 257 | +| [Soft Delete](docs/soft-delete.md) | ISoftDeletable, lifecycle methods, restoring records | |
| 258 | +| [Audit Trail](docs/audit-trail.md) | IAuditable, IFullAuditable, field-level AuditLog | |
117 | 259 | | [Multi-Tenancy](docs/multi-tenancy.md) | ITenantEntity, automatic filtering, tenant validation | |
118 | | -| [Pagination](docs/pagination.md) | Offset and keyset pagination, PagedResult, KeysetPagedResult | |
119 | | -| [Query Helpers](docs/query-helpers.md) | WhereIf, OrderByDynamic, specifications, DbSet extensions | |
120 | | -| [Bulk Operations](docs/bulk-operations.md) | BulkInsert/Update/Delete/Upsert, BulkConfig tuning | |
| 260 | +| [Repository & Unit of Work](docs/repository-uow.md) | IRepository, IReadRepository, IUnitOfWork | |
| 261 | +| [Specification Pattern](docs/specifications.md) | Spec classes, And/Or combinators, projection specs | |
| 262 | +| [Pagination](docs/pagination.md) | Offset and keyset pagination, PagedResult | |
| 263 | +| [Dynamic Filters](docs/dynamic-filters.md) | FilterDescriptor, all supported operators | |
| 264 | +| [Query Helpers](docs/query-helpers.md) | WhereIf, OrderByDynamic, DbSet extensions | |
| 265 | +| [DbContext Utilities](docs/dbcontext-utilities.md) | Transactions, DetachAll, TruncateAsync | |
121 | 266 |
|
122 | 267 | --- |
123 | 268 |
|
|
0 commit comments