Exact signatures for interfaces, classes, and extension methods in the Regira Entities framework. Do not guess — look up here first.
using Regira.Entities.Models.Abstractions;
public interface IEntity;
public interface IEntity<TKey> : IEntity
{
TKey Id { get; set; }
}
// Shortcut for int primary key
public interface IEntityWithSerial : IEntity<int>;
using Regira.Entities.Models.Abstractions;
public interface IHasCode
{
string? Code { get; set; }
}
// Title is read-only on the interface; entity classes may expose a setter
public interface IHasTitle
{
string? Title { get; }
}
public interface IHasNormalizedTitle : IHasTitle
{
string? NormalizedTitle { get; set; }
}
public interface IHasDescription
{
string? Description { get; set; }
}
public interface IHasNormalizedContent
{
string? NormalizedContent { get; set; }
}
public interface IHasLastNormalized : IHasNormalizedContent, IHasLastModified
{
DateTime? LastNormalized { get; set; }
}
public interface ISortable
{
int SortOrder { get; set; }
}
Timestamps are UTC: the default primers write DateTime.UtcNow and normalize client-supplied values.
using Regira.Entities.Models.Abstractions;
public interface IHasCreated
{
DateTime Created { get; set; } // UTC
}
public interface IHasLastModified
{
DateTime? LastModified { get; set; } // UTC
}
// Combined — most common choice
public interface IHasTimestamps : IHasCreated, IHasLastModified;
// Optimistic concurrency: UseDefaults() declares ConcurrencyToken a concurrency token and mints a new one on every
// write (HasConcurrencyTokenDbPrimer). Guid.Empty from a client means "not sent" — that write is not checked.
public interface IHasConcurrencyToken
{
Guid ConcurrencyToken { get; set; }
}
using Regira.Entities.Models.Abstractions;
public interface IArchivable
{
bool IsArchived { get; set; }
}
using Regira.Entities.Models.Abstractions;
// ⚠️ members are nullable — implementing them with non-nullable DateTime fails with CS0738.
// UTC instants by default; prefer a DateOnly property of your own for pure calendar semantics.
public interface IHasStartDate { DateTime? StartDate { get; set; } }
public interface IHasEndDate { DateTime? EndDate { get; set; } }
public interface IHasStartEndDate : IHasStartDate, IHasEndDate;
public interface IHasSlug { string? Slug { get; set; } }
public interface IHasUri { string? Uri { get; set; } }
using Regira.Entities.Attachments.Abstractions;
public interface IHasAttachments
{
ICollection<IEntityAttachment>? Attachments { get; set; }
bool? HasAttachment { get; set; } // ⚠️ yours to set on the read path (a processor or a mapped projection) — nothing populates it, so it serializes null even for a row that has attachments. `FilterHasAttachment(so.HasAttachment)` does not read it either: it filters on Attachments.Any(). To show a "has documents" flag without loading the collection, set it; otherwise drop it from the DTO.
}
// Typed (int keys) — most common
public interface IHasAttachments<TEntityAttachment>
: IHasAttachments<TEntityAttachment, int, int, int, Attachment>
where TEntityAttachment : IEntityAttachment<int, int, int, Attachment>;
public interface IHasAttachments<TEntityAttachment, TKey, TObjectKey, TAttachmentKey, TAttachment>
where TEntityAttachment : IEntityAttachment<TKey, TObjectKey, TAttachmentKey, TAttachment>
where TAttachment : class, IAttachment<TAttachmentKey>, new()
{
ICollection<TEntityAttachment>? Attachments { get; set; }
bool? HasAttachment { get; set; } // ⚠️ yours to set on the read path (a processor or a mapped projection) — nothing populates it, so it serializes null even for a row that has attachments. `FilterHasAttachment(so.HasAttachment)` does not read it either: it filters on Attachments.Any(). To show a "has documents" flag without loading the collection, set it; otherwise drop it from the DTO.
}
public interface IHasObjectId<TKey>
{
TKey ObjectId { get; set; }
}
using Regira.Entities.Services.Abstractions;
public interface IEntityReadService<TEntity, in TKey>
{
Task<TEntity?> Details(TKey id, CancellationToken token = default);
// Explicit archived scope; null falls back to EntityQueryOptions.DefaultArchivedFilter.
// Only the built-in IArchivable filter reads it — tenant/owner row security still applies.
// ⚠️ Default interface member (falls back to Details(id, token)) — a custom read service must
// OVERRIDE BOTH overloads, or the archived-inclusive write path cannot see archived rows and
// restore silently 404s while everything still compiles.
Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default);
// ⚠️ first parameter is object? — a positionally-passed CancellationToken binds as the
// search object and silently filters nothing; call List(null, null, token)
Task<IList<TEntity>> List(object? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
Task<long> Count(object? so, CancellationToken token = default);
}
public interface IEntityReadService<TEntity, in TKey, in TSearchObject>
: IEntityReadService<TEntity, TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
{
Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
}
public interface IEntityReadService<TEntity, in TKey, TSearchObject, TSortBy, TIncludes>
: IEntityReadService<TEntity, TKey, TSearchObject>
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
{
Task<IList<TEntity>> List(
IList<TSearchObject?> so,
IList<TSortBy> sortBy,
TIncludes? includes = null,
PagingInfo? pagingInfo = null,
CancellationToken token = default);
Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default);
}
using Regira.Entities.Services.Abstractions;
public interface IEntityWriteService<TEntity, TKey>
where TEntity : class, IEntity<TKey>
{
Task Add(TEntity item, CancellationToken token = default);
Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
Task Save(TEntity item, CancellationToken token = default); // Upsert: Add or Modify
Task Remove(TEntity item, CancellationToken token = default);
Task<int> SaveChanges(CancellationToken token = default);
}
The default implementation, and the base of a custom write service:
using Regira.Entities.EFcore.Services;
public class EntityWriteService<TContext, TEntity, TKey>(
TContext dbContext,
IEntityReadService<TEntity, TKey> readService,
IEnumerable<IEntityPrepper> preppers,
IEnumerable<IEntityValidator> validators,
ILoggerFactory? loggerFactory = null) : IEntityWriteService<TEntity, TKey>
where TContext : DbContext
where TEntity : class, IEntity<TKey>
{
// runs no validators — a subclass passes IEnumerable<IEntityValidator> to the constructor above
public EntityWriteService(TContext dbContext, IEntityReadService<TEntity, TKey> readService,
IEnumerable<IEntityPrepper> preppers, ILoggerFactory? loggerFactory = null);
// Add / Modify: PrepareItem, then ValidateItem, then the entity is tracked
public virtual Task PrepareItem(TEntity item, TEntity? original, CancellationToken token = default);
// not virtual: validators are the one way to refuse a write; their queries on this context track nothing
public Task ValidateItem(TEntity item, TEntity? original, EntityWriteOperation operation,
CancellationToken token = default);
// Remove: ValidateItem with the stored row, then RemoveItem — mark extra rows for a delete here, once the validators passed
protected virtual Task RemoveItem(TEntity item, CancellationToken token = default);
}
// int-keyed: EntityWriteService<TContext, TEntity>, with the same two constructors
using Regira.Entities.Services.Abstractions;
// Full-featured (TKey explicit)
public interface IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>
: IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>,
IEntityService<TEntity, TKey, TSearchObject>
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
// Shortcut — int key assumed (most common for injection)
public interface IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>
: IEntityService<TEntity, int, TSearchObject, TSortBy, TIncludes>,
IEntityService<TEntity>
where TEntity : class, IEntity<int>
where TSearchObject : class, ISearchObject<int>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
Inject as
IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>(int key shortcut) when registered with.For<TEntity, TSearchObject, TSortBy, TIncludes>().
Custom services with HasRepository<>() or HasManager<>() per entity, or app-wide with
UseEntities(o => o.UseRepository(...)) (see entities.instructions.md → Replacing the repository app-wide).
using Regira.Entities.Services.Abstractions;
// Primary shortcut forms (int key, full-featured)
public interface IEntityRepository<TEntity, TSearchObject, TSortBy, TIncludes>
: IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>, IEntityRepository<TEntity>;
public interface IEntityManager<TEntity, TSearchObject, TSortBy, TIncludes>
: IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>, IEntityManager<TEntity>;
// Additional TKey and partial variants follow the same pattern as IEntityService.
Inject the inner service via constructor; override only the methods you need.
using Regira.Entities.Services.Abstractions;
// Without sort/includes — exposes Service field
public abstract class EntityWrappingServiceBase<TEntity, TKey, TSearchObject>(
IEntityService<TEntity, TKey, TSearchObject> service) : IEntityService<TEntity, TKey, TSearchObject>
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
{
protected readonly IEntityService<TEntity, TKey, TSearchObject> Service = service;
// All members are virtual:
public virtual Task<TEntity?> Details(TKey id, CancellationToken token = default);
public virtual Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default); // an archived-explicit read goes straight to the inner service: override both
public virtual Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
public virtual Task<IList<TEntity>> List(object? so, PagingInfo? pagingInfo, CancellationToken token = default);
public virtual Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
public virtual Task<long> Count(object? so, CancellationToken token = default);
public virtual Task Add(TEntity item, CancellationToken token = default);
public virtual Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
public virtual Task Save(TEntity item, CancellationToken token = default);
public virtual Task Remove(TEntity item, CancellationToken token = default);
public virtual Task<int> SaveChanges(CancellationToken token = default);
}
// Full-featured (int key) — most common; empty body, delegates to explicit-TKey variant
public abstract class EntityWrappingServiceBase<TEntity, TSearchObject, TSortBy, TIncludes>(
IEntityService<TEntity, int, TSearchObject, TSortBy, TIncludes> service)
: EntityWrappingServiceBase<TEntity, int, TSearchObject, TSortBy, TIncludes>(service)
where TEntity : class, IEntity<int>
where TSearchObject : class, ISearchObject<int>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
// Full-featured (explicit TKey)
public abstract class EntityWrappingServiceBase<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes> service)
: IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
{
// Virtual — override as needed:
public virtual Task<TEntity?> Details(TKey id, CancellationToken token = default);
public virtual Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default); // an archived-explicit read goes straight to the inner service: override both
public virtual Task<IList<TEntity>> List(object? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
public virtual Task<IList<TEntity>> List(IList<TSearchObject?> so, IList<TSortBy> sortBy, TIncludes? includes = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
public virtual Task<long> Count(object? so, CancellationToken token = default);
public virtual Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default);
public virtual Task Add(TEntity item, CancellationToken token = default);
public virtual Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
public virtual Task Save(TEntity item, CancellationToken token = default);
public virtual Task Remove(TEntity item, CancellationToken token = default);
public virtual Task<int> SaveChanges(CancellationToken token = default);
public virtual TSearchObject? Convert(object? so);
// Not virtual — they call the inner service directly, and overriding them fails with CS0506:
public Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
public Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
}
Register the wrapper with
e.UseEntityService<MyService>()(⚠️ Beware for circular dependency!). You can register implementations for custom interface derived fromIEntityServicewithe.AddTransient<IMyService, MyService>().
The generic type arguments on the controller must exactly match those used in .For<>(). The controller adds TDto and TInputDto on top.
.For<>() registration |
Required controller base |
|---|---|
.For<TEntity>() |
EntityControllerBase<TEntity, TDto, TInputDto> |
.For<TEntity, TKey>() |
EntityControllerBase<TEntity, TKey, SearchObject<TKey>, TDto, TInputDto> |
.For<TEntity, TKey, TSearchObject>() |
EntityControllerBase<TEntity, TKey, TSearchObject, TDto, TInputDto> |
.For<TEntity, TSearchObject, TSortBy, TIncludes>() |
EntityControllerBase<TEntity, TSearchObject, TSortBy, TIncludes, TDto, TInputDto> |
.For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() |
EntityControllerBase<TEntity, TKey, TSearchObject, TSortBy, TIncludes, TDto, TInputDto> |
using Regira.Entities.Web.Controllers.Abstractions;
// Minimal — no sorting or includes
[ApiController]
public abstract class EntityControllerBase<TEntity, TKey, TSearchObject, TDto, TInputDto>
: ControllerBase
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>
where TDto : class
where TInputDto : class;
// Full-featured — with sorting and includes (int key shortcut)
[ApiController]
public abstract class EntityControllerBase<TEntity, TSo, TSortBy, TIncludes, TDto, TInputDto>
: EntityControllerBase<TEntity, int, TSo, TSortBy, TIncludes, TDto, TInputDto>
where TEntity : class, IEntity<int>
where TSo : class, ISearchObject<int>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
where TDto : class
where TInputDto : class;
// Full-featured — with explicit TKey
[ApiController]
public abstract class EntityControllerBase<TEntity, TKey, TSo, TSortBy, TIncludes, TDto, TInputDto>
: ControllerBase
where TEntity : class, IEntity<TKey>
where TSo : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
where TDto : class
where TInputDto : class;
The generated actions are virtual — override one to add an [Authorize] policy (administrators manage
reference data) without re-implementing it. Signatures are identical on both bases; the complex one adds the
[FromBody] TSo[] overloads of List/Search:
public virtual Task<ActionResult<DetailsResult<TDto>>> Details([FromRoute] TKey id, [FromQuery] ArchivedFilter? archived = null);
public virtual Task<ActionResult<ListResult<TDto>>> List([FromQuery] TSearchObject so, [FromQuery] PagingInfo pagingInfo);
public virtual Task<ActionResult<SearchResult<TDto>>> Search([FromQuery] TSearchObject so, [FromQuery] PagingInfo pagingInfo);
public virtual Task<ActionResult<SaveResult<TDto>>> Save([FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>> Create([FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>> Modify([FromRoute] TKey id, [FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>> Patch([FromRoute] TKey id); // reads the body itself
public virtual Task<ActionResult<DeleteResult<TDto>>?> Delete([FromRoute] TKey id);
The verb attribute is inherited by the override, so the route survives without it: declare only the
[Authorize] and delegate — return base.Create(model);.
Endpoints exposed by controller bases:
| Method | Route | Action | Availability |
|---|---|---|---|
GET |
/{id} |
Details | All |
GET |
/ |
List | All |
POST |
/list |
List (body) | Complex only |
GET |
/search |
Search (with count) | All |
POST |
/search |
Search (body) | Complex only |
POST |
/save |
Save (upsert) | All |
POST |
/ |
Create | All |
PUT |
/{id} |
Modify (full update) | All |
PATCH |
/{id} |
Patch (partial update, JSON Merge Patch) | All |
DELETE |
/{id} |
Delete | All |
Complex only = bases with
TSortBy+TIncludes. Simple bases omitPOST /listandPOST /search(the body/array variants), but do exposeGET /search(single search object, with count for paging) alongside basic list viaGET /?q=…. For response envelope shapes (item/items,count) seeentities.instructions.md§Step 13.
A write a validator refuses — save, create, modify, patch or delete — answers 400 with a ValidationProblemDetails.
using Regira.Entities.Models.Abstractions;
using Regira.Entities.Models;
public interface ISearchObject
{
string? Q { get; set; }
DateTime? MinCreated { get; set; }
DateTime? MaxCreated { get; set; }
DateTime? MinLastModified { get; set; }
DateTime? MaxLastModified { get; set; }
// Bound from ?archived=. null → EntityQueryOptions.DefaultArchivedFilter (Excluded).
ArchivedFilter? Archived { get; set; }
}
public interface ISearchObject<TKey> : ISearchObject
{
TKey? Id { get; set; }
ICollection<TKey>? Ids { get; set; }
ICollection<TKey>? Exclude { get; set; }
}
// Default implementation (int key) — extend this for custom filters
public record SearchObject : SearchObject<int>;
public record SearchObject<TKey> : ISearchObject<TKey>
{
public TKey? Id { get; set; }
public ICollection<TKey>? Ids { get; set; }
public ICollection<TKey>? Exclude { get; set; }
public string? Q { get; set; }
public DateTime? MinCreated { get; set; }
public DateTime? MaxCreated { get; set; }
public DateTime? MinLastModified { get; set; }
public DateTime? MaxLastModified { get; set; }
public ArchivedFilter? Archived { get; set; }
}
using Regira.Entities.Extensions;
public static class EntityExtensions
{
public static bool IsNew<TKey>(this IEntity<TKey> item);
public static void AdjustIdForEfCore(this IEnumerable<IEntity<int>> items);
public static void SetSortOrder(this IEnumerable<ISortable> items);
}
using Regira.Entities.EFcore.Extensions;
public static class ModelBuilderExtensions
{
public const string ArchivedQueryFilterName = "Regira:Archived";
// Applies e => !e.IsArchived as a NAMED filter to every IArchivable root entity type (a hierarchy is
// filtered through its root, an owned type through its owner) — the only thing that hides archived rows,
// Include(...) included. Excluded on the search object composes nothing.
// OPTIONAL: UseEntities<TContext>(e => e.UseDefaults()) installs the same filter through the context's
// options (DbContextWiring.ArchivedQueryFilter). Call this only when the context is built outside that
// wiring, or in place of it. Then it goes AFTER your own HasQueryFilter(...) calls, exactly once;
// calling it alongside the wiring is safe. Startup validation raises an ERROR when a model ends up with
// no archived filter. (net8.0: no-op — see entities.patterns → Soft Delete.)
public static void SetArchivedQueryFilter(this ModelBuilder modelBuilder);
}
public static class DbContextOptionsBuilderExtensions
{
// The AddDbContext counterpart of SetArchivedQueryFilter: same named filter, applied at model
// finalization (so after everything OnModelCreating configured). Auto-added by UseDefaults() —
// call it yourself for a DbContext constructed outside DI (tests, design-time factory, seeding tool),
// which no service-collection wiring can reach. (net8.0: no filter is installed.)
public static DbContextOptionsBuilder AddArchivedQueryFilter(this DbContextOptionsBuilder optionsBuilder);
public static DbContextOptionsBuilder<TContext> AddArchivedQueryFilter<TContext>(
this DbContextOptionsBuilder<TContext> optionsBuilder) where TContext : DbContext;
// Declares IHasConcurrencyToken.ConcurrencyToken a concurrency token on every implementing entity type, at
// model finalization (an explicit .IsConcurrencyToken(false) still wins). Auto-added by UseDefaults() — call
// it yourself for a DbContext constructed outside DI.
public static DbContextOptionsBuilder AddConcurrencyTokenConvention(this DbContextOptionsBuilder optionsBuilder);
public static DbContextOptionsBuilder<TContext> AddConcurrencyTokenConvention<TContext>(
this DbContextOptionsBuilder<TContext> optionsBuilder) where TContext : DbContext;
}
using Regira.Entities.EFcore.Extensions;
public static class DeleteCycleExtensions
{
// Two rows deleted together that reference each other — an entity carrying a foreign key to one of its
// own children — are a save EF Core refuses with "a circular dependency was detected in the data to be
// saved". Dropping the reference needs an UPDATE before the DELETEs, so it cannot happen inside one
// SaveChanges: call these FROM the DbContext's own overrides, BOTH of them, passing base.SaveChanges as
// the delegate. A save with no such pair calls the delegate exactly once and opens no transaction; the
// change tracker is read only when the model has two entity types referencing each other and the
// provider is relational (the in-memory provider orders no deletes and is passed straight through).
// Give acceptAllChangesOnSuccess to the EXTENSION and let the delegate take it as a parameter: the
// reference is dropped with a direct UPDATE and the delegate then runs exactly once with that flag, so
// nothing is accepted before the save returns and a failed save leaves every change pending for the
// retry. A transaction the caller owns (or a TransactionScope) is joined rather than nested, so the
// pattern holds under EnableRetryOnFailure().
public static int SaveChangesBreakingDeleteCycles(this DbContext dbContext, Func<bool, int> save,
bool acceptAllChangesOnSuccess = true);
public static Task<int> SaveChangesBreakingDeleteCyclesAsync(this DbContext dbContext,
Func<bool, CancellationToken, Task<int>> save, bool acceptAllChangesOnSuccess = true,
CancellationToken token = default);
}
Direct pairs only; a longer ring (
A → B → C → A) is left to EF’s own exception. Startup validation warns about the shape at registration time. Full recipe:entities.patterns→ An entity that references one of its own children.
Every method
using Regira.Entities.EFcore.Extensions;
public static class QueryExtensions
{
// Requires IEntity<TKey>
public static IQueryable<TEntity> FilterId<TEntity, TKey>(this IQueryable<TEntity> query, TKey? id);
public static IQueryable<TEntity> FilterIds<TEntity, TKey>(this IQueryable<TEntity> query, ICollection<TKey>? ids);
public static IQueryable<TEntity> FilterExclude<TEntity, TKey>(this IQueryable<TEntity> query, ICollection<TKey>? ids);
// Requires IHasStartEndDate — rows whose period contains `date`; a null bound is treated as open.
// The only built-in helper for the date interfaces, and you call it yourself (no global filter).
public static IQueryable<TEntity> FilterIsActiveOn<TEntity>(this IQueryable<TEntity> query, DateTime? date);
// Requires IHasCode
public static IQueryable<TEntity> FilterCode<TEntity>(this IQueryable<TEntity> query, string? code);
// Requires IHasTitle
public static IQueryable<TEntity> FilterTitle<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);
// Requires IHasNormalizedTitle
public static IQueryable<TEntity> FilterNormalizedTitle<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);
// Requires IHasNormalizedContent
public static IQueryable<TEntity> FilterQ<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);
// No constraint — keyword search over explicit fields (each keyword must match at least one).
// Matches the RAW family (TrimmedQW): the fields you name are ordinary columns holding the client's
// value verbatim. Do not pass a normalized column here — build that predicate with QW yourself.
public static IQueryable<TEntity> FilterQ<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords,
Expression<Func<TEntity, string?>> field, params Expression<Func<TEntity, string?>>[] moreFields);
// Requires IHasCreated — input dates are normalized to UTC (local kinds converted, unspecified assumed UTC)
public static IQueryable<TEntity> FilterCreated<TEntity>(this IQueryable<TEntity> query, DateTime? minDate, DateTime? maxDate);
// Requires IHasLastModified — input dates are normalized to UTC
public static IQueryable<TEntity> FilterLastModified<TEntity>(this IQueryable<TEntity> query, DateTime? minDate, DateTime? maxDate);
// Requires IHasTimestamps — input dates are normalized to UTC
public static IQueryable<TEntity> FilterTimestamps<TEntity>(this IQueryable<TEntity> query,
DateTime? minCreated, DateTime? maxCreated, DateTime? minModified, DateTime? maxModified);
// Requires class + IArchivable. net10.0: Excluded composes nothing (the named archived EF query filter
// already hides archived rows); Included/Only suspend that ONE filter by name, never yours. net8.0: no
// query filter exists, so Excluded composes the predicate itself and nothing is ever suspended.
public static IQueryable<TEntity> FilterArchivable<TEntity>(this IQueryable<TEntity> query, ArchivedFilter archived);
// Requires IHasAttachments
public static IQueryable<TEntity> FilterHasAttachment<TEntity>(this IQueryable<TEntity> query, bool? hasAttachment);
public static IQueryable<TEntity> SortQuery<TEntity, TKey>(this IQueryable<TEntity> query)
where TEntity : IEntity<TKey>;
// Start the ordering, or continue it with ThenBy when one is already applied. ALWAYS use these in
// SortBy builders — a hand-rolled `is IOrderedQueryable<T>` check throws at request time on EF Core.
public static IOrderedQueryable<TEntity> OrderOrThenBy<TEntity, TKey>(this IQueryable<TEntity> query, Expression<Func<TEntity, TKey>> keySelector);
public static IOrderedQueryable<TEntity> OrderOrThenByDescending<TEntity, TKey>(this IQueryable<TEntity> query, Expression<Func<TEntity, TKey>> keySelector);
}
How it decides: it reads query.Expression.Type — whether the composed expression tree already reports
an ordering — not the runtime type. That distinction is the whole point: an EF Core query object satisfies
is IOrderedQueryable<T> before any ordering exists, which is why the hand-rolled check throws.
Chaining is therefore supported, and is how you write a multi-key sort. Inside one SortBy arm,
query.OrderOrThenBy(x => x.LastName).OrderOrThenBy(x => x.FirstName) composes OrderBy(LastName)
.ThenBy(FirstName): the first call returns an IOrderedQueryable<T>, so the second sees an ordered
expression tree and continues it. No need to split a composite sort across several enum members.
For<>() overload → builder → available methodsEach For<>() overload yields a different builder type, which determines the SortBy lambda arity
and whether typed Includes is available. Match the controller base and any manual
IEntityService<> resolution to the same generics (see entities.instructions.md §Step 13).
For<>() overload |
Builder type | Tier | SortBy lambda |
Typed Includes |
Process / Related |
|---|---|---|---|---|---|
For<TEntity>() |
EntityIntServiceBuilder |
Simple | query => … (1-arg) |
— | Process ✓ · Related<TRelated> ✓ |
For<TEntity, TKey>() |
EntityServiceBuilder |
Simple | query => … (1-arg) |
— | Process ✓ · Related<TRelated, TRelatedKey> (2-arg for non-int key) |
For<TEntity, TKey, TSearchObject>() |
EntitySearchObjectServiceBuilder |
Simple | query => … (1-arg) |
— | Process ✓ · Related ✓ |
For<TEntity, TSearchObject, TSortBy, TIncludes>() |
ComplexEntityIntServiceBuilder |
Complex | (query, sortBy) => … (2-arg) |
✓ | Process ✓ · Related<TRelated> ✓ |
For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() |
ComplexEntityServiceBuilder |
Complex | (query, sortBy) => … (2-arg) |
✓ | Process ✓ · Related ✓ |
Using the 2-arg
SortBy((query, sortBy) => …)on a simple builder is CS1593 — simple builders have noTSortBy. The typede.Includes((query, includes) => …)overload (keyed to a[Flags]TIncludes) exists only on the two complex builders — but every builder, simple ones included, inherits the untypede.Includes((query, EntityIncludes?) => query.Include(...))overload (the “TypedIncludes” column below tracks only the typed form), so simple registrations can still eager-load navigations. The single-arge.Related<TRelated>(…)shortcut works on every int-key builder (incl. the simpleFor<TEntity, int, TSearchObject>()), and on each of them a lambda in second position may be either the parentprepareFuncor theRelatedEntityBuildercallback —e.Related(x => x.Lines, r => r.ServerOwned(x => x.UnitPrice)). A non-int related key needs the 2-arge.Related<TRelated, TRelatedKey>(…), where the second position is alwaysprepareFunc: name the callback,configure: r => ….
HasAttachmentsis an extension on the baseEntityServiceBuilder(Regira.Entities.DependencyInjection.Attachments), so it applies on every tier — a complex owner chains.HasAttachments(...)exactly like a simple one.WithAttachmentsis an instance method on theEntityServiceCollection<TContext>thatUseEntitiesreturns (registered once for the sharedAttachment, independent of any owner’s tier). (§Attachments)
using Regira.Entities.DependencyInjection.Extensions;
public static EntityServiceCollection<TContext> UseEntities<TContext>(
this IServiceCollection services,
Action<EntityServiceCollectionOptions>? configure = null)
where TContext : DbContext;
using Regira.Entities.DependencyInjection.Extensions;
// Registers in one call: paging defaults (DefaultPageSize=10, MaxPageSize=100), default primers
// (AddDefaultPrimers()), the AutoServerOwnedPrepper, default global filters (AddDefaultGlobalQueryFilters()),
// and the default entity normalizer. Also calls AddDefaultInterceptors() (= WireDbContext(DbContextWiring.All)): UseEntities<TContext>()
// then wires the primer/normalizer/auto-truncate/reactor interceptors + UTC date convention into the DbContext options
// automatically (AddDbContext only needs the provider; assignability match — an abstract-base registration
// also wires derived provider-specific contexts, in any registration order). UTC date handling itself is on
// by default process-wide (DateTimeDefaults.UseUtc) — disable with UseUtc(false).
// The normalized-content Q filter (FilterHasNormalizedContentQueryBuilder)
// is added ONLY on the parameterless overload — UseDefaults(cfg => …) skips it, so register it yourself then.
public static EntityServiceCollectionOptions UseDefaults(
this EntityServiceCollectionOptions options,
Action<EntityDefaultNormalizingOptions>? configure = null);
public static EntityServiceCollectionOptions UseNormalizerDefaults(
this EntityServiceCollectionOptions options,
Action<EntityDefaultNormalizingOptions>? configure = null);
// package: Regira.Entities.Mapping.Mapster
using Regira.Entities.Mapping.Mapster;
public static EntityServiceCollectionOptions UseMapsterMapping(
this EntityServiceCollectionOptions options,
Action<TypeAdapterConfig>? configure = null);
// package: Regira.Entities.Mapping.AutoMapper
using Regira.Entities.Mapping.AutoMapper;
public static EntityServiceCollectionOptions UseAutoMapper(
this EntityServiceCollectionOptions options,
Action<IServiceProvider, IMapperConfigurationExpression>? configure = null);
using Regira.Entities.DependencyInjection.Mapping;
public static EntityServiceCollectionOptions AddAfterMapper<TAfterMapper>(
this EntityServiceCollectionOptions options)
where TAfterMapper : class, IEntityAfterMapper;
public static EntityServiceCollectionOptions AfterMap<TSource, TTarget>(
this EntityServiceCollectionOptions options,
Action<TSource, TTarget> afterMapAction);
public static EntityServiceCollectionOptions AfterMap<TSource, TTarget>(
this EntityServiceCollectionOptions options,
Func<IServiceProvider, Action<TSource, TTarget>> afterMapAction);
using Regira.Entities.DependencyInjection.Preppers;
public static EntityServiceCollectionOptions AddPrepper<TImplementation>(
this EntityServiceCollectionOptions options)
where TImplementation : class, IEntityPrepper;
public static EntityServiceCollectionOptions AddPrepper<TEntity>(
this EntityServiceCollectionOptions options,
Action<TEntity> prepareFunc)
where TEntity : class;
public static EntityServiceCollectionOptions AddPrepper<TContext, TEntity, TKey>(
this EntityServiceCollectionOptions options,
Func<TEntity, TContext, Task> prepareFunc)
where TContext : DbContext
where TEntity : class, IEntity<TKey>;
using Regira.Entities.DependencyInjection.Validators;
// A validator class for every entity its IEntityValidator<TScope> covers — an interface or base class reaches every
// entity implementing it; one implementing only IEntityValidator checks every entity. A class registered twice runs once.
public static EntityServiceCollectionOptions AddValidator<TValidator>(
this EntityServiceCollectionOptions options)
where TValidator : class, IEntityValidator;
public static EntityServiceCollectionOptions AddValidator<TScope>(
this EntityServiceCollectionOptions options,
Action<IEntityValidatorContext<TScope>> validate)
where TScope : class;
// async ctx => … binds here, and the write awaits it
public static EntityServiceCollectionOptions AddValidator<TScope>(
this EntityServiceCollectionOptions options,
Func<IEntityValidatorContext<TScope>, Task> validate)
where TScope : class;
// receives the request's DbContext (unfiltered) and the write's cancellation token for its queries
public static EntityServiceCollectionOptions AddValidator<TContext, TScope>(
this EntityServiceCollectionOptions options,
Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate)
where TContext : DbContext
where TScope : class;
// IServiceCollection forms — the same scope rule
public static IServiceCollection AddValidator<TValidator>(this IServiceCollection services)
where TValidator : class, IEntityValidator;
public static IServiceCollection AddValidator<TScope>(this IServiceCollection services,
Action<IEntityValidatorContext<TScope>> validate) where TScope : class;
public static IServiceCollection AddValidator<TScope>(this IServiceCollection services,
Func<IEntityValidatorContext<TScope>, Task> validate) where TScope : class;
public static IServiceCollection AddValidator<TContext, TScope>(this IServiceCollection services,
Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate) where TContext : DbContext where TScope : class;
using Regira.Entities.DependencyInjection.Primers;
public static EntityServiceCollectionOptions AddPrimer<TPrimer>(
this EntityServiceCollectionOptions options)
where TPrimer : class, IEntityPrimer;
// Registers ArchivablePrimer + HasCreatedDbPrimer + HasLastModifiedDbPrimer + HasConcurrencyTokenDbPrimer
public static EntityServiceCollectionOptions AddDefaultPrimers(
this EntityServiceCollectionOptions options);
using Regira.Entities.DependencyInjection.Reactors;
// Reacts to every entity its IEntityReactor<T> covers (an interface or base type reaches every implementing
// entity); a reactor implementing only IEntityReactor reacts to every entity.
public static EntityServiceCollectionOptions AddReactor<TReactor>(
this EntityServiceCollectionOptions options)
where TReactor : class, IEntityReactor;
// IServiceCollection forms
public static IServiceCollection AddReactor<TReactor>(this IServiceCollection services)
where TReactor : class, IEntityReactor;
public static IServiceCollection AddReactor<TEntity, TReactor>(this IServiceCollection services)
where TReactor : class, IEntityReactor<TEntity>; // reacts to TEntity only
public static IServiceCollection AddReactor<TEntity>(this IServiceCollection services,
Func<IServiceProvider, IEntityReactor<TEntity>> factory); // factory gets the reaction's own scope
using Regira.Entities.DependencyInjection.QueryBuilders;
public static EntityServiceCollectionOptions AddGlobalFilterQueryBuilder<TImplementation>(
this EntityServiceCollectionOptions options)
where TImplementation : class, IGlobalFilteredQueryBuilder;
// Registers FilterIdsQueryBuilder + FilterArchivablesQueryBuilder
// + FilterHasCreatedQueryBuilder + FilterHasLastModifiedQueryBuilder
public static EntityServiceCollectionOptions AddDefaultGlobalQueryFilters(
this EntityServiceCollectionOptions options);
// Removes every global filter registered before it, UseDefaults()' among them — the archived filter too.
// On IServiceCollection, so it chains off the UseEntities<TContext>() builder; not on the options.
public static TServiceCollection RemoveGlobalQueryFilters<TServiceCollection>(this TServiceCollection services)
where TServiceCollection : IServiceCollection;
using Regira.Entities.DependencyInjection.Normalizers;
public static EntityServiceCollectionOptions AddNormalizer<TNormalizer>(
this EntityServiceCollectionOptions options)
where TNormalizer : class, IEntityNormalizer;
public static EntityServiceCollectionOptions AddNormalizer<TEntity, TNormalizer>(
this EntityServiceCollectionOptions options)
where TNormalizer : class, IEntityNormalizer<TEntity>;
// Registers DefaultNormalizer + ObjectNormalizer + DefaultEntityNormalizer + QKeywordHelper
public static EntityServiceCollectionOptions AddDefaultEntityNormalizer(
this EntityServiceCollectionOptions options,
Action<NormalizeOptions>? configure = null);
using Regira.Entities.DependencyInjection.ServiceCollections;
using Regira.Entities.DependencyInjection.Attachments; // HasAttachments, the extension below
public class EntityServiceCollection<TContext>
where TContext : DbContext
{
// Simple entity (int key, default SearchObject)
EntityServiceCollection<TContext> For<TEntity>(
Action<EntityIntServiceBuilder<TContext, TEntity>>? configure = null)
where TEntity : class, IEntity<int>;
// Entity with custom TKey
EntityServiceCollection<TContext> For<TEntity, TKey>(
Action<EntityServiceBuilder<TContext, TEntity, TKey>>? configure = null)
where TEntity : class, IEntity<TKey>;
// Entity with custom TKey + SearchObject
EntityServiceCollection<TContext> For<TEntity, TKey, TSearchObject>(
Action<EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>>? configure = null)
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new();
// Full-featured (int key) — most common
EntityServiceCollection<TContext> For<TEntity, TSearchObject, TSortBy, TIncludes>(
Action<ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>>? configure = null)
where TEntity : class, IEntity<int>
where TSearchObject : class, ISearchObject<int>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
// Full-featured (with TKey)
EntityServiceCollection<TContext> For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
Action<ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>>? configure = null)
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
// Attachments — register the shared Attachment entity + file store + bytes→file primer.
// Framework infrastructure: the shared Attachment base is registered once and reused by every owner.
EntityServiceCollection<TContext> WithAttachments(
Func<IServiceProvider, IFileService> factory,
Action<EntitySearchObjectServiceBuilder<TContext, Attachment, int, AttachmentSearchObject<int>>>? configure = null);
// Generic service helpers
EntityServiceCollection<TContext> AddTransient<TService, TImplementation>()
where TService : class
where TImplementation : class, TService;
EntityServiceCollection<TContext> AddTransient<TService>(
Func<IServiceProvider, TService> factory)
where TService : class;
}
// Typed per-owner attachment registration — extension on the For<>() builder
// (Regira.Entities.DependencyInjection.Attachments.EntityServiceBuilderExtensions).
// Registers the typed read/write services, the link prepper, RelatedAttachments and DTO mapping,
// plus a per-owner join entity (e.g. ProductAttachment). For slot cost see §License requirement.
// Usage: services.For<Product>(e => e.HasAttachments<AppDbContext, Product, ProductAttachment>(x => x.Attachments));
public static class EntityServiceBuilderExtensions
{
public static EntityAttachmentServiceBuilder<TContext, TEntity, int, TEntityAttachment, int, EntityAttachmentSearchObject, int, Attachment>
HasAttachments<TContext, TEntity, TEntityAttachment>(
this EntityServiceBuilder<TContext, TEntity, int> builder,
Expression<Func<TEntity, ICollection<TEntityAttachment>?>> navigationExpression,
Action<EntityAttachmentServiceBuilder<TContext, TEntity, int, TEntityAttachment, int, EntityAttachmentSearchObject, int, Attachment>>? configure = null)
where TContext : DbContext
where TEntity : class, IEntity<int>, IHasAttachments<TEntityAttachment>
where TEntityAttachment : class, IEntityAttachment<int, int, int, Attachment>, new();
}
Base builder. Derives from EntityServiceCollection<TContext> (above), so inside a .For<>(e => …) lambda e also offers that type’s generic AddTransient(...) registration helpers — e.g. e.AddTransient<IOrderService, OrderManager>() — alongside the builder methods below.
using Regira.Entities.DependencyInjection.ServiceBuilders;
public partial class EntityServiceBuilder<TContext, TEntity, TKey> : EntityServiceCollection<TContext>
where TContext : DbContext
where TEntity : class, IEntity<TKey>
{
bool HasEntityService();
bool HasService<TService>();
// Elevate to typed search object builder
EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
WithSearchObject<TSearchObject>()
where TSearchObject : class, ISearchObject<TKey>, new();
// Mapping
MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto>
UseMapping<TDto, TInputDto>(Action<IEntityMapConfigurator>? mapAction = null);
// Escape hatch — register an explicit Mapster config for a single type pair.
// NOT required to project nested DTOs / Related() collections: Mapster maps similar models
// (incl. nested children) by convention. Add a pair only when convention gets it wrong —
// e.g. a child DTO whose shape diverges, or a child InputDto that needs a custom mapping.
// (See entities.instructions.md §Step 10. An empty nested collection is usually a missing
// Includes, not a missing mapping.)
EntityServiceBuilder<TContext, TEntity, TKey> AddMapping<TSource, TTarget>();
// Service registration
EntityServiceBuilder<TContext, TEntity, TKey> AddDefaultService();
// Registers IEntityService<TEntity,TKey> + IEntityService<TEntity,TKey,SearchObject<TKey>>.
// Note: does NOT register the bare IEntityService<TEntity> shortcut — that is only registered by
// For<TEntity>() and the complex int For<TEntity,TSearchObject,TSortBy,TIncludes>() overloads.
EntityServiceBuilder<TContext, TEntity, TKey> UseEntityService<TService>()
where TService : class, IEntityService<TEntity, TKey>, IEntityService<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> UseEntityService<TService>(
Func<IServiceProvider, TService> factory)
where TService : class, IEntityService<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> UseReadService<TService>()
where TService : class, IEntityReadService<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> UseWriteService<TService>()
where TService : class, IEntityWriteService<TEntity, TKey>;
EntityServiceBuilder<TContext, TEntity, TKey> HasRepository<TService>()
where TService : class, IEntityRepository<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> HasRepository<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IEntityRepository<TEntity, TKey>, IEntityRepository<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> HasManager<TService>()
where TService : class, IEntityManager<TEntity, TKey>, IEntityManager<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> HasManager<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IEntityManager<TEntity, TKey>, IEntityManager<TEntity, TKey, SearchObject<TKey>>;
// Query builders
EntityServiceBuilder<TContext, TEntity, TKey> AddDefaultQueryBuilder();
EntityServiceBuilder<TContext, TEntity, TKey> UseQueryBuilder<TImplementation>()
where TImplementation : class, IQueryBuilder<TEntity, TKey, SearchObject<TKey>, EntitySortBy, EntityIncludes>;
EntityServiceBuilder<TContext, TEntity, TKey> UseQueryBuilder<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IQueryBuilder<TEntity, TKey, SearchObject<TKey>, EntitySortBy, EntityIncludes>;
EntityServiceBuilder<TContext, TEntity, TKey> AddFilter<TImplementation>()
where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> AddFilter<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, SearchObject<TKey>>;
EntityServiceBuilder<TContext, TEntity, TKey> Filter(
Func<IQueryable<TEntity>, SearchObject<TKey>?, IQueryable<TEntity>> filterFunc);
// Sorting / includes (typed to EntitySortBy / EntityIncludes at this level)
EntityServiceBuilder<TContext, TEntity, TKey> SortBy(
Func<IQueryable<TEntity>, IQueryable<TEntity>> sortBy);
EntityServiceBuilder<TContext, TEntity, TKey> Includes(
Func<IQueryable<TEntity>, EntityIncludes?, IQueryable<TEntity>> addIncludes);
// Per-entity paging override (HTTP boundary). null = off; SetPageSize() opts out entirely.
EntityServiceBuilder<TContext, TEntity, TKey> SetPageSize(
int? defaultPageSize = null, int? maxPageSize = null);
// Per-entity read-path override (fully replaces the global EntityReadOptions for this entity):
// whether save endpoints re-fetch the entity via Details(id) for the response.
EntityServiceBuilder<TContext, TEntity, TKey> SetReadBehavior(
RefetchAfterSave refetchAfterSave = RefetchAfterSave.Always);
EntityServiceBuilder<TContext, TEntity, TKey> AddPrimer<TPrimer>()
where TPrimer : class, IEntityPrimer<TEntity>;
// Normalizers
EntityServiceBuilder<TContext, TEntity, TKey> AddNormalizer<TNormalizer>()
where TNormalizer : class, IEntityNormalizer<TEntity>;
// Processors
EntityServiceBuilder<TContext, TEntity, TKey> Process(
Func<IList<TEntity>, EntityIncludes?, Task> process);
EntityServiceBuilder<TContext, TEntity, TKey> Process(
Action<TEntity, EntityIncludes?> process);
EntityServiceBuilder<TContext, TEntity, TKey> AddProcessor<TProcessor>()
where TProcessor : class, IEntityProcessor<TEntity, EntityIncludes>;
// Preppers
// inline shortcuts:
EntityServiceBuilder<TContext, TEntity, TKey> Prepare(Action<TEntity> prepareFunc);
EntityServiceBuilder<TContext, TEntity, TKey> Prepare(
Func<TEntity, TContext, Task> prepareFunc);
// class-based:
EntityServiceBuilder<TContext, TEntity, TKey> AddPrepper<TPrepper>()
where TPrepper : class, IEntityPrepper<TEntity>;
// Validators — run after every prepper on Add/Modify/Save, and on Remove; one 400 for all their errors
// inline (each call is a validator of its own; the write awaits an async one):
EntityServiceBuilder<TContext, TEntity, TKey> Validate(Action<IEntityValidatorContext<TEntity>> validate);
// e.Validate(async ctx => …)
EntityServiceBuilder<TContext, TEntity, TKey> Validate(Func<IEntityValidatorContext<TEntity>, Task> validate);
// e.Validate(async (ctx, db, token) => …) with the request's DbContext — unfiltered: check a row-secured reference
// through IEntityReadService<,> in a class validator — and the write's cancellation token
EntityServiceBuilder<TContext, TEntity, TKey> Validate(
Func<IEntityValidatorContext<TEntity>, TContext, CancellationToken, Task> validate);
// class-based: a validator scoped to an interface or base class TEntity is in checks EVERY entity in that
// scope, not only this one (options.AddValidator<T>() is its place)
EntityServiceBuilder<TContext, TEntity, TKey> AddValidator<TValidator>()
where TValidator : class, IEntityValidator<TEntity>;
// Server-owned scalar/FK: restored from the stored row on update, minted on create when
// mintOnCreate is supplied and the property is unset. [ServerOwned] is the protect-only
// attribute form (no registration needed once UseDefaults() has run).
EntityServiceBuilder<TContext, TEntity, TKey> ServerOwned<TProp>(
Expression<Func<TEntity, TProp>> selector,
Func<TEntity, TProp>? mintOnCreate = null);
// Primers
// inline shortcuts:
EntityServiceBuilder<TContext, TEntity, TKey> Prime(Action<TEntity> primeFunc);
EntityServiceBuilder<TContext, TEntity, TKey> Prime(
Func<TEntity, EntityEntry, TContext, Task> primeFunc);
// class-based: AddPrimer<TPrimer>() (see above)
// Reactors — run once a save that changed a TEntity row is committed, in a DI scope of their own;
// reactFunc receives the change and that scope's IServiceProvider.
EntityServiceBuilder<TContext, TEntity, TKey> React(
Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);
EntityServiceBuilder<TContext, TEntity, TKey> React(
Func<IEntityChange<TEntity>, bool> canReact,
Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);
// when the save brought property to value (ChangedTo); an invalid selector throws ArgumentException here
EntityServiceBuilder<TContext, TEntity, TKey> React<TProp>(
Expression<Func<TEntity, TProp>> property, TProp value,
Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);
// reacts to TEntity only — also a reactor written against an interface (options.AddReactor<T>() for all it covers)
EntityServiceBuilder<TContext, TEntity, TKey> AddReactor<TReactor>()
where TReactor : class, IEntityReactor<TEntity>;
// Related child collections (managed by RelatedCollectionPrepper).
// prepareFunc = optional parent-level prepare; configure = optional RelatedEntityBuilder
// callback to nest sub-collections or add per-item prepare logic.
EntityServiceBuilder<TContext, TEntity, TKey> Related<TRelated, TRelatedKey>(
Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
Action<TEntity>? prepareFunc = null,
Action<RelatedEntityBuilder<TContext, TRelated, TRelatedKey>>? configure = null)
where TRelated : class, IEntity<TRelatedKey>;
void Build();
}
Passed into the configure callback of the Related(...) overload. Allows configuring nested sub-collections and per-item prepare logic for a related collection.
using Regira.Entities.DependencyInjection.ServiceBuilders;
public class RelatedEntityBuilder<TContext, TRelated, TRelatedKey>
where TContext : DbContext
where TRelated : class, IEntity<TRelatedKey>
{
// Nest a sub-collection (generic key)
RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Related<TSubRelated, TSubRelatedKey>(
Expression<Func<TRelated, ICollection<TSubRelated>?>> navigationExpression,
Action<RelatedEntityBuilder<TContext, TSubRelated, TSubRelatedKey>>? configure = null)
where TSubRelated : class, IEntity<TSubRelatedKey>;
// Int-key shortcut for sub-collections
RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Related<TSubRelated>(
Expression<Func<TRelated, ICollection<TSubRelated>?>> navigationExpression,
Action<RelatedEntityBuilder<TContext, TSubRelated, int>>? configure = null)
where TSubRelated : class, IEntity<int>;
// Add a prepare action applied to each item in the collection
RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Prepare(Action<TRelated> prepareFunc);
// Server-owned scalar/FK on the child (a line's UnitPrice) - same semantics as the parent's
RelatedEntityBuilder<TContext, TRelated, TRelatedKey> ServerOwned<TProp>(
Expression<Func<TRelated, TProp>> selector,
Func<TRelated, TProp>? mintOnCreate = null);
}
Returned by WithSearchObject<TSearchObject>(). Inherits all EntityServiceBuilder methods.
Only listing new / changed members:
public partial class EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
: EntityServiceBuilder<TContext, TEntity, TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
{
// NEW: elevate to full-featured builder
ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>
Complex<TSortBy, TIncludes>()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
// CHANGED: constraint uses TSearchObject instead of SearchObject<TKey>
EntitySearchObjectServiceBuilder<...> UseEntityService<TService>()
where TService : class, IEntityService<TEntity, TKey, TSearchObject>;
EntitySearchObjectServiceBuilder<...> AddFilter<TImplementation>()
where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, TSearchObject>;
EntitySearchObjectServiceBuilder<...> AddFilter<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, TSearchObject>;
EntitySearchObjectServiceBuilder<...> Filter(
Func<IQueryable<TEntity>, TSearchObject?, IQueryable<TEntity>> filterFunc);
// Re-declared to keep the builder type through a chain, here and on the complex builders:
// Validate(...) and React(...) — every overload — AddValidator<T>(), AddReactor<T>() and ServerOwned(...)
// NEW: single-type-arg Related shortcut for int-keyed children (related key is int,
// independent of the parent TKey). Use the inherited Related<TRelated, TRelatedKey> for non-int related keys.
EntitySearchObjectServiceBuilder<...> Related<TRelated>(
Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
Action<TEntity>? prepareFunc = null,
Action<RelatedEntityBuilder<TContext, TRelated, int>>? configure = null)
where TRelated : class, IEntity<int>;
void Build();
}
No bare
IEntityService<TEntity>for this builder.For<TEntity, int, TSearchObject>()registersIEntityService<TEntity, int>andIEntityService<TEntity, int, TSearchObject>— not the bareIEntityService<TEntity>shortcut. Inject/resolveIEntityService<TEntity, int, TSearchObject>(see the Inject-as table inentities.instructions.md). The bare shortcut exists only forFor<TEntity>()and the complex intFor<TEntity, TSearchObject, TSortBy, TIncludes>()overloads.
// For<TEntity>() → EntityIntServiceBuilder
public partial class EntityIntServiceBuilder<TContext, TEntity>
: EntityServiceBuilder<TContext, TEntity, int>
where TEntity : class, IEntity<int>
{
// Advance to SearchObject variant
EntityIntServiceBuilder<TContext, TEntity, TSearchObject> WithSearchObject<TSearchObject>()
where TSearchObject : class, ISearchObject<int>, new();
// Int-key shortcuts (no TRelatedKey / TContext parameter needed)
EntityIntServiceBuilder<TContext, TEntity> Prepare(Func<TEntity, TContext, Task> prepareFunc);
// Re-declared to keep the builder type through a chain
EntityIntServiceBuilder<TContext, TEntity> Validate(Action<IEntityValidatorContext<TEntity>> validate);
EntityIntServiceBuilder<TContext, TEntity> Validate(Func<IEntityValidatorContext<TEntity>, Task> validate);
EntityIntServiceBuilder<TContext, TEntity> Validate(Func<IEntityValidatorContext<TEntity>, TContext, CancellationToken, Task> validate);
EntityIntServiceBuilder<TContext, TEntity> AddValidator<TValidator>()
where TValidator : class, IEntityValidator<TEntity>;
// React(...) — every overload — and AddReactor<TReactor>() likewise return EntityIntServiceBuilder<TContext, TEntity>
// Re-declared to keep the builder type through a chain — without it the next call falls back to
// the base Related<TRelated, TRelatedKey>, whose key argument cannot be inferred (CS0411).
EntityIntServiceBuilder<TContext, TEntity> ServerOwned<TProp>(
Expression<Func<TEntity, TProp>> selector,
Func<TEntity, TProp>? mintOnCreate = null);
// Int-key shortcuts: sync only, or with a configure callback. For a parent-level prepare use
// the inherited Related<TRelated, int>(nav, prepareFunc) or a separate e.Prepare(...).
EntityIntServiceBuilder<TContext, TEntity> Related<TRelated>(
Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression)
where TRelated : class, IEntity<int>;
EntityIntServiceBuilder<TContext, TEntity> Related<TRelated>(
Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
Action<RelatedEntityBuilder<TContext, TRelated, int>> configure)
where TRelated : class, IEntity<int>;
void Build();
}
// For<TEntity, TSearchObject>() or WithSearchObject() → EntityIntServiceBuilder<TContext, TEntity, TSearchObject>
public class EntityIntServiceBuilder<TContext, TEntity, TSearchObject>
: EntitySearchObjectServiceBuilder<TContext, TEntity, int, TSearchObject>
where TEntity : class, IEntity<int>
where TSearchObject : class, ISearchObject<int>, new()
{
// Advance to full-featured
ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>
Complex<TSortBy, TIncludes>()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
void Build();
}
Returned by .For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() or .Complex<TSortBy, TIncludes>().
Inherits all EntitySearchObjectServiceBuilder methods.
Only listing new / changed members:
public partial class ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>
: EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
where TSortBy : struct, Enum
where TIncludes : struct, Enum
{
// CHANGED: constraints use full TSortBy/TIncludes
ComplexEntityServiceBuilder<...> UseEntityService<TService>()
where TService : class, IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;
ComplexEntityServiceBuilder<...> UseReadService<TService>()
where TService : class, IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;
ComplexEntityServiceBuilder<...> HasRepository<TService>()
where TService : class, IEntityRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>, IEntityRepository<TEntity, TKey>;
ComplexEntityServiceBuilder<...> HasManager<TService>()
where TService : class, IEntityManager<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;
ComplexEntityServiceBuilder<...> UseQueryBuilder<TImplementation>()
where TImplementation : class, IQueryBuilder<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;
// NEW: typed sorting
ComplexEntityServiceBuilder<...> AddSortBy<TImplementation>()
where TImplementation : class, ISortedQueryBuilder<TEntity, TKey, TSortBy>;
ComplexEntityServiceBuilder<...> SortBy(
Func<IQueryable<TEntity>, TSortBy?, IQueryable<TEntity>> sortByFunc);
// NEW: typed includes
ComplexEntityServiceBuilder<...> AddIncludes<TImplementation>()
where TImplementation : class, IIncludableQueryBuilder<TEntity, TKey, TIncludes>;
ComplexEntityServiceBuilder<...> Includes(
Func<IQueryable<TEntity>, TIncludes?, IQueryable<TEntity>> addIncludes);
// NEW: typed processors
ComplexEntityServiceBuilder<...> Process(Func<IList<TEntity>, TIncludes?, Task> process);
ComplexEntityServiceBuilder<...> Process(Action<TEntity, TIncludes?> process);
ComplexEntityServiceBuilder<...> AddProcessor<TImplementation>()
where TImplementation : class, IEntityProcessor<TEntity, TIncludes>;
void Build();
}
Returned by .For<TEntity, TSearchObject, TSortBy, TIncludes>().
Inherits all ComplexEntityServiceBuilder methods. Only addition vs parent:
public partial class ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>
: ComplexEntityServiceBuilder<TContext, TEntity, int, TSearchObject, TSortBy, TIncludes>
{
// Int-key shortcut — no TRelatedKey type parameter needed
ComplexEntityIntServiceBuilder<...> Related<TRelated>(
Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
Action<TEntity>? prepareFunc = null,
Action<RelatedEntityBuilder<TContext, TRelated, int>>? configure = null)
where TRelated : class, IEntity<int>;
void Build();
}
Returned by UseMapping<TDto, TInputDto>(). Inherits all builder methods.
⚠️ The class-based
After<TImplementation>()overloads live on the base (untyped) variant and return it —TDto/TInputDtoare lost, so the typed.After(...)/.AfterInput(...)shortcuts no longer compile after them (CS1061). Chain the typed shortcuts first, or keep both after-mappers inline.
// Base variant — any source/target after-mapper
public class MappedEntityServiceBuilder<TContext, TEntity, TKey>
: EntityServiceBuilder<TContext, TEntity, TKey>
{
MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TImplementation>()
where TImplementation : class, IEntityAfterMapper;
MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TImplementation>(
Func<IServiceProvider, TImplementation> factory)
where TImplementation : class, IEntityAfterMapper;
MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TSource, TTarget>(
Action<TSource, TTarget> afterMapAction);
MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TSource, TTarget>(
Func<IServiceProvider, Action<TSource, TTarget>> afterMapActionFactory);
}
// Typed variant — TDto and TInputDto known; shortcut After/AfterInput
public class MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto>
: MappedEntityServiceBuilder<TContext, TEntity, TKey>
{
// Entity → TDto after-mapper
MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto> After(
Action<TEntity, TDto> afterMapAction);
// TInputDto → Entity after-mapper
MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto> AfterInput(
Action<TInputDto, TEntity> afterMapAction);
}
using Regira.Entities.Mapping.Abstractions;
public interface IEntityMapper
{
TTarget Map<TTarget>(object source);
TTarget Map<TSource, TTarget>(TSource source, TTarget target);
}
using Regira.Entities.Mapping.Abstractions;
public interface IEntityAfterMapper
{
bool CanMap(object source);
void AfterMap(object source, object target);
}
public interface IEntityAfterMapper<in TSource, in TTarget> : IEntityAfterMapper
{
void AfterMap(TSource source, TTarget target);
}
// Inherit to create a custom after-mapper class
public abstract class EntityAfterMapperBase<TSource, TTarget> : IEntityAfterMapper<TSource, TTarget>
{
public abstract void AfterMap(TSource source, TTarget target);
public bool CanMap(object source);
}
using Regira.Entities.QueryBuilders.Abstractions;
public interface IFilteredQueryBuilder<TEntity, TKey, in TSearchObject>
where TSearchObject : ISearchObject<TKey>
{
IQueryable<TEntity> Build(IQueryable<TEntity> query, TSearchObject? so);
}
// Preferred base class — inherit and override Build().
public abstract class FilteredQueryBuilderBase<TEntity>
: FilteredQueryBuilderBase<TEntity, SearchObject<int>>
where TEntity : IEntity<int>;
public abstract class FilteredQueryBuilderBase<TEntity, TSearchObject>
: FilteredQueryBuilderBase<TEntity, int, TSearchObject>
where TEntity : IEntity<int>
where TSearchObject : ISearchObject<int>;
public abstract class FilteredQueryBuilderBase<TEntity, TKey, TSearchObject>
: IFilteredQueryBuilder<TEntity, TKey, TSearchObject>
where TSearchObject : ISearchObject<TKey>
{
public abstract IQueryable<TEntity> Build(IQueryable<TEntity> query, TSearchObject? so);
}
// 2-param shortcut defaults TSortBy to EntitySortBy
public interface ISortedQueryBuilder<TEntity, TKey> : ISortedQueryBuilder<TEntity, TKey, EntitySortBy>
where TEntity : IEntity<TKey>;
public interface ISortedQueryBuilder<TEntity, TKey, TSortBy>
where TEntity : IEntity<TKey>
where TSortBy : struct, Enum
{
IQueryable<TEntity> SortBy(IQueryable<TEntity> query, TSortBy? sortBy = null);
}
// 2-param shortcut defaults TIncludes to EntityIncludes
public interface IIncludableQueryBuilder<TEntity, TKey> : IIncludableQueryBuilder<TEntity, TKey, EntityIncludes>
where TEntity : IEntity<TKey>;
public interface IIncludableQueryBuilder<TEntity, TKey, TIncludes>
where TEntity : IEntity<TKey>
where TIncludes : struct, Enum
{
IQueryable<TEntity> AddIncludes(IQueryable<TEntity> query, TIncludes? includes = null);
}
using Regira.Entities.Processing.Abstractions;
public interface IEntityProcessor<TEntity, in TIncludes>
where TIncludes : struct, Enum
{
Task Process(IList<TEntity> items, TIncludes? includes, CancellationToken token = default);
}
using Regira.Entities.Preppers.Abstractions;
public interface IEntityPrepper
{
Task Prepare(object modified, object? original, CancellationToken token = default);
}
public interface IEntityPrepper<in TEntity> : IEntityPrepper
{
Task Prepare(TEntity modified, TEntity? original, CancellationToken token = default);
}
using Regira.Entities.Validators.Abstractions;
public enum EntityWriteOperation { Add, Modify, Remove } // a soft delete of an IArchivable is Remove
public interface IEntityValidatorContext
{
object Item { get; } // after every prepper; Remove: the stored row (else the caller's instance)
object? Original { get; } // the stored row on Modify; null on Add and Remove
EntityWriteOperation Operation { get; }
IReadOnlyList<EntityInputError> Errors { get; } // what every validator of this write added so far
// key: property path ("CustomerId", "Lines[0].Quantity"), "" = whole entity; message: a text, or a translation key
// a client pairs with its messages; args: the values a translation fills in — new { max = 20 } or a dictionary
void AddError(string key, string message, object? args = null);
}
public interface IEntityValidatorContext<out TEntity> : IEntityValidatorContext
{
new TEntity Item { get; }
new TEntity? Original { get; }
}
public interface IEntityValidator
{
Task Validate(IEntityValidatorContext context, CancellationToken token = default);
}
// TScope: the entity, a base class or an interface — matched against the item's runtime type
// implement the typed Validate only: the untyped one forwards to it by default (a class implementing two scopes
// implements the untyped one itself)
public interface IEntityValidator<in TScope> : IEntityValidator
{
Task Validate(IEntityValidatorContext<TScope> context, CancellationToken token = default);
}
// one scope: AddValidator refuses a subclass that implements IEntityValidator<T> for a second one, which it would never run
public abstract class EntityValidatorBase<TScope> : IEntityValidator<TScope> where TScope : class
{
public virtual bool CanValidate(TScope item) => true; // per-item opt-out
public abstract Task Validate(IEntityValidatorContext<TScope> context, CancellationToken token = default);
}
// a validator scoped wider than what it checks (e.g. to IEntity): it runs only for the item types it covers, and startup
// validation counts it only for those
public interface ISelectiveEntityValidator : IEntityValidator
{
bool Covers(Type entityType);
}
// Regira.Entities.Validators
// build one to unit-test a validator
public class EntityValidatorContext<TEntity>(EntityWriteOperation operation, TEntity item, TEntity? original = null)
: IEntityValidatorContext<TEntity> where TEntity : class;
public static class EntityValidatorExtensions
{
// runs every validator in scope of the item's runtime type, in order, against one context; throws one
// EntityInputException<TEntity> holding all their errors — what a custom repository calls before it writes
public static Task ValidateItem<TEntity>(this IEnumerable<IEntityValidator> validators, TEntity item,
TEntity? original, EntityWriteOperation operation, CancellationToken token = default) where TEntity : class;
// whether any validator runs for an item of itemType — when none does, ValidateItem cannot refuse
public static bool AnyApplyTo(this IEnumerable<IEntityValidator> validators, Type itemType);
}
public static class EntityScopeTypes
{
// the type itself, then every type it derives from or implements — the scope rule of validators and global filters
public static IReadOnlyList<Type> Of(Type entityType);
}
// Regira.Entities.EFcore.Validators — what the builder's Validate(...) registers
public class EntityValidator<TScope>(Func<IEntityValidatorContext<TScope>, Task> validate)
: EntityValidatorBase<TScope> where TScope : class
{
public EntityValidator(Action<IEntityValidatorContext<TScope>> validate);
}
public class EntityValidator<TContext, TScope>(TContext dbContext, Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate)
: EntityValidatorBase<TScope> where TContext : DbContext where TScope : class;
FluentValidation adapter — package Regira.Entities.Validation.FluentValidation:
using Regira.Entities.Validation.FluentValidation;
// inside UseEntities(): registers the assemblies' validators (scoped) and the FluentEntityValidator stage
public static EntityServiceCollectionOptions UseFluentValidation(
this EntityServiceCollectionOptions options, params Assembly[] assemblies);
// Add/Modify run the rules outside any rule set plus Add/Modify; Remove runs Remove alone
public static class EntityRuleSets
{
public const string Add = "Add";
public const string Modify = "Modify";
public const string Remove = "Remove";
}
public static class ValidationContextExtensions
{
public static T? GetOriginal<T>(this ValidationContext<T> context); // the stored row on Modify
public static EntityWriteOperation? GetOperation<T>(this ValidationContext<T> context); // null outside the pipeline
}
// an IEntityValidator<IEntity>: resolves IValidator<T> for every type in EntityScopeTypes.Of(item.GetType());
// only Severity.Error failures refuse the write; covers the entities an AbstractValidator applies to
public class FluentEntityValidator(IServiceProvider services) : EntityValidatorBase<IEntity>, ISelectiveEntityValidator;
using Regira.Entities.EFcore.Primers.Abstractions;
public interface IEntityPrimer
{
Task PrepareManyAsync(IList<EntityEntry> entries, CancellationToken token = default);
Task PrepareAsync(object entity, EntityEntry entry, CancellationToken token = default);
bool CanPrepare(object entity);
}
public interface IEntityPrimer<in T> : IEntityPrimer
{
Task PrepareAsync(T entity, EntityEntry entry, CancellationToken token = default);
bool CanPrepare(T entity);
}
using Regira.Entities.Reactors.Abstractions;
public enum EntityChangeKind { Added, Modified, Deleted } // a soft delete is Modified (IsArchived → true)
public interface IEntityChange
{
EntityChangeKind Kind { get; }
object Entity { get; } // as committed; Deleted: as it was stored
object? Original { get; } // as stored before the save; null for Added
IReadOnlyCollection<string> ChangedProperties { get; } // Modified only; complex members as "Address.City"
}
public interface IEntityChange<out TEntity> : IEntityChange
{
new TEntity Entity { get; }
new TEntity? Original { get; }
}
public interface IEntityReactor
{
bool CanReact(IEntityChange change);
Task React(IEntityChange change, CancellationToken token = default);
}
public interface IEntityReactor<in TEntity> : IEntityReactor
{
bool CanReact(IEntityChange<TEntity> change);
Task React(IEntityChange<TEntity> change, CancellationToken token = default);
}
public abstract class EntityReactorBase<TEntity> : IEntityReactor<TEntity> where TEntity : class
{
public virtual bool CanReact(IEntityChange<TEntity> change) => true;
public abstract Task React(IEntityChange<TEntity> change, CancellationToken token = default);
}
public static class EntityChangeExtensions
{
// Modified with a committed value that differs from the stored one; a complex property matches any member
public static bool HasChanged<TEntity, TProp>(this IEntityChange<TEntity> change,
Expression<Func<TEntity, TProp>> property);
// an insert holding value, or an update that brought the property to it; never a delete
public static bool ChangedTo<TEntity, TProp>(this IEntityChange<TEntity> change,
Expression<Func<TEntity, TProp>> property, TProp value);
}
// Regira.Entities.Reactors — build one to unit-test a reactor
public class EntityChange<TEntity>(EntityChangeKind kind, TEntity entity, TEntity? original = null,
IReadOnlyCollection<string>? changedProperties = null) : IEntityChange<TEntity> where TEntity : class;
using Regira.Entities.Normalizing.Abstractions;
public interface IEntityNormalizer
{
bool IsExclusive { get; }
Task HandleNormalize(object item, CancellationToken token = default);
Task HandleNormalizeMany(IEnumerable<object> items, CancellationToken token = default);
}
public interface IEntityNormalizer<in T> : IEntityNormalizer
{
Task HandleNormalize(T item, CancellationToken token = default);
Task HandleNormalizeMany(IEnumerable<T> items, CancellationToken token = default);
}
using Regira.Entities.Keywords;
using Regira.Entities.Keywords.Abstractions;
public interface IQKeywordHelper
{
ParsedKeywordCollection Parse(string? input); // splits on spaces — one QKeyword per term
QKeyword ParseKeyword(string? input); // a single term
}
public class QKeyword
{
public string? Keyword { get; set; } // unmodified input, wildcards included
public bool HasWildcardAtStart { get; set; }
public bool HasWildcardAtEnd { get; set; }
public bool HasWildcard { get; }
// RAW family — the input minus its wildcards, untouched otherwise
public string? Trimmed { get; set; }
public string? TrimmedStartsWith { get; set; } // "term%"
public string? TrimmedEndsWith { get; set; } // "%term"
public string? TrimmedQ { get; set; } // wildcards the INPUT carried, e.g. "*term" → "%term"
public string? TrimmedQW { get; set; } // "%term%", always both ends
// NORMALIZED family — same shapes, built from the normalized keyword
public string? Normalized { get; set; }
public string? StartsWith { get; set; }
public string? EndsWith { get; set; }
public string? Q { get; set; }
public string? QW { get; set; }
}
Match the family to the column. Everything carrying the Trimmed prefix holds the raw keyword;
everything without it holds the normalized one (Q/QW included). Pair a normalized column
(NormalizedContent, NormalizedLastName) with the unprefixed members, and a column that stores the
client’s value verbatim (FileName, a reference code) with the Trimmed* ones. Crossing them compiles
and silently matches nothing — QKeywordHelper.ApplyNormalize defaults to true, and the default
normalizer drops . and turns - into a space (case is preserved: Transform defaults to
NoChanges), so no real file name survives it — my-report.pdf normalizes to my reportpdf.
Note the flip side: because the Trimmed* members are raw, a % or _ the client typed keeps its
SQL LIKE meaning and over-matches. The normalized family is narrower here, not exempt: the default
normalizer’s allow-list ([^a-z0-9\s\-_,!;&']) deletes % but keeps _, so _ reaches Q/QW
as a single-character wildcard too. Escape them if exact punctuation has to match.
using Regira.Entities.Web.Models;
public record DetailsResult<TDto> { public TDto Item { get; set; } public long? Duration { get; set; } }
public record ListResult<TDto> { public IList<TDto> Items { get; set; } public long? Duration { get; set; } }
public record SearchResult<TDto> { public IList<TDto> Items { get; set; } public long Count { get; set; } public long? Duration { get; set; } }
public record SaveResult<TDto> { public TDto Item { get; set; } public bool IsNew { get; set; } public int Affected { get; set; } public long? Duration { get; set; } }
public record DeleteResult<TDto> { public TDto Item { get; set; } public long? Duration { get; set; } }
using Regira.Entities.Attachments.Abstractions;
public interface IAttachment : IBinaryFile, IHasTimestamps;
public interface IAttachment<TKey> : IAttachment, IEntity<TKey>;
// Members (via IBinaryFile -> IStorageFile -> INamedFile and IHasTimestamps):
// string? FileName; string? Identifier; string? Prefix; string? Path;
// string? ContentType; long Length; byte[]? Bytes; Stream? Stream;
// DateTime Created; DateTime? LastModified
// A Uri property exists on EntityAttachmentDto (Regira.Entities.Mapping.Models), not on the entity.
public interface IEntityAttachment
{
string? ObjectType { get; }
string? NewFileName { get; set; }
[Obsolete] string? NewContentType { get; set; } // ignored: the content type follows the file name
byte[]? NewBytes { get; set; }
IAttachment? Attachment { get; set; }
}
public interface IEntityAttachment<TKey, TObjectKey> : IEntityAttachment<TKey, TObjectKey, int, Attachment>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey> : IEntityAttachment<TKey, TObjectKey, TAttachmentKey, Attachment<TAttachmentKey>>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey, TAttachment>
: IEntity<TKey>, IHasObjectId<TObjectKey>, IEntityAttachment, ISortable
where TAttachment : class, IAttachment<TAttachmentKey>, new()
{
TAttachmentKey AttachmentId { get; set; }
new TAttachment? Attachment { get; set; }
}
using Regira.Entities.Web.Attachments.Abstractions;
// Simplest variant
public abstract class EntityAttachmentControllerBase<TEntity>
: EntityAttachmentControllerBase<TEntity, EntityAttachmentDto, EntityAttachmentInputDto>
where TEntity : class, IEntityAttachment<int, int, int, Attachment>, IEntity<int>;
// Standard variant — set the class [Route] to the owner base path (e.g. [Route("products")]);
// the base actions append the sub-routes below.
public abstract class EntityAttachmentControllerBase<TEntity, TDto, TInputDto>
: ControllerBase
where TEntity : class, IEntityAttachment<int, int, int, Attachment>, IEntity<int>
where TInputDto : class, IEntityAttachmentInput;
Every {id} is the link id (EntityAttachmentDto.Id), never attachmentId; {objectId} is the owner’s id.
| Route (under the owner base path) | Action |
|---|---|
GET {objectId}/attachments |
the owner’s links (ListResult) |
GET attachments · GET attachments/{id} |
links across owners (EntityAttachmentSearchObject) · one link |
POST {objectId}/files |
upload — multipart file + the input DTO’s fields as form values; always creates a link, whatever id the form sends |
PUT {objectId}/files/{id} |
replace the file’s bytes (multipart file) |
PUT {objectId}/attachments/{id} |
update the link’s own fields (JSON input DTO); newFileName renames the file and retypes it, newBytes replaces its content. The link keeps its attachment, whatever attachmentId the body sends |
DELETE attachments/{id} |
remove the link and its file |
GET files/{id} · GET {objectId}/files/{*fileName} |
download by link id · by the client FileName (?inline=false → attachment) |
A write a validator refuses answers 400 with a ValidationProblemDetails, and so do the two PUT routes for a link of
another owner, keyed objectId. Validators scoped to the link entity run for these routes only: a PUT of the owner
that syncs its Attachments runs the owner’s validators alone.
using Regira.Entities.Models;
// Throw the generic form; the base is what handlers catch. HTTP 400, from any MVC action.
public abstract class EntityInputException(string message, Exception? innerException = null)
: Exception(message, innerException)
{
// several messages per key: the 400 body is built from it (with none, Message goes out under the key "")
public IList<EntityInputError> Errors { get; set; } // pre-initialized
// a view over Errors, one message per key (a key's messages joined by a space); setting a key replaces its
// messages in Errors, so a key added to a caught rejection before it is rethrown reaches the 400; assigning a
// dictionary copies its entries, and a later change to that dictionary does not reach the exception
public IDictionary<string, string> InputErrors { get; set; }
}
// Key "" = the entity as a whole. Message: a text, or a translation key a client pairs with its messages. Args: the
// values a translation fills in; scalar values only reach the client (text, numbers, booleans, dates, times, Guids, enums)
public record EntityInputError(string Key, string Message, IReadOnlyDictionary<string, object?>? Args = null);
public class EntityInputException<T>(string message, Exception? innerException = null)
: EntityInputException(message, innerException)
{
public T? Item { get; set; }
}
// HTTP 409, ProblemDetails title "Conflict": a database integrity constraint rejected the change.
public class EntityConstraintException(string message, Exception? innerException = null)
: Exception(message, innerException)
{
public const string ClientMessage = "A database constraint rejected the change.";
}
// HTTP 409, ProblemDetails title "Concurrency conflict": a stale concurrency token, or a row another writer
// removed. InnerException is EF Core's DbUpdateConcurrencyException (the conflicting rows are in Entries).
public class EntityConcurrencyException(string message, Exception? innerException = null)
: Exception(message, innerException)
{
public const string ClientMessage = "The record was changed or removed since it was read. Reload it and try again.";
}
ConfigureDefaultJsonOptions() registers the filter that maps them — 400 with Errors as a
ValidationProblemDetails (errors, and errorDetails with each error’s args), 409 for
EntityConstraintException and EntityConcurrencyException — so a hand-written action returns
what the generated ones do.
Catch the non-generic base if you handle it yourself: the generated write actions catch their own closed
generic, which misses the one a prepper threw for a related entity (EntityInputException<Product> inside
an Order write).
InputErrors is initialized, so both forms work — a nested initializer for a fixed set, indexer assignment for a map you build.
An error with args goes into Errors itself. ctx.AddError(key, message, args?) in a validator is the other way in;
all of them land in Errors:
throw new EntityInputException<Order>("Saving order failed")
{
InputErrors = { ["OrderLines"] = "Order must contain at least one order line." } // nested initializer, no `new`
};
var ex = new EntityInputException<Order>("Saving order failed");
foreach (var line in invalidLines)
ex.InputErrors[$"OrderLines[{line.Index}].Quantity"] = "Must be greater than zero."; // dynamic map
throw ex;
throw new EntityInputException<Order>("Saving order failed")
{
Errors = { new EntityInputError(nameof(Order.Total), "ValueTooLarge", new Dictionary<string, object?> { ["max"] = 10_000 }) }
};
using Regira.DAL.Paging;
public record PagingInfo
{
public int PageSize { get; set; }
public int Page { get; set; } = 1;
}
using Regira.Entities.DependencyInjection.ServiceCollections.Models;
public class EntityServiceCollectionOptions(IServiceCollection services)
{
public IServiceCollection Services { get; }
// Global list/paging defaults applied at the HTTP boundary (null = off).
public int? DefaultPageSize { get; set; } // forced page size when the request omits paging
public int? MaxPageSize { get; set; } // upper limit a requested page size is clamped to
// Query-pipeline behavior (see EntityQueryOptions below): the archived scope applied
// when a search object leaves Archived null. Default Excluded.
public ArchivedFilter DefaultArchivedFilter { get; set; }
// UTC handling of entity DateTime values — one policy per process (Regira.Utilities.DateTimeDefaults.UseUtc,
// on by default): primers write UtcNow, filters normalize input dates, and the UTC convention's converter
// follows the same policy. UseUtc(false) → DateTime.Now and values are used as given.
public EntityServiceCollectionOptions UseUtc(bool enabled = true);
// The DbContext plumbing UseEntities<TContext>() auto-wires into the context's options.
// UseDefaults() → All; à la carte without UseDefaults(): e.g. WireDbContext(DbContextWiring.PrimerInterceptors);
// WireDbContext(DbContextWiring.None) → wire the DbContext yourself.
public DbContextWiring DbContextWiring { get; set; }
public EntityServiceCollectionOptions WireDbContext(DbContextWiring wiring = DbContextWiring.All);
// Shorthand for WireDbContext(DbContextWiring.All) — the full default plumbing; called by UseDefaults()
public EntityServiceCollectionOptions AddDefaultInterceptors();
// Replaces the default EntityRepository for every For<>() that names no repository of its own.
// Open generic types, matched to a For<>() shape by their number of type parameters (1–5).
// e.g. UseRepository(typeof(AppRepository<>), typeof(AppRepository<,>))
public EntityServiceCollectionOptions UseRepository(params Type[] repositoryTypes);
// Startup validation (arity mismatches, unwired interceptors, ignored ?q=, competing write authorities,
// null attachment Uri, out-of-scope global filters, missing archived query filter, archivable reference
// data behind a required FK, attachments the input DTO cannot carry, For<>() shapes UseRepository() has no
// type for). Development-only by default.
public EntityServiceCollectionOptions ConfigureValidation(Action<EntityValidationOptions> configure);
}
[Flags]
public enum DbContextWiring
{
None = 0,
PrimerInterceptors = 1 << 0,
NormalizerInterceptors = 1 << 1,
AutoTruncateInterceptors = 1 << 2,
UtcDateTimeConvention = 1 << 3,
// e => !e.IsArchived on every IArchivable entity type — soft delete without a DbContext change
ArchivedQueryFilter = 1 << 4,
// IHasConcurrencyToken.ConcurrencyToken declared a concurrency token — without a DbContext change
ConcurrencyTokens = 1 << 5,
// runs the registered IEntityReactor implementations once a save's changes are committed
Reactors = 1 << 6,
All = PrimerInterceptors | NormalizerInterceptors | AutoTruncateInterceptors | UtcDateTimeConvention
| ArchivedQueryFilter | ConcurrencyTokens | Reactors
}
using Regira.Entities.DependencyInjection.Validation;
public class EntityValidationOptions
{
public bool? Enabled { get; set; } // null = Development only; true = always; false = never
public bool ThrowOnError { get; set; } // default true: error-severity issues stop the host
}
// Entities.Web — registers the controller ↔ For<>() arity startup check and the controllers' DTO shapes
// (also enabled automatically by UseEntities())
public static EntityServiceCollectionOptions ValidateEntityControllers(this EntityServiceCollectionOptions options);
public static IServiceCollection ValidateEntityControllers(this IServiceCollection services);
using Regira.Entities.Models;
// Runtime carrier for the list/paging defaults; resolved by the controller List/Search endpoints.
public class EntityListOptions
{
public int? DefaultPageSize { get; set; }
public int? MaxPageSize { get; set; }
}
// Per-entity override, registered via e.SetPageSize(...). When present it fully replaces the
// global EntityListOptions for that entity (each null aspect is off).
public class EntityListOptions<TEntity> : EntityListOptions where TEntity : class;
// Which archived rows a read returns. ISearchObject.Archived binds this from ?archived=.
public enum ArchivedFilter
{
Excluded = 0, // non-archived only
Included, // archived and non-archived alike
Only // archived only — the recycle bin
}
// Runtime carrier for query-pipeline behavior; registered as a singleton by UseEntities().
public class EntityQueryOptions
{
// Applied when ISearchObject.Archived is null.
public ArchivedFilter DefaultArchivedFilter { get; set; }
}
// Read-path behavior. Global via UseEntities(o => { o.RefetchAfterSave = ...; }), or per-entity via
// e.SetReadBehavior(...). Details(id) always loads all registered includes; the web save endpoints
// re-fetch per RefetchAfterSave.
public enum RefetchAfterSave { Always = 0, WhenProcessorsRegistered = 1, Never = 2 } // Always = current default
public class EntityReadOptions
{
public RefetchAfterSave RefetchAfterSave { get; set; }
}
public class EntityReadOptions<TEntity> : EntityReadOptions where TEntity : class; // per-entity override
// DTO wire-shape descriptor recorded by UseMapping<TDto, TInputDto>(); registered as a singleton so
// endpoint scanners and other infrastructure can resolve the wire shape configured for an entity.
public sealed record EntityMappingRegistration(Type EntityType, Type DtoType, Type InputDtoType);