CSharpEssentials.EntityFrameworkCore
6.5.1
See the version list below for details.
dotnet add package CSharpEssentials.EntityFrameworkCore --version 6.5.1
NuGet\Install-Package CSharpEssentials.EntityFrameworkCore -Version 6.5.1
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" Version="6.5.1" />
<PackageVersion Include="CSharpEssentials.EntityFrameworkCore" Version="6.5.1" />
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" />
paket add CSharpEssentials.EntityFrameworkCore --version 6.5.1
#r "nuget: CSharpEssentials.EntityFrameworkCore, 6.5.1"
#:package CSharpEssentials.EntityFrameworkCore@6.5.1
#addin nuget:?package=CSharpEssentials.EntityFrameworkCore&version=6.5.1
#tool nuget:?package=CSharpEssentials.EntityFrameworkCore&version=6.5.1
CSharpEssentials.EntityFrameworkCore
Extensions, interceptors, and utilities for Entity Framework Core. Audit trails, domain events, pagination, and CQRS support.
Features
- Audit Interceptor: Auto-populates
CreatedAt,CreatedBy,UpdatedAt,UpdatedByand handles soft deletes. - Domain Event Interceptor: Dispatches domain events with before/after save timing and outbox support.
- Slow Query Logging: Logs queries exceeding a configurable threshold.
- Pagination: Offset-based pagination on
IQueryableand composite keyset (cursor) pagination with opaque, versioned cursors andResulterrors for bad cursors. - Batch Soft Delete: Soft-delete many rows with a single
UPDATEstatement. - Named Query Filters (EF Core 10): Register the soft-delete filter under a name and ignore only that filter, with the CSE3001 analyzer and code fix for parameterless
IgnoreQueryFilters(). - BaseDbContext: Opt-in interceptor attachment from DI and domain event dispatch around
SaveChanges. - Maybe Columns: Store
Maybe<T>?properties in nullable columns, per property or by convention. - CQRS Support: Register separate write and read DbContexts, plus pooled and factory registrations with shared options.
- Entity Mapping:
EntityBaseMap,SoftDeletableEntityBaseMap,OptimisticConcurrencyVersionMapand a JSON column conversion. - Result Helpers:
FirstOrDefaultAsResultAsync,SingleOrDefaultAsResultAsync,FindAsResultAsyncandSaveChangesAsResultAsyncreturnResult<T>instead ofnullor an exception. - Hard Delete and Data Seeding:
HardDeleteon a context or set, andMigrateDataAsyncto sync seed data. - SQL Connection Factories: Abstractions for raw SQL/Dapper scenarios.
- Database Error Translation: Turns unique, foreign key, check and not-null violations, serialization failures and deadlocks into
Errorvalues through SQLSTATE, with pluggable provider translators.
Installation
dotnet add package CSharpEssentials.EntityFrameworkCore
Targets net10.0 (EF Core 10), net9.0 (EF Core 9) and net8.0 (EF Core 8); there is no net11.0 target. Use the EF Core major that matches your target framework.
Usage
Audit Interceptor
using CSharpEssentials.EntityFrameworkCore.Interceptors;
services.AddAuditInterceptor(() => "system-user");
services.AddDbContext<MyDbContext>((sp, options) =>
options.UseNpgsql(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
On SaveChanges, an Added entity that implements ICreationAudit gets SetCreatedInfo, a Modified one that implements IModificationAudit gets SetUpdatedInfo, and a Deleted ISoftDeletable that is not marked with MarkAsHardDeleted() is turned into a soft delete (MarkAsDeleted, state Modified). The user name comes from a scoped IAuditUserIdProvider, resolved on every save, and the time from TimeProvider. AddAuditInterceptor also registers TimeProvider.System and AuditInterceptor as singletons.
AddAuditInterceptor takes a Func<string>, a Func<IServiceProvider, string>, or the typed Func<TUserId> / Func<IServiceProvider, TUserId> forms (the user id is stored with ToString()). To resolve the user from the request, use the IServiceProvider form:
services.AddAuditInterceptor(sp =>
sp.GetRequiredService<IHttpContextAccessor>().HttpContext?.User.FindFirst("sub")?.Value ?? "anonymous");
AddAuditUserIdProvider (same overloads) registers only the scoped IAuditUserIdProvider, not the interceptor. Use it, or your own IAuditUserIdProvider / IAuditUserIdProvider<TUserId> class, when you register the interceptor yourself: services.AddSingleton(TimeProvider.System); services.AddSingleton<AuditInterceptor>(); (AddAuditInterceptor registers TimeProvider.System unconditionally, so it replaces a TimeProvider registered before it; register yours after it).
Domain Event Interceptor
services.AddSingleton<IDomainEventPublisher, MyPublisher>();
services.AddSingleton<DomainEventInterceptor>();
services.AddDbContext<MyDbContext>((sp, options) =>
options.UseNpgsql(connectionString)
.AddInterceptors(sp.GetRequiredService<DomainEventInterceptor>()));
// In your entity
public sealed record PriceChangedEvent(Guid ProductId, decimal NewPrice) : IDomainEvent;
public class Product : EntityBase<Guid>
{
public decimal Price { get; private set; }
public void UpdatePrice(decimal newPrice)
{
Price = newPrice;
Raise(new PriceChangedEvent(Id, newPrice));
}
}
public sealed class MyPublisher : IDomainEventPublisher
{
public Task PublishAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) =>
Task.CompletedTask; // hand the event to your bus or handlers
}
DomainEventInterceptor takes the events of every tracked IDomainEventHolder in SavingChanges, clears them from the entities, publishes the BeforeSave events through IDomainEventPublisher and then handles the AfterSave events (see the comparison table under BaseDbContext). IDomainEventOutbox.StoreAsync receives the AfterSave events instead of the publisher when an outbox is registered.
Pagination
using CSharpEssentials.EntityFrameworkCore.Pagination;
// Offset-based
var response = await dbContext.Users.PaginateAsync(
new PaginationRequest { PageNumber = 1, PageSize = 10 }, cancellationToken: ct);
// Offset-based with page number and size
var page = await dbContext.Users.OrderBy(u => u.Id).PaginateAsync(pageNumber: 2, pageSize: 20, cancellationToken: ct);
// Synchronous, also works on in-memory IQueryable
var memoryPage = users.AsQueryable().Paginate(pageNumber: 1, pageSize: 10);
// Cursor-based: see Keyset Pagination below
Result<KeysetPaginationResponse<Log>> feed = await dbContext.Logs.KeysetPaginateAsync(
new KeysetPaginationRequest { Limit = 20, After = nextCursor },
k => k.Descending(x => x.CreatedAt).Descending(x => x.Id), ct);
Offset pagination caps the page size at PaginationDefaults.MaxPageSize (100) by default. IPaginationRequest.Normalize() (a default interface method, so call it through the interface type) raises the page number and page size to 1 and lowers a larger page size to 100. Normalize(int maxPageSize) uses your own cap instead; pass int.MaxValue to turn the cap off. A cap below 1 throws ArgumentOutOfRangeException. PaginateAsync and Paginate take the same cap through an optional maxPageSize parameter, the same way KeysetPaginateAsync clamps Limit to KeysetPaginationOptions.MaxLimit.
IPaginationRequest request = new PaginationRequest { PageNumber = 1, PageSize = 5000 };
await dbContext.Users.OrderBy(u => u.Id).PaginateAsync(request, cancellationToken: ct); // 100 rows
await dbContext.Users.OrderBy(u => u.Id).PaginateAsync(request, maxPageSize: 500, cancellationToken: ct); // 500 rows
ICursorPaginationRequest<TCursor>.Normalize() only raises Limit to 1; Normalize(int maxLimit) also lowers it to maxLimit. It is used only by the obsolete single-column cursor PaginateAsync, so it keeps its 5.x behavior; KeysetPaginateAsync caps Limit on its own.
PaginationRequest.Search is trimmed by Normalize and, when it is not empty and you pass a search delegate (Func<string, Expression<Func<T, bool>>>), PaginateAsync and Paginate apply that filter before counting. PaginationResponse<T> carries Items, PageNumber, PageSize, TotalCount, TotalPages, HasPreviousPage and HasNextPage; with includeTotalCount: false no COUNT runs and TotalCount is -1.
Keyset Pagination
KeysetPaginateAsync pages through a query by a composite key, such as "newest first, then by id". It returns opaque cursors for the next and previous page, sends one query that reads limit + 1 rows, and never runs a COUNT. Unlike offset paging, its cost does not grow with the page number, and rows inserted or deleted between requests do not shift the pages.
using CSharpEssentials.EntityFrameworkCore.Pagination;
using CSharpEssentials.EntityFrameworkCore.Pagination.Requests;
// GET /orders?limit=20&after=eyJ2IjoxLCJkIjoiYSIs...
Result<KeysetPaginationResponse<Order>> page = await db.Orders
.Where(o => o.CustomerId == customerId)
.KeysetPaginateAsync(
new KeysetPaginationRequest { Limit = 20, After = after, Before = before },
k => k.Descending(o => o.CreatedAt).Descending(o => o.Id),
ct);
// page.Value.Items, NextCursor, PreviousCursor, HasNext, HasPrevious
- Keys. The keys replace any
OrderByalready on the query. Together they must be unique, so end with a unique tie-breaker such as the primary key. Directions can be mixed (Descending(x => x.CreatedAt).Ascending(x => x.Id)). AKeysetOrdering<T>is immutable; build it once and reuse it with theKeysetOrdering<T>overloads. - Request.
AftertakesNextCursorand returns the following page;BeforetakesPreviousCursorand returns the page before it. Setting both returns aKeysetPagination.AfterAndBeforevalidation error. A request with neither returns the first page. - Limit.
Limitis clamped to1..KeysetPaginationOptions.MaxLimit(default100, at mostint.MaxValue - 1). - Cursor length. A cursor longer than
KeysetPaginationOptions.MaxCursorLength(default2048characters) is rejected withInvalidCursorbefore it is decoded. Raise it if a protector produces longer cursors. - Before pages. For a
Beforerequest the ordering is reversed in SQL,limit + 1rows are read, and the page is reversed in memory, soItemsis always in the configured order. - Has next/previous.
HasNextandHasPreviouscome from the extra row, not from a count. A page reached withAfteralways reports a previous page, and a page reached withBeforealways reports a next page, since the cursor came from a real neighbouring page. An empty page has no cursors. - Errors. A malformed, tampered, outdated or mismatched cursor never throws. It returns
Error.Validationwith one of theKeysetCursorErrorscodes (InvalidCursor,UnsupportedCursorVersion,CursorDirectionMismatch,CursorKeyMismatch), and the metadata keyparameternames the bad request field (afterorbefore). Map it to HTTP 400.
Cursor format. A cursor is base64url (no padding) of a small versioned JSON payload: {"v":1,"d":"a","k":"<key fingerprint>","p":[<key values>]}. d is the direction (a after, b before), k is a hash of the entity type and the key paths, types and directions, so a cursor issued for one ordering or entity is rejected by another, and p holds the typed key values. DateTime keeps its Kind, DateTimeOffset its offset, enums are stored as numbers, and non-finite double/float values as the strings "NaN", "Infinity" and "-Infinity". Supported key types: int, long, short, byte, decimal, double, float, string, Guid, DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly and enums (except ulong-based ones).
Cursor protection. The default cursor is encoded, not encrypted or signed: a client can read the key values and craft a cursor. That is safe for the query (every value is a typed SQL parameter), but if the key values are sensitive or you want to reject client-built cursors, plug in an ICursorProtector. The package does not reference ASP.NET Core; a Data Protection wrapper takes a few lines:
public sealed class DataProtectionCursorProtector(IDataProtectionProvider provider) : ICursorProtector
{
private readonly IDataProtector _protector = provider.CreateProtector("KeysetCursor.v1");
public string Protect(string cursor) => _protector.Protect(cursor);
public bool TryUnprotect(string protectedCursor, out string cursor)
{
try { cursor = _protector.Unprotect(protectedCursor); return true; }
catch (Exception ex) when (ex is CryptographicException or FormatException) { cursor = string.Empty; return false; }
}
}
var options = new KeysetPaginationOptions { Protector = protector, MaxLimit = 50 };
Result<KeysetPaginationResponse<Order>> page = await db.Orders.KeysetPaginateAsync(request, ordering, options, ct);
TryUnprotect must not throw; return false and the request gets an InvalidCursor error.
Predicate. The default KeysetPredicateBuilder writes the expanded form with a redundant leading-column condition, which lets the database use an index on the first key:
-- Descending(CreatedAt).Ascending(Name).Ascending(Id), After cursor
WHERE "CreatedAt" <= @p0 AND ("CreatedAt" < @p0 OR ("CreatedAt" = @p0 AND ("Name" > @p1 OR ("Name" = @p1 AND "Id" > @p2))))
ORDER BY "CreatedAt" DESC, "Name", "Id"
LIMIT @p3
Cursor values always become SQL parameters, so the SQL text, and the cached query plan, are the same for every page. Strings are compared with string.Compare, Guid with CompareTo, and enums through their numeric value, which EF Core translates to plain </> comparisons. The comparison therefore follows the database's own ordering (collation for strings, the stored form for Guid), the same ordering as the generated ORDER BY.
To emit a different predicate, implement IKeysetPredicateBuilder and set KeysetPaginationOptions.PredicateBuilder. Each KeysetColumn carries the column expression, the parameter expression for the cursor value, the CLR type and the effective direction (already reversed for Before pages).
Untested sketch: PostgreSQL row values. This is not shipped or tested by this package; verify it against your Npgsql version before use. Npgsql translates EF.Functions.GreaterThan/LessThan over value tuples to a row-value comparison, which PostgreSQL can serve from one composite index. A custom builder would have to emit an expression equivalent to this LINQ (shown for Descending(x => x.CreatedAt).Descending(x => x.Id) and an After cursor):
x => EF.Functions.LessThan(ValueTuple.Create(x.CreatedAt, x.Id), ValueTuple.Create(cursorCreatedAt, cursorId))
// WHERE ("CreatedAt", "Id") < (@p0, @p1)
A row-value comparison only fits orderings whose keys all share one direction. For mixed directions, return KeysetPredicateBuilder.Instance.BuildPredicate(columns) from your builder.
Limitations.
- Nullable key columns (
int?,string?with nullable annotations) are rejected withArgumentExceptionwhen the ordering is built, because SQLNULLbreaks</>comparisons. Use a non-nullable column, or a computed non-null column. - Keys must be property or field accesses on the entity (
x => x.CreatedAt,x => x.Audit.CreatedAt), not computed expressions. Every member on the path must be non-nullable:x => x.Audit!.CreatedAtthrough a nullableAudit?navigation is rejected withArgumentException. - The provider must translate ordering and comparison of each key type. SQLite, for example, cannot order or compare
decimal,DateTimeOffsetorTimeSpancolumns, and EF Core throwsNotSupportedExceptionfor them; SQL Server and PostgreSQL support them. Enum keys are compared through their numeric value, so they must be stored as numbers (the default). Enums stored as strings, throughConfigureEnumConventions,[StringEnum]orHasConversion<string>(), cannot be keys: the database would order and compare the names, not the numeric values the cursor holds. - The ordering is by the key values alone. Rows that change their key values between requests can move between pages.
- There is no total count. Run a separate
CountAsyncif you need it.
Moving from PaginateAsync<T, TCursor>. The single-column cursor overload is [Obsolete] since 6.0 (warning CS0618) and will be removed in a later major version. It still works and sends the cursor as a SQL parameter, but it filters by one column only, so that column must be unique: with a non-unique column such as a timestamp, a page that ends inside a group of equal values skips the rest of that group. It also has no upper bound on Limit. To migrate:
// Before
var response = await db.Logs.PaginateAsync(
new CursorPaginationRequest<DateTime> { Limit = 20, Cursor = lastCreatedAt }, x => x.CreatedAt, isAscending: false);
// After: the tie-breaker makes the key unique, and the cursor is opaque
Result<KeysetPaginationResponse<Log>> page = await db.Logs.KeysetPaginateAsync(
new KeysetPaginationRequest { Limit = 20, After = nextCursor },
k => k.Descending(x => x.CreatedAt).Descending(x => x.Id), ct);
Cursors from PaginateAsync (raw values) are not accepted by KeysetPaginateAsync; clients restart from the first page once.
Batch Soft Delete
// UPDATE ... SET IsDeleted = 1, DeletedAt = @now, DeletedBy = @user WHERE ... AND NOT IsDeleted
int affected = await dbContext.Orders
.Where(o => o.CustomerId == customerId)
.SoftDeleteAsync(deletedBy: userId, timeProvider, ct);
Works for any entity implementing ISoftDeletable (e.g. SoftDeletableEntityBase). Rows already soft-deleted keep their original DeletedAt/DeletedBy. Like all ExecuteUpdate calls, it bypasses the change tracker and SaveChanges interceptors and needs a relational provider.
Named Query Filters (EF Core 10)
EF Core 10 can name query filters, so a query can switch off one filter and keep the others. On net10.0 the package registers the soft-delete filter (!IsDeleted) under QueryFilterNames.SoftDelete:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasSoftDeleteQueryFilter() // one entity
.HasQueryFilter(QueryFilterNames.Tenant, o => o.TenantId == _tenant.Id);
// or, at the end of OnModelCreating, every root entity type that implements ISoftDeletableBase:
// modelBuilder.ApplyNamedSoftDeleteQueryFilter();
}
// Soft-deleted orders of the current tenant: the Tenant filter still applies.
List<Order> orders = await db.Orders.IgnoreSoftDeleteQueryFilter().ToListAsync(ct);
// same as db.Orders.IgnoreQueryFilters(SoftDeleteOnly), with
// private static readonly string[] SoftDeleteOnly = [QueryFilterNames.SoftDelete];
QueryFilterNames.SoftDelete("SoftDelete") andQueryFilterNames.Tenant("Tenant") are plain constants; use them for your own filters so callers can ignore them by name.- Pass filter keys in a
static readonlyarray ornew[] { ... }. EF Core 10.0.x compiles the query again on every execution when the keys come from a collection expression ([...]) or aList<string>.IgnoreSoftDeleteQueryFilter()already uses a cached array. - EF Core does not allow an anonymous filter and named filters on the same entity type. Use these helpers instead of
ApplySoftDeleteQueryFilter/AddQueryFilter, not next to them; they throwInvalidOperationExceptionwhen the entity type already has an anonymous filter. Those anonymous helpers keep working unchanged, and onnet8.0/net9.0they are the only option. ApplyNamedSoftDeleteQueryFilterputs the filter on root entity types only; derived types inherit it. A soft-deletable type whose root is not soft-deletable gets no filter, so make the root implementISoftDeletableBase. Owned types are skipped, because EF Core does not allow query filters on them.
Moving from ApplySoftDeleteQueryFilter(). Named keys only switch off named filters. While the model still uses the anonymous ApplySoftDeleteQueryFilter(), IgnoreQueryFilters(new[] { QueryFilterNames.SoftDelete }) and IgnoreSoftDeleteQueryFilter() ignore nothing and soft-deleted rows stay hidden. Switch the model to ApplyNamedSoftDeleteQueryFilter() (and register your other filters by name, for example with HasQueryFilter(QueryFilterNames.Tenant, ...)) before you change the queries.
The package ships an analyzer for the parameterless IgnoreQueryFilters(), which turns off every filter, the tenant filter included. It runs only when the referenced EF Core has the named overload (EF Core 10 or later):
| ID | Severity | Rule |
|---|---|---|
| CSE3001 | Info | IgnoreQueryFilters() ignores every query filter of the entity type. Pass the names of the filters to ignore. |
CSE3001 is Info by default so that apps still on the anonymous filter are not broken. Once your filters are named, raise it in .editorconfig:
[*.cs]
dotnet_diagnostic.CSE3001.severity = warning
When the entity implements ISoftDeletableBase, the code fix "Ignore only the named 'SoftDelete' filter (requires HasSoftDeleteQueryFilter/ApplyNamedSoftDeleteQueryFilter)" rewrites the call to IgnoreQueryFilters(new[] { QueryFilterNames.SoftDelete }), adding the using when needed (the "SoftDelete" literal when QueryFilterNames is not available). It uses new[] { ... } because collection expressions are not allowed in expression trees and make EF Core 10.0.x recompile the query; move the array to a static readonly field if the call is on a hot path. Apply it only after the model registers the named filter (see above). For other entities there is no fix, because only you know which filters the query should skip. If the query really must see every row, suppress the diagnostic at that call with [SuppressMessage("CSharpEssentials.EntityFrameworkCore", "CSE3001", Justification = "...")], or set dotnet_diagnostic.CSE3001.severity = none in .editorconfig.
BaseDbContext
public sealed class AppDbContext(DbContextOptions<AppDbContext> options, IServiceScopeFactory scopeFactory)
: BaseDbContext<AppDbContext>(options, scopeFactory)
{
// Attach interceptors registered in DI (AddAuditInterceptor, AddSlowQueryInterceptor, ...).
protected override DbContextInterceptors InterceptorsFromServices =>
DbContextInterceptors.Audit | DbContextInterceptors.SlowQuery;
// Dispatch domain events around SaveChanges/SaveChangesAsync.
protected override bool DispatchDomainEventsOnSaveChanges => true;
}
Both hooks are off by default. Interceptors that are not registered in DI, or are already on the options, are skipped. When dispatch is on, BeforeSave events go to IDomainEventPublisher before the save, and AfterSave events go to IDomainEventOutbox (if registered) or IDomainEventPublisher after the save succeeds. Override DispatchDomainEventsAsync to change this. If you override OnConfiguring, call base.OnConfiguring(optionsBuilder).
The two domain event paths differ in when AfterSave events are handled:
DispatchDomainEventsOnSaveChanges |
DomainEventInterceptor |
|
|---|---|---|
AfterSave events |
after the save completes | in SavingChanges, before the database write |
| Outbox write | not atomic with the save: the save is already committed when StoreAsync runs; inside an explicit transaction it runs before that transaction commits |
before the save, in the same SaveChanges call |
| Save fails | AfterSave events are put back on their entities, so a retry dispatches them |
events are already dispatched |
Use one of them, not both. When DispatchDomainEventsOnSaveChanges is true, DbContextInterceptors.DomainEvents in InterceptorsFromServices is ignored and the DomainEventInterceptor is not attached. The context's service scope (ServiceProvider) is disposed with the context.
Maybe Columns
Maybe<T> is a struct, and EF Core cannot make a non-nullable struct property optional, so a plain Maybe<T> property always maps to a NOT NULL column. Declare the property as Maybe<T>? to store absence as NULL:
using CSharpEssentials.Maybe; // Maybe<T>, Flatten
using CSharpEssentials.EntityFrameworkCore; // HasNullableMaybeConversion, ConfigureNullableMaybeConventions
public sealed class Customer
{
public int Id { get; set; }
public Maybe<int>? LoyaltyPoints { get; set; } // INTEGER NULL
public Maybe<string>? Nickname { get; set; } // TEXT NULL
}
// Every public read-write Maybe<T>? property of every entity type
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
configurationBuilder.ConfigureNullableMaybeConventions();
// Or per property
modelBuilder.Entity<Customer>().Property(c => c.LoyaltyPoints).HasNullableMaybeConversion();
modelBuilder.Entity<Customer>().Property(c => c.Nickname).HasNullableMaybeConversion();
Maybe<int> points = customer.LoyaltyPoints.Flatten(); // null becomes None
nullandNoneare both written asNULL;NULLis read back asnull, notNone. CallFlatten()to get aMaybe<T>.- The convention covers entity types, owned types, derived types (TPH) and complex types (
ComplexProperty). It skips ignored and[NotMapped]properties and properties with your ownHasConversion(yours wins). It reads the CLR properties once while the model is built; nothing runs per query. Maybe<T?>?(for exampleMaybe<int?>?) is not supported: EF Core fails the model build. Ignore the property or configure your own converter.HasNullableMaybeConversionneeds a concreteT: from unconstrained generic code (or forMaybe<int?>?) the compiler reports CS0452 against theclassoverload. Use the convention there, orHasConversion(new NullableMaybeConverter<T>())/new NullableMaybeReferenceConverter<T>()(namespaceCSharpEssentials.EntityFrameworkCore.Converters).- Mutable reference values (
Maybe<byte[]>?): change tracking comparesMaybe<T>values, so changing the array in place is not saved. Assign a new value instead. - NativeAOT: the convention closes the converter types with
MakeGenericTypewhile the model is built ([RequiresDynamicCode]). Under NativeAOT use a compiled model (dotnet ef dbcontext optimize), or the per-property API. This applies toMaybe<T>?columns only: properties configured byConfigureEnumConventionscannot be compiled (see Enum Storage). - Query rules:
- absence:
c.Nickname == nullor!c.Nickname.HasValue(theNullable<T>.HasValue); - presence:
c.Nickname != null; - a value:
c.LoyaltyPoints == Maybe<int>.From(5)(or aMaybe<int>variable). - Never compare with
None:c.LoyaltyPoints == Maybe<int>.Noneis sent as a non-null parameter, so it matches noNULLrow. - Members of
Maybe<T>itself (.Value.HasValue,.Value.Value) do not translate to SQL; the query throwsInvalidOperationException.
- absence:
- Background: EF Core issues #24685, #34943 and #13850.
- Migration: changing a
Maybe<T>property toMaybe<T>?changes the column fromNOT NULLtoNULL(anAlterColumn). Existingdefault(T)values written forNonestay as they are; update them toNULLin the migration if they meant absence.
MaybeConversion<T>() (for Maybe<T> without ?) is kept for compatibility but cannot store None: for a value type T it writes default(T) and reads back Some(default(T)); for a reference type T it writes NULL into a NOT NULL column, so the insert fails. Prefer Maybe<T>? with the API above.
If you would rather keep the entity free of nullable Maybe, map a T? backing field and expose Maybe<T> from the domain side:
public sealed class Customer
{
private string? _nickname; // TEXT NULL, mapped by EF
public Maybe<string> Nickname
{
get => Maybe<string>.From(_nickname);
set => _nickname = value.GetValueOrDefault();
}
}
modelBuilder.Entity<Customer>(b =>
{
b.Ignore(c => c.Nickname);
b.Property<string?>("_nickname").HasColumnName("Nickname");
});
// Query through the field: db.Customers.Where(c => EF.Property<string?>(c, "_nickname") == null)
Enum Storage
ConfigureEnumConventions stores [StringEnum] enums by their wire name (the same spelling as JSON and binding) and adds a check constraint per column:
using CSharpEssentials.Enums; // EnumConventions, EnumStorage, [StringEnum]
using CSharpEssentials.EntityFrameworkCore; // ConfigureEnumConventions, HasEnumStorage, HasEnumCheckConstraint
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
configurationBuilder.ConfigureEnumConventions(EnumConventions.Default);
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().Property(o => o.Priority).HasEnumStorage(EnumStorage.Integer);
modelBuilder.Entity<Order>().Property(o => o.Kind).HasEnumCheckConstraint(false);
}
}
| Shape | Column | Check constraint ck_{table}_{column}_enum |
|---|---|---|
String (default) |
text on PostgreSQL, TEXT on SQLite, otherwise n = longest wire name rounded up to 16 |
"Status" IN ('pending', 'shipped') |
Integer ([StringEnum(Storage = EnumStorage.Integer)], HasEnumStorage, EnumConventions.Storage) |
underlying integer type | "Priority" IN (0, 1, 2) |
[Flags] (integer by default) |
underlying integer type | ("Permissions" & ~7) = 0 |
[Flags] with String storage |
text[] on PostgreSQL, JSON array text elsewhere |
<@ ARRAY[...]::text[] on PostgreSQL |
List<TEnum> |
text[]/integer[] on PostgreSQL, JSON text elsewhere |
<@ ARRAY[...] on PostgreSQL |
ToJson() owned types |
JSON column | none; EnumJsonValueReaderWriter<TEnum> writes wire names |
- Precedence:
HasEnumStorageper property, then[StringEnum(Storage)], thenEnumConventions.Storage(FlagsStoragefor flags). - Reads are tolerant (wire name, member name, aliases, any case); writes are canonical. An unknown stored value maps to the
[EnumFallback]member, otherwise the query throws and the message names the column. - Enums without generated metadata (no
[StringEnum]) keep EF's default integer column.ConfigureEnumConventionsWithReflectionopts them in through reflection (not trim or AOT safe). - Properties with your own
HasConversionare skipped. - Compiled models (
dotnet ef dbcontext optimize) are not supported for properties configured by these conventions: the converters capture the model'sEnumConventionsand metadata as instances, anddbcontext optimizefails for them. - Adding a member changes only the constraint, so the migration is
DropCheckConstraint+AddCheckConstraint. - Opt out of constraints per property with
HasEnumCheckConstraint(false), or globally withEnumConventions.CheckConstraints = false. - Renaming a member also changes the constraint; see Renaming a member safely.
Upgrading an existing database
Existing enum columns hold integers (EF's default) or an older text format. Keep them as they are, so the first 5.0 migration is empty:
using CSharpEssentials.Enums; // EnumConventions, EnumStorage
using CSharpEssentials.EntityFrameworkCore; // EnumStoredAs, HasLegacyEnumStorage
configurationBuilder.ConfigureEnumConventions(EnumConventions.Default, existingStorage: EnumStoredAs.Integer);
// opt in one column at a time
modelBuilder.Entity<Order>().Property(o => o.Status).HasEnumStorage(EnumStorage.String);
// or keep a column's old text format (MemberName, CamelCase, LegacySnakeCase, FlagsText)
modelBuilder.Entity<Order>().Property(o => o.Kind).HasLegacyEnumStorage(EnumStoredAs.MemberName);
existingStorage and HasLegacyEnumStorage keep writing the old format, read every spelling and add no check constraint. The 4.x overloads (ConfigureEnumConventions(params Assembly[]), EnumConventionOptions) are obsolete forwarders; UseLegacySnakeCase = true maps to existingStorage: EnumStoredAs.LegacySnakeCase.
Converting the data
When you drop HasLegacyEnumStorage (or existingStorage) for a column, dotnet ef migrations add generates an AlterColumn that changes the type without converting the data. Replace it with ConvertEnumColumn, and keep the constraint operations around it:
using CSharpEssentials.Enums; // EnumStorage
using CSharpEssentials.EntityFrameworkCore; // ConvertEnumColumn, EnumStoredAs
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(name: "ck_orders_Status_enum", table: "orders"); // when one exists
migrationBuilder.ConvertEnumColumn<OrderStatus>("orders", "Status", from: EnumStoredAs.Integer, to: EnumStorage.String);
migrationBuilder.AddCheckConstraint(name: "ck_orders_Status_enum", table: "orders", sql: "..."); // as generated
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(name: "ck_orders_Status_enum", table: "orders");
migrationBuilder.ConvertEnumColumn<OrderStatus>("orders", "Status", from: EnumStoredAs.Text, to: EnumStorage.Integer);
}
| From | To | Notes |
|---|---|---|
Integer |
EnumStorage.String or a text format (MemberName, CamelCase, LegacySnakeCase) |
undefined numbers are kept as their text and fail the new constraint |
Text (any known spelling), MemberName, CamelCase, LegacySnakeCase |
EnumStorage.String or another text format |
unknown text is kept and fails the new constraint |
| any text | EnumStorage.Integer |
integer text is kept; other unknown text aborts the migration with cannot convert orders.Status to OrderStatus, unknown value: ... |
FlagsText ("Read, Write") |
EnumStorage.Integer |
tokens are OR'ed; on PostgreSQL through a temporary column |
flags Integer |
EnumStoredAs.FlagsText |
single-flag member names; values with undefined bits stay numbers |
- No conversion writes
NULL. Text conversions skip canonical values, so running them twice changes nothing. - SQL is generated for PostgreSQL and SQLite (
MigrationBuilder.ActiveProvider); other providers throw. On SQLite EF rebuilds the table. type:sets a non-default target store type (for examplevarchar(32));schema:the table schema.ConvertEnumJsonPath<TEnum>("orders", "payload", ["status"])rewrites one path of a PostgreSQLjsonbcolumn (to: EnumStorage.IntegerforDown); unknown values are left untouched.EnumDataAudit.Sql<TEnum>("orders", "Status", storedAs: ..., provider: ...)returns a read-only query that lists the values a conversion or constraint would reject, with their counts. Run it in production before you deploy the migration (see below).- CSE0014 (build error) reports a generated
AlterColumnthat you forgot to delete next toConvertEnumColumnfor the same column.
Auditing the data
EnumDataAudit.Sql only builds the SQL (SELECT "Status" AS "Value", count(*) AS "Count" ... GROUP BY ... ORDER BY ...); it changes nothing. Print it and run it with psql or your SQL client, or run it through EF Core:
using CSharpEssentials.EntityFrameworkCore; // EnumDataAudit, EnumStoredAs
string sql = EnumDataAudit.Sql<OrderStatus>("orders", "Status", storedAs: EnumStoredAs.Integer, provider: db.Database.ProviderName);
List<EnumAuditRow> rejected = await db.Database.SqlQueryRaw<EnumAuditRow>(sql).ToListAsync(ct);
foreach (EnumAuditRow row in rejected)
logger.LogWarning("orders.Status holds {Value} in {Count} rows", row.Value, row.Count);
// Value has the column's type: int for EnumStoredAs.Integer, string for text columns
public sealed class EnumAuditRow
{
public int Value { get; init; }
public long Count { get; init; }
}
An empty result means every row converts and passes the new constraint. Pass schema: for a table outside the default schema.
CQRS Write/Read
using CSharpEssentials.EntityFrameworkCore.Extensions;
services.AddCqrsDbContexts<WriteDbContext, ReadDbContext>(
configureWrite: (sp, options) => options.UseNpgsql(connectionString),
configureRead: (sp, options) => options.UseNpgsql(connectionString).UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
AddCqrsDbContexts registers both contexts as pooled contexts (AddDbContextPool): the write context with UseAsWriteContext() (QueryTrackingBehavior.TrackAll) and the read context with UseAsReadContext() (NoTracking). Without configureRead the read context reuses configureWrite. The same pieces are available on their own: AddWriteDbContext<T>, AddReadDbContext<T>, AddPooledDbContext<T> and RegisterDbContextFactory<T> (an IDbContextFactory<T>), and UseAsWriteContext, UseAsReadContext and UseAsReadContextWithIdentityResolution on DbContextOptionsBuilder. Each registration method also has an overload that takes Action<DbContextRegistrationOptions> before the provider callback:
services.AddWriteDbContext<WriteDbContext>(
options =>
{
options.EnableAuditInterceptor = true;
options.EnableSlowQueryInterceptor = true;
options.EnableDetailedErrors = true;
},
(sp, builder) => builder.UseNpgsql(connectionString));
DbContextRegistrationOptions applies EnableDetailedErrors, EnableSensitiveDataLogging, QueryTrackingBehavior, and EnableAuditInterceptor, EnableDomainEventInterceptor and EnableSlowQueryInterceptor, which add the interceptor only when it is registered in DI. The properties MigrationsAssembly, RetryOptions and QuerySplittingBehavior exist on the class but are not applied by this version; set them in the provider callback instead (UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly("MyApp.Migrations").EnableRetryOnFailure().UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))).
Entity Mapping
EntityBaseExtensions configures the audit columns of the CSharpEssentials.Entity base classes in IEntityTypeConfiguration<T> or OnModelCreating:
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order> // Order : SoftDeletableEntityBase<Guid>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.SoftDeletableEntityBaseGuidIdMap(); // key Id, audit columns, IsDeleted/DeletedAt/DeletedBy; IsHardDeleted is ignored
builder.OptimisticConcurrencyVersionMap(); // shadow byte[] "RowVersion" marked as a row version
}
}
public sealed class InvoiceConfiguration : IEntityTypeConfiguration<Invoice> // Invoice : EntityBase<long>
{
public void Configure(EntityTypeBuilder<Invoice> builder) =>
builder.EntityBaseMap<Invoice, long>(userIdMaxLength: 64); // any key type
}
EntityBaseGuidIdMap() does the same for an EntityBase<Guid> entity without the soft delete columns. The audit columns are CreatedAt/CreatedBy (required) and UpdatedAt/UpdatedBy (optional), with the user columns limited to 40 characters unless you pass userIdMaxLength. EntityBaseMap<TEntity>() and SoftDeletableEntityBaseMap<TEntity>() map the audit columns without a key. ApplySoftDeleteQueryFilter() adds !IsDeleted as an anonymous filter to every root entity type that implements ISoftDeletableBase, and AddQueryFilter<T>(expression) combines your filter with the existing anonymous one with AND (on net10.0 use the named helpers below when you need to ignore filters by name). HasJsonConversion(columnType: "jsonb") stores a property as JSON with the CSharpEssentials JSON options.
Result Helpers, Hard Delete and Seeding
Result<Order> order = await db.Orders.Where(o => o.Id == id).FirstOrDefaultAsResultAsync(Error.NotFound("Order.NotFound", "Order not found"), ct);
Result<Order> single = await db.Orders.SingleOrDefaultAsResultAsync(cancellationToken: ct); // Error.NotFound() when no row
Result<Order> found = await db.Orders.FindAsResultAsync([id], cancellationToken: ct);
Result saved = await db.SaveChangesAsResultAsync(ct); // every exception becomes an Unknown error
db.HardDelete(order.Value); // MarkAsHardDeleted + Remove: the audit interceptor keeps the delete
db.HardDelete(await db.Orders.Where(o => o.IsDeleted).ToListAsync(ct));
await db.SaveChangesAsync(ct);
DbSet<T>.Delete(entities) is a hard delete as well, despite its name: it calls MarkAsHardDeleted() on every item and removes them. A null row gives the notFoundError you pass, or Error.NotFound(). SaveChangesAsResultAsync lets an OperationCanceledException from your token propagate; use DbErrorTranslation (below) when you want database errors translated into specific Error values. AsNoTracking(bool isDisabled) on IQueryable<T> applies AsNoTracking() unless isDisabled is true.
MigrateDataAsync adds seed rows when a precondition says they are missing, or syncs a table with a key-based diff (add, update, and remove rows that are not in the seed data):
await db.MigrateDataAsync<Country, CountrySeed>(
seed,
(set, data) => set.Any(), // true: the table is already seeded, do nothing
s => new Country { Code = s.Code },
ct);
await db.MigrateDataAsync(seed, new MigrateDataOptions<Country, CountrySeed, string>
{
Query = set => set,
EntityKeyProperty = c => c.Code,
DataKeyProperty = s => s.Code,
IsUpdatedFunc = (c, s) => c.Name != s.Name,
UpdateFunc = (c, s) => { c.Name = s.Name; return c; },
Converter = s => new Country { Code = s.Code, Name = s.Name },
HardDeleteMode = false, // false: a soft-deletable row missing from the seed goes through the normal delete (soft delete with the audit interceptor); true: it is hard deleted
}, ct);
SQL Connection Factories
ISqlConnectionFactory (IDbConnection Create()) and ISqlReadOnlyConnectionFactory are interfaces only. Implement and register them yourself for Dapper or raw ADO.NET code that should not go through the DbContext.
Transaction Runner
EfCoreTransactionRunner<TDbContext> implements ITransactionRunner (from CSharpEssentials.Core) for the Mediator TransactionBehavior or any code of your own.
services.AddEfCoreTransactionRunner<AppDbContext>();
// namespaces: CSharpEssentials.Transactions (ITransactionRunner), CSharpEssentials.EntityFrameworkCore.Transactions (AddEfCoreTransactionRunner)
// orderService.PlaceAsync returns ValueTask<Result>
Result result = await transactionRunner.ExecuteAsync(
ct => orderService.PlaceAsync(command, ct),
outcome => outcome.IsSuccess,
cancellationToken);
The work delegate returns a ValueTask<T> and shouldCommit decides whether the transaction commits; a work that throws rolls back.
- The outermost call runs inside
Database.CreateExecutionStrategy()and opens a transaction withBeginTransactionAsync, so it works with retrying strategies such asEnableRetryOnFailure. A retry re-runs the whole unit of work, nested calls included, on a cleared change tracker. With a retrying strategy the context must have no pending changes when the call starts (the runner throws otherwise), because the clear would drop them. - A nested call on the same context instance (
Database.CurrentTransactionis set) joins the outer transaction: no new strategy, begin or commit. When the provider supports savepoints, the nested work runs behind a savepoint and is rolled back to it when it fails, so its partial writes are undone and the outer call still decides the commit. Entities that nested work saved stay tracked after a rollback to the savepoint, and entities stillAddedafter a failed nestedSaveChangesare written again by the next outerSaveChanges; reload or detach them if the outer work carries on. Without savepoint support (for example SQL Server with MARS enabled), a failed nested call keeps its writes and the outer call decides about them. - Only the same
DbContextinstance is joined. Another context type or connection opens its own transaction and is not atomic with this one. - It needs a relational provider: the InMemory provider throws on
BeginTransactionAsyncunlessTransactionIgnoredWarningis ignored. Retrying strategies also refuse to run inside an ambientTransactionScope, so don't combine it withTransactionScopeBehavior. - If the commit itself fails with a transient error after the database applied it, the retry runs the work again. Make the work idempotent when you use a retrying strategy.
Slow Query Logging
services.AddSlowQueryInterceptor(TimeSpan.FromMilliseconds(500)); // or: options => options.Threshold = ...; the default is 1 second
services.AddSingleton<ISlowQueryHandler, MetricsSlowQueryHandler>(); // optional
services.AddDbContext<MyDbContext>((sp, options) =>
options.UseNpgsql(connectionString)
.AddInterceptors(sp.GetRequiredService<SlowQueryInterceptor>()));
public sealed class MetricsSlowQueryHandler : ISlowQueryHandler
{
public void OnSlowQuery(SlowQueryContext context) =>
Console.WriteLine($"{context.ElapsedTime}: {context.CommandText}"); // also Parameters, Database, HasTransaction, MethodName
}
Every command (reader, scalar or non-query) that runs for at least the threshold is logged as a warning with its command text and parameter values, and passed to the ISlowQueryHandler when one is registered. Parameter values can be sensitive; keep that in mind before shipping the logs.
Database Error Translation
DbErrorTranslation (namespace CSharpEssentials.EntityFrameworkCore.DbErrors) turns persistence exceptions into Error values, which the AspNetCore package renders as ProblemDetails. The built-in SqlStateErrorTranslator reads DbException.SqlState from the exception or any inner exception, so it needs no provider reference:
| SQLSTATE | Meaning | Error | Code |
|---|---|---|---|
23505 |
unique violation | Conflict (409) |
Database.UniqueViolation |
23503 |
foreign key violation | Conflict (409) |
Database.ForeignKeyViolation |
23514 |
check violation | Validation (400) |
Database.CheckViolation |
23502 |
not null violation | Validation (400) |
Database.NotNullViolation |
40001 |
serialization failure | Conflict (409), retryable: true |
Database.SerializationFailure |
40P01 |
deadlock | Conflict (409), retryable: true |
Database.Deadlock |
The metadata carries sqlState, and entities (the entity type names of DbUpdateException.Entries) when there are any. The database message is not copied, because it can contain row values.
services.AddDbErrorTranslation();
public sealed class RegisterUserHandler(AppDbContext db, DbErrorTranslation translation)
{
public async Task<Result> Handle(RegisterUser command, CancellationToken ct)
{
db.Users.Add(new User(command.Email));
Result<int> saved = await translation.SaveChangesAsync(db, ct); // Conflict on a duplicate email
return saved.IsSuccess ? Result.Success() : saved.Errors;
}
}
translation.SaveChangesAsync returns the translated error and rethrows exceptions no translator recognizes, so unexpected failures still reach your exception handler. SaveChangesAsResultAsync is different: it turns every exception into an Unknown error, except an OperationCanceledException caused by the cancellationToken you passed, which propagates. After a failure the change tracker keeps the unsaved changes. To translate an exception you caught yourself, call translation.TryTranslate(exception, out Error error).
Providers that report no SQLSTATE (SQL Server, SQLite) or extra details (the constraint name of PostgresException) plug in an IDbErrorTranslator. Custom translators run in registration order before the SQLSTATE default, and the first match wins. DbErrorTranslation is a singleton, so translators must be stateless:
using Npgsql; // PostgresException
services.AddDbErrorTranslator<PostgresConstraintTranslator>();
public sealed class PostgresConstraintTranslator : IDbErrorTranslator
{
private readonly SqlStateErrorTranslator _sqlState = new();
public bool TryTranslate(Exception exception, out Error error)
{
if (!_sqlState.TryTranslate(exception, out error))
return false;
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is PostgresException postgres)
error.Metadata!.AddMetadata("constraint", postgres.ConstraintName);
}
return true;
}
}
The default ProblemDetails fields (Codes | ValidationErrors) do not include error metadata. With ProblemErrorFields.AllErrors, the client also sees sqlState, entities and anything your translators add, such as constraint names.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CSharpEssentials.Core (>= 6.5.1)
- CSharpEssentials.Entity (>= 6.5.1)
- CSharpEssentials.Enums (>= 6.5.1)
- CSharpEssentials.Json (>= 6.5.1)
- CSharpEssentials.Maybe (>= 6.5.1)
- CSharpEssentials.Results (>= 6.5.1)
- EFCore.NamingConventions (>= 10.0.0 && < 11.0.0)
- Microsoft.EntityFrameworkCore (>= 10.0.1 && < 11.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.1)
- Microsoft.Extensions.Caching.Memory (>= 10.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.1)
-
net8.0
- CSharpEssentials.Core (>= 6.5.1)
- CSharpEssentials.Entity (>= 6.5.1)
- CSharpEssentials.Enums (>= 6.5.1)
- CSharpEssentials.Json (>= 6.5.1)
- CSharpEssentials.Maybe (>= 6.5.1)
- CSharpEssentials.Results (>= 6.5.1)
- EFCore.NamingConventions (>= 8.0.0 && < 9.0.0)
- Microsoft.Bcl.TimeProvider (>= 8.0.0)
- Microsoft.EntityFrameworkCore (>= 8.0.0 && < 9.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Caching.Memory (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- System.Text.Json (>= 9.0.4)
-
net9.0
- CSharpEssentials.Core (>= 6.5.1)
- CSharpEssentials.Entity (>= 6.5.1)
- CSharpEssentials.Enums (>= 6.5.1)
- CSharpEssentials.Json (>= 6.5.1)
- CSharpEssentials.Maybe (>= 6.5.1)
- CSharpEssentials.Results (>= 6.5.1)
- EFCore.NamingConventions (>= 9.0.0 && < 10.0.0)
- Microsoft.EntityFrameworkCore (>= 9.0.4 && < 10.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Caching.Memory (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.5.2 | 67 | 10/10/2026 |
| 6.5.1 | 65 | 10/10/2026 |
| 6.5.0 | 73 | 10/9/2026 |
| 6.4.0 | 67 | 10/9/2026 |
| 6.3.0 | 61 | 10/8/2026 |
| 6.2.0 | 64 | 10/8/2026 |
| 6.1.0 | 67 | 10/7/2026 |
| 6.0.0 | 65 | 10/7/2026 |
| 5.2.1 | 77 | 10/7/2026 |
| 5.2.0 | 72 | 10/7/2026 |
| 5.1.0 | 76 | 10/7/2026 |
| 5.0.0 | 81 | 10/6/2026 |
| 4.1.0 | 91 | 10/6/2026 |
| 4.0.0 | 90 | 10/5/2026 |
| 3.2.3 | 170 | 5/31/2026 |
| 3.2.2 | 133 | 5/31/2026 |
| 3.2.1 | 125 | 5/31/2026 |
| 3.2.0 | 124 | 5/30/2026 |
| 3.1.0 | 134 | 5/27/2026 |
| 3.0.8 | 130 | 5/20/2026 |