A normalized property is usually just a joined string (using a space), built by normalizing one or more source properties.
[Normalized] attribute on property to be normalizedSourceProperties for the source propertiesDefaultEntityNormalizer to process NormalizedAttribute automaticallyIEntityNormalizerEntityNormalizerBaseDefaultEntityNormalizer to extend default behavior// base class
public abstract class EntityNormalizerBase<T>(INormalizer? normalizer = null) : IEntityNormalizer<T>
{
public virtual bool IsExclusive => false;
public abstract Task HandleNormalize(T item, CancellationToken token = default);
public virtual async Task HandleNormalizeMany(IEnumerable<T> items, CancellationToken token = default) {...;
}
When IsExclusive is true, only this normalizer is executed for the entity type.
Otherwise, other compatible normalizers are also executed.
NormalizedContent is computed when the entity is saved. When a parent folds in text from related
entities (e.g. a ticket whose searchable content includes its replies), re-normalize the parent whenever
that related data changes.
The catch: a child added in the same SaveChanges as the parent isn’t committed yet, so a normalizer
that queries the database for it won’t see it — the searchable text lags by one save. Three ways to
handle it, cheapest first:
ticket.Replies)
instead of querying the DB — children attached in the same object graph are already visible. One save,
no extra wiring. Use when the children hang off the parent’s navigation.ChangeTracker. Inject the DbContext into the normalizer (normalizers are resolved
through normal DI) and read the pending siblings — db.GetPendingEntries<Reply>() (i.e.
db.ChangeTracker.Entries<Reply>()) includes the Added rows of the in-flight save. One save; works
even when the child isn’t on the parent’s navigation, as long as it’s tracked in the same context.Save twice (two-phase write). Persist the child first, then re-stamp the parent so its normalizer re-runs against the now-committed child. The fallback when the normalizer must query the DB:
await replyService.Add(reply);
await replyService.SaveChanges(); // phase 1: child is committed
await ticketService.Modify(ticket); // phase 2: re-attach + re-normalize the parent
await ticketService.SaveChanges(); // normalizer now sees the committed reply
(Bulk seeding uses the same two-phase shape: create parents → add children → re-stamp parents in a final pass.)
These fit simple-to-moderate denormalization. When the cross-entity logic gets genuinely complex, don’t force it into a normalizer — move it to a dedicated service that owns building the searchable text; it’s clearer and easier to test.
IQKeywordHelper to normalize search keywordsINormalizer for saving and filtering (by default)Sample from FilterHasNormalizedContentQueryBuilder
public IQueryable<IHasNormalizedContent> Build(IQueryable<IHasNormalizedContent> query, ISearchObject<TKey>? so)
{
if (!string.IsNullOrWhiteSpace(so?.Q))
{
var keywords = qHelper.Parse(so.Q);
foreach (var q in keywords)
{
query = query.Where(x => EF.Functions.Like(x.NormalizedContent, q.QW));
}
}
return query;
}
[Normalized] attribute)SourceProperty - Single source property nameSourceProperties - Array of source property names (content concatenated with space)Recursive - Process nested objects (class-level only, default: true)Normalizer - Custom normalizer type (must implement INormalizer or IObjectNormalizer)// Normalize from multiple properties (concatenated with space)
[Normalized(SourceProperties = [nameof(Title), nameof(Description)])]
public string? NormalizedContent { get; set; }
Normalizers run as SaveChanges interceptors. UseEntities<TContext>(e => e.UseDefaults()) wires the
EntityNormalizerContainerInterceptor into the DbContext options automatically; without UseDefaults(),
select it explicitly:
services.UseEntities<MyDbContext>(e => e.WireDbContext(DbContextWiring.NormalizerInterceptors));
The interceptor resolves all matching normalizers when saving entities.
| Interface | Implementation |
|---|---|
INormalizer |
DefaultNormalizer |
IQKeywordHelper |
QKeywordHelper |
IObjectNormalizer |
ObjectNormalizer |
IEntityNormalizer |
DefaultEntityNormalizer<IEntity> |
services.UseEntities<DbContext>(e =>
{
// Registers all default (normalizing) services
e.AddDefaultEntityNormalizer();
// or e.UseDefaults(); to also register other default helper services
});
services.UseEntities<DbContext>(e =>
{
e.AddNormalizer<IEntityInterface, MyGlobalEntityNormalizer>();
});
services
.UseEntities<DbContext>(/*...*/)
.For<Entity>(entity =>
{
entity.AddNormalizer<MyEntityNormalizer>();
});