Skip to content
Merged
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
164 changes: 164 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1 +1,165 @@
# Contributing to EfCoreKit

Thank you for taking the time to contribute! This guide covers everything you need to go from idea to merged pull request.

---

## Table of Contents

- [Ways to Contribute](#ways-to-contribute)
- [Before You Start](#before-you-start)
- [Development Setup](#development-setup)
- [Branch Strategy](#branch-strategy)
- [Making Changes](#making-changes)
- [Running the Tests](#running-the-tests)
- [Code Style](#code-style)
- [Commit Messages](#commit-messages)
- [Pull Request Process](#pull-request-process)
- [Versioning and Releases](#versioning-and-releases)

---

## Ways to Contribute

- **Report a bug** — [Open an issue](https://github.com/Clifftech123/EfCoreKit/issues) with steps to reproduce, expected behaviour, and actual behaviour.
- **Request a feature** — [Open an issue](https://github.com/Clifftech123/EfCoreKit/issues) describing the use case and what you'd like the API to look like.
- **Fix a bug or implement a feature** — Fork the repo, make changes on a branch, and submit a pull request.
- **Improve documentation** — Typos, missing examples, unclear wording — all fixes are welcome.

---

## Before You Start

For anything beyond a small bug fix or documentation change, **open an issue first**. This lets us agree on the approach before you invest time writing code, and avoids situations where a well-written PR cannot be merged because the design doesn't fit the project's direction.

---

## Development Setup

### Prerequisites

| Tool | Version |
|------|---------|
| .NET SDK | 10.0 or later |
| Git | Any recent version |

### Getting the code

```bash
# Fork the repo on GitHub, then clone your fork
git clone https://github.com/<your-username>/EfCoreKit.git
cd EfCoreKit

# Add the upstream remote so you can pull future changes
git remote add upstream https://github.com/Clifftech123/EfCoreKit.git
```

### Build

```bash
dotnet restore
dotnet build
```

---

## Branch Strategy

| Branch | Purpose |
|--------|---------|
| `master` | Latest stable release — never commit here directly |
| `develop` | Integration branch — all PRs target this branch |
| `feature/<name>` | New features |
| `fix/<name>` | Bug fixes |
| `docs/<name>` | Documentation-only changes |

**Always branch off `develop`, and open your PR against `develop`.**

```bash
git fetch upstream
git checkout -b fix/soft-delete-cascade upstream/develop
```

---

## Making Changes

1. Create your branch off `develop` (see above).
2. Make focused, minimal changes — one concern per PR.
3. Keep the public API backwards-compatible unless you've discussed a breaking change in an issue first.
4. Do not add features, refactor surrounding code, or clean up unrelated areas as part of a bug fix PR.
5. Update the relevant `docs/` page if your change affects documented behaviour.

---

## Running the Tests

The project uses integration tests (no mocks — tests run against a real in-memory or SQLite database):

```bash
dotnet test tests/EfCoreKit.Tests.Integration/EfCoreKit.Tests.Integration.csproj --configuration Release
```

All tests must pass before a PR can be merged. If you're adding a feature or fixing a bug, add a test that covers the new behaviour.

---

## Code Style

- Follow the conventions already in the codebase — consistency matters more than any individual preference.
- Use `var` where the type is obvious from the right-hand side.
- Prefer expression-bodied members for single-line methods/properties.
- Use `async`/`await` throughout — no `.Result` or `.Wait()`.
- No unused `using` directives.
- No commented-out code.
- XML doc comments (`///`) are not required unless you are adding a new public API surface.

The project does not currently enforce a formatter tool, so use your judgement to match the surrounding code.

---

## Commit Messages

Use the conventional commits style:

```
<type>: <short summary in present tense>

[Optional longer description explaining *why*, not what]
```

| Type | Use when |
|------|----------|
| `feat` | Adding a new feature |
| `fix` | Fixing a bug |
| `docs` | Documentation changes only |
| `test` | Adding or updating tests |
| `refactor` | Code change that is neither a fix nor a feature |
| `chore` | Build system, CI, or tooling changes |

Examples:

```
feat: add WhereIfNotEmpty extension method
fix: restore clears DeletedBy when soft-delete interceptor is enabled
docs: add cascade soft delete example to soft-delete guide
```

---

## Pull Request Process

1. Ensure your branch is up to date with `upstream/develop` before opening the PR.
2. Fill in the PR description — what changed, why, and how to test it.
3. All CI checks (build + tests) must pass.
4. At least one maintainer review is required before merge.
5. Squash commits if the history is noisy — a clean history per PR is preferred.
6. Once approved, a maintainer will merge into `develop`.



## Code of Conduct

Be respectful. Constructive criticism of code and design is welcome; personal criticism is not. We want this to be a project where everyone feels comfortable contributing.

If you experience or witness unacceptable behaviour, please open a private issue or contact the maintainer directly.
37 changes: 10 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ Every .NET project with EF Core ends up writing the same plumbing: soft delete f

**Design goals:**

- **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 EfCoreKit at any time without rewriting your data layer.
- **Opt-in everything** Enable only the features you need. Nothing runs unless you turn it on.
- **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.
- **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 EfCoreKit at any time without rewriting your data layer.
- **Opt-in everything** Enable only the features you need. Nothing runs unless you turn it on.
- **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.

---

Expand All @@ -42,7 +42,7 @@ Every .NET project with EF Core ends up writing the same plumbing: soft delete f
| **Query Helpers** | `ExistsAsync`, `GetByIdOrThrowAsync`, `WhereIf`, `OrderByDynamic`, and more |
| **DbContext Utilities** | `ExecuteInTransactionAsync`, `DetachAll`, `TruncateAsync<T>` |
| **Slow Query Logging** | Logs warnings for queries exceeding a configurable threshold |
| **Structured Exceptions** | `ConcurrencyConflictException`, `DuplicateEntityException`, `TenantMismatchException` |
| **Structured Exceptions** | `EntityNotFoundException`, `ConcurrencyConflictException`, `DuplicateEntityException`, `InvalidFilterException` |

---

Expand All @@ -67,9 +67,7 @@ builder.Services.AddEfCoreExtensions<AppDbContext>(
.EnableSoftDelete()
.EnableAuditTrail() // basic: stamps CreatedAt/By, UpdatedAt/By
// .EnableAuditTrail(fullLog: true) // alternative: also writes field-level AuditLog rows
.EnableMultiTenancy()
.UseUserProvider<HttpContextUserProvider>()
.UseTenantProvider<HttpContextTenantProvider>()
.LogSlowQueries(TimeSpan.FromSeconds(1)));
```

Expand All @@ -85,7 +83,7 @@ public class Order : AuditableEntity<Guid> { }
// Soft-deletable + audited
public class Customer : SoftDeletableEntity { }

// Full — soft-delete + audit + tenant + row version
// Full — soft-delete + audit + row version
public class Invoice : FullEntity { }
```

Expand Down Expand Up @@ -140,21 +138,6 @@ var orders = await dbSet.FindAsync(spec);

---

## What Happens Behind the Scenes

| You do this | EfCoreKit does this |
|-------------|------------------------------|
| Call `SaveChangesAsync()` | Stamps `CreatedAt`/`UpdatedAt`, sets `CreatedBy`/`UpdatedBy` from your user provider |
| Delete an entity implementing `ISoftDeletable` | Converts to a soft delete — sets `IsDeleted`, `DeletedAt`, `DeletedBy` instead of removing the row |
| Query any `DbSet` | Automatically filters out soft-deleted rows and scopes to the current tenant |
| Add a new tenant entity | Auto-assigns `TenantId` from your tenant provider |
| Modify a tenant entity you don't own | Throws `TenantMismatchException` before hitting the database |
| Save with a stale row version | Throws `ConcurrencyConflictException` wrapping `DbUpdateConcurrencyException` |
| Run a slow query | Logs a warning with the SQL and duration |
| Save `IFullAuditable` entities with `fullLog: true` | Writes an `AuditLog` row for every changed property |

---

## Soft Delete Lifecycle

```csharp
Expand Down Expand Up @@ -187,16 +170,16 @@ var page = await context.Orders
.OrderBy(o => o.CreatedAt)
.ToPagedAsync(page: 2, pageSize: 25);

Console.WriteLine($"Page {page.CurrentPage} of {page.TotalPages} ({page.TotalCount} total)");
Console.WriteLine($"Page {page.Page} of {page.TotalPages} ({page.TotalCount} total)");

// Keyset / cursor pagination (no OFFSET — scales to millions of rows)
var first = await context.Orders
.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
.ToKeysetPagedAsync(pageSize: 25, afterId: null);
.OrderBy(o => o.Id)
.ToKeysetPagedAsync(o => o.Id, cursor: null, pageSize: 25);

var next = await context.Orders
.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
.ToKeysetPagedAsync(pageSize: 25, afterId: first.NextCursor);
.OrderBy(o => o.Id)
.ToKeysetPagedAsync(o => o.Id, cursor: int.Parse(first.NextCursor!), pageSize: 25);
```

---
Expand Down
9 changes: 4 additions & 5 deletions docs/base-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ EfCoreKit provides a hierarchy of ready-made base classes so you don't have to r
BaseEntity<TKey>
└── AuditableEntity<TKey> (+ CreatedAt/By, UpdatedAt/By)
└── SoftDeletableEntity<TKey> (+ IsDeleted, DeletedAt/By)
└── FullEntity<TKey> (+ TenantId, RowVersion)
└── FullEntity<TKey> (+ RowVersion)
```

Each level adds the interface properties for the corresponding feature. All levels have an `int`-key convenience alias (e.g. `BaseEntity` = `BaseEntity<int>`).
Expand Down Expand Up @@ -71,13 +71,12 @@ public class Customer : SoftDeletableEntity { }

### FullEntity&lt;TKey&gt; / FullEntity

Implements everything: `IAuditable`, `ISoftDeletable`, `ITenantEntity`, and `IConcurrencyAware`.
Implements `IAuditable`, `ISoftDeletable`, and `IConcurrencyAware`.

```csharp
public abstract class FullEntity<TKey> : SoftDeletableEntity<TKey>, ITenantEntity, IConcurrencyAware
public abstract class FullEntity<TKey> : SoftDeletableEntity<TKey>, IConcurrencyAware
{
public string? TenantId { get; set; }
public byte[] RowVersion { get; set; } = [];
public byte[] RowVersion { get; set; } = [];
}
```

Expand Down
28 changes: 0 additions & 28 deletions docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ Exception
├── EntityNotFoundException
├── ConcurrencyConflictException
├── DuplicateEntityException
├── TenantMismatchException
└── InvalidFilterException
```

Expand Down Expand Up @@ -135,33 +134,6 @@ Messages produced:

---

## TenantMismatchException

Thrown by `TenantInterceptor` when a save is attempted on an entity that belongs to a different tenant than the current request.

```csharp
public sealed class TenantMismatchException : EfCoreException
{
public string? ExpectedTenant { get; } // current tenant from ITenantProvider
public string? ActualTenant { get; } // tenant on the entity
}
```

```csharp
try
{
await context.SaveChangesAsync();
}
catch (TenantMismatchException ex)
{
// ex.ExpectedTenant == "tenant-abc"
// ex.ActualTenant == "tenant-xyz"
return Forbid();
}
```

---

## InvalidFilterException

Thrown by `ApplyFilters` when a `FilterDescriptor` is invalid.
Expand Down
26 changes: 3 additions & 23 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ builder.Services.AddEfCoreExtensions<AppDbContext>(
.EnableSoftDelete()
.EnableAuditTrail() // basic: stamps CreatedAt/By, UpdatedAt/By
// .EnableAuditTrail(fullLog: true) // alternative: also writes field-level AuditLog rows
.EnableMultiTenancy()
.UseUserProvider<HttpContextUserProvider>()
.UseTenantProvider<HttpContextTenantProvider>()
.LogSlowQueries(TimeSpan.FromSeconds(1)));
```

Expand All @@ -64,14 +62,14 @@ public class Order : AuditableEntity<Guid> { }
// Soft-deletable + audited, int PK
public class Customer : SoftDeletableEntity { }

// Full — soft-delete + audit + tenant + row version
// Full — soft-delete + audit + row version
public class Invoice : FullEntity { }
```

You can also implement interfaces directly if you prefer to control your own hierarchy:

```csharp
public class Customer : IAuditable, ISoftDeletable, ITenantEntity
public class Customer : IAuditable, ISoftDeletable
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
Expand All @@ -84,8 +82,6 @@ public class Customer : IAuditable, ISoftDeletable, ITenantEntity
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }

public string? TenantId { get; set; }
}
```

Expand Down Expand Up @@ -129,20 +125,6 @@ public class HttpContextUserProvider : IUserProvider
}
```

## 6. Implement ITenantProvider (if using multi-tenancy)

```csharp
public class HttpContextTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _accessor;

public HttpContextTenantProvider(IHttpContextAccessor accessor) => _accessor = accessor;

public string? GetCurrentTenantId()
=> _accessor.HttpContext?.User?.FindFirst("tenant_id")?.Value;
}
```

## What Happens Automatically

Once configured, EfCoreKit handles the following via EF Core interceptors:
Expand All @@ -151,8 +133,7 @@ Once configured, EfCoreKit handles the following via EF Core interceptors:
|---------|-------------|------|
| **Audit Trail** | Sets `CreatedAt`/`CreatedBy` on insert, `UpdatedAt`/`UpdatedBy` on update | Every `SaveChanges` / `SaveChangesAsync` |
| **Soft Delete** | Converts `DELETE` to `UPDATE SET IsDeleted = true` | When deleting an `ISoftDeletable` entity |
| **Multi-Tenancy** | Auto-assigns `TenantId` on insert, validates ownership on update | Every `SaveChanges` / `SaveChangesAsync` |
| **Query Filters** | Hides soft-deleted rows and scopes queries to the current tenant | Every LINQ query |
| **Query Filters** | Hides soft-deleted rows | Every LINQ query |
| **Slow Query Logging** | Logs a warning for queries exceeding the threshold | After each database command |
| **Concurrency** | Throws `ConcurrencyConflictException` on stale row version conflicts | Every `SaveChanges` / `SaveChangesAsync` |

Expand All @@ -161,7 +142,6 @@ Once configured, EfCoreKit handles the following via EF Core interceptors:
- [Base Entities](base-entities.md) — Entity class hierarchy and configuration bases
- [Soft Delete](soft-delete.md) — Lifecycle methods, restoring records, cascade delete
- [Audit Trail](audit-trail.md) — Timestamps, user tracking, field-level AuditLog
- [Multi-Tenancy](multi-tenancy.md) — Tenant isolation and filtering
- [Repository & Unit of Work](repository-uow.md) — Generic repository and transaction management
- [Specification Pattern](specifications.md) — Composable, reusable query logic
- [Pagination](pagination.md) — Offset and keyset/cursor pagination
Expand Down
Loading
Loading