Table of Contents

Global query filters

A query filter is a predicate bound to an entity type in the mapping metadata; nextorm ands it into every query in which the entity participates — the primary FROM, join sources, subqueries and eagerly-loaded children — unless the query calls IgnoreFilters().

Prerequisites: Quickstart · Provider overview · Filtering (Where)

Overview

Global query filters attach a recurring predicate — soft-delete and multi-tenancy are the canonical cases — to the entity type instead of repeating it in every Where. A filter can read the executing IDataContext, so a tenant identifier or a soft-delete flag is resolved per query while the generated plan stays cached and shared across contexts.

This is the nextorm equivalent of EF Core global query filters and linq2db query filters.

Declaring a filter

Fluent

Declare the filter where the entity's mapping is configured with From<T>. The declaration runs once, when the type's metadata is first built, so the condition is part of the mapping rather than of one query:

The examples on this page use a Document entity mapped to documents(id, tenant_id, is_deleted) and an Attachment entity mapped to attachments(id, document_id, is_deleted). Document declares a soft-delete filter keyed "soft-delete" and a tenant filter keyed "tenant" that reads the tenant id from c.Properties["tenant"]; Attachment declares an anonymous soft-delete filter; the write examples use an ArchivedDocument entity mapped to archived_documents(id, tenant_id, is_deleted) as the INSERT … SELECT target.

ctx.From<Document>(m => m.HasQueryFilter(d => !d.IsDeleted));

The context-aware overload receives the executing IDataContext as a second parameter, so the filter can read per-context state:

ctx.From<Document>(m => m.HasQueryFilter((d, c) => d.TenantId == (int)c.Properties["tenant"]));

EntityMetadataBuilder<T> exposes the anonymous overloads plus the keyed one:

public EntityMetadataBuilder<T> HasQueryFilter(Expression<Func<T, bool>> filter);
public EntityMetadataBuilder<T> HasQueryFilter(Expression<Func<T, IDataContext, bool>> filter);
public EntityMetadataBuilder<T> HasQueryFilter(string filterKey, Expression<Func<T, IDataContext, bool>>? filter);

A repeated anonymous call adds another predicate; all anonymous filters declared for a type are combined with and.

Named (keyed) filters

Give a filter a key so it can be targeted individually by IgnoreFilters:

ctx.From<Document>(m => m
    .HasQueryFilter("soft-delete", (d, c) => !d.IsDeleted)
    .HasQueryFilter("tenant", (d, c) => d.TenantId == (int)c.Properties["tenant"]));

Both predicates are then and-ed into every read of Document; the tenant value is a bound parameter, not an inlined literal:

var ids = ctx.From<Document>()
    .Where(d => d.Id == 10)
    .Select(d => d.Id)
    .ToList();
-- PostgreSQL
select id
 from documents
 where ((id = 10 and not (is_deleted)) and tenant_id = @p0)

-- SQL Server
select id
 from documents
 where ((id = 10 and not ((is_deleted) = 1)) and tenant_id = @p0)

A keyed filter occupies a slot: a repeated call with the same key replaces the earlier filter, and passing a null filter removes the slot. Anonymous filters (declared without a key) are additive — several are combined with and — and are reported with the key QueryFilters.AnonymousKey, the empty string "".

When the same key is declared more than once, the resolver is deterministic: fluent calls come first (in call order), then [QueryFilter] attributes from the base type down to the most derived one, so a derived attribute overrides a base one and an attribute overrides a same-key fluent filter. A repeated key on the same type is different: the runtime does not guarantee attribute declaration order, so two [QueryFilter] attributes that share a non-empty FilterKey on one type have no deterministic winner and are rejected when the metadata is built. Declare the key once, or override it on a derived type. Anonymous filters are additive and may repeat. Keys are compared ordinally (case-sensitive); a null, empty or whitespace key declares an anonymous filter instead of a slot.

Attribute

The same filters can be declared declaratively with [QueryFilter] on a class or an interface. FilterLambda names a static member of the attributed type — a field, a property or a parameterless method — that returns the filter lambda:

[QueryFilter(FilterLambda = nameof(Active))]
public sealed class Document
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }

    public static Expression<Func<Document, bool>> Active() => d => !d.IsDeleted;
}

The named member may also return a context-aware predicate:

[QueryFilter(FilterLambda = nameof(ForTenant))]
public sealed class Document
{
    public int Id { get; set; }
    public int TenantId { get; set; }

    public static Expression<Func<Document, IDataContext, bool>> ForTenant()
        => (d, c) => d.TenantId == (int)c.Properties["tenant"];
}

Set FilterKey to name the attribute filter and make it targetable:

[QueryFilter(FilterKey = "soft-delete", FilterLambda = nameof(Active))]
[QueryFilter(FilterKey = "tenant", FilterLambda = nameof(ForTenant))]
public sealed class Document { /* ... */ }

With a key, the same slot rules apply: repeating the key replaces the earlier filter, and FilterLambda = null removes a slot inherited from a base type. An attribute with neither FilterKey nor FilterLambda is invalid and throws when the metadata is built.

AllowMultiple = true means several [QueryFilter] attributes can be placed on one type; anonymous attributes are combined with and just like repeated fluent calls.

Reading the executing context

A filter whose lambda takes IDataContext reads the value from the context at execution time:

public static Expression<Func<Document, IDataContext, bool>> ForTenant()
    => (d, c) => d.TenantId == (int)c.Properties["tenant"];

The tenant value is not inlined into the SQL. It becomes a runtime parameter, and the filter's identity (not its current values) is part of the plan key, so:

  • two contexts with different tenant values can share the same cached plan;
  • each execution reads its own context value, even on a plan-cache hit.

The same predicate compiles and re-reads the context on every invocation in the in-memory provider.

Disabling filters for one query

IgnoreFilters returns a copy of the builder with the chosen filters disabled for that query. There are four overloads:

public EntityBuilder<TEntity> IgnoreFilters();
public EntityBuilder<TEntity> IgnoreFilters(params Type[] entityTypes);
public EntityBuilder<TEntity> IgnoreFilters(IEnumerable<string> filterKeys);
public EntityBuilder<TEntity> IgnoreFilters(IEnumerable<string> filterKeys, params Type[] entityTypes);

Their semantics are:

Call Disables
IgnoreFilters() every filter, on every entity type
IgnoreFilters(typeof(A), typeof(B)) every filter (any key, including anonymous) declared for A and B
IgnoreFilters(["soft-delete"]) the filter keyed soft-delete on every entity type
IgnoreFilters(["soft-delete"], typeof(A)) the filter keyed soft-delete only on A (intersection of key and type)

Rules that follow from the above:

  • Types only disables all filters of those entity types (anonymous and keyed).
  • Keys only disables those keys across all entity types.
  • Keys and types disables the intersection — only the listed keys on the listed types.
  • Empty or null key or type list is a no-op: nothing is disabled. The key list is the gate, so an empty key list disables nothing even when entity types are supplied (mirroring EF Core).
  • QueryFilters.AnonymousKey ("") targets anonymous filters, e.g. IgnoreFilters([QueryFilters.AnonymousKey]).

For example, ignoring only the soft-delete filter while keeping tenant scoping:

var deleted = ctx.From<Document>()
    .IgnoreFilters(["soft-delete"])
    .Where(d => d.IsDeleted)
    .Select(d => d.Id)
    .ToList();

The tenant filter is still applied; only the soft-delete predicate is dropped:

-- PostgreSQL
select id
 from documents
 where (id = 10 and tenant_id = @p0)

-- SQL Server
select id
 from documents
 where (id = 10 and tenant_id = @p0)

The original builder is unchanged; only the returned copy ignores the filters. Repeated calls accumulate (union), they do not replace: IgnoreFilters(["a"]).IgnoreFilters(["b"]) disables both a and b, and the order of calls and duplicate keys do not matter. Type-only and key-only selectors also accumulate independently, while a single IgnoreFilters(keys, types) call is an intersection — each of its keys applies only to the listed types. A combined call is never folded into another selector's union. A scope that is empty (an empty or null key/type list) disables nothing, and an all-or-nothing IgnoreFilters() dominates any selective scope combined with it.

Once the query command exists, QueryCommand.IgnoreFilters reads back the result: it is true when any filter is disabled — whether by the all-or-nothing form or by a selective scope — and false when none is. Its setter is all-or-nothing (true disables every filter, false clears the scope) because a bool cannot express a selective disable; the selective scope itself is the authoritative state.

Scope: primary source, joins and subqueries

A filter is attached to the entity type and is injected wherever that type appears in the query shape:

Position Injected
Primary FROM into the statement's WHERE, combined with an explicit Where
Join source into the join's ON condition for the joined entity
Subquery into the subquery when it is prepared
Mutation target (UPDATE / DELETE) into the statement's WHERE, combined with the predicate or the key equality
MERGE target (SQL Server, PostgreSQL) into the MERGE ... ON condition and every WHEN NOT MATCHED BY SOURCE arm (see Write-target isolation)

For example, the filter of a joined entity is injected into that join's ON, while the filter of the main source stays in WHERE:

var rows = ctx.From<Attachment>()
    .Join<Document>((a, d) => a.DocumentId == d.Id)
    .Select(p => p.Item1.Id)
    .ToList();
-- PostgreSQL
select t1.id
 from attachments as "t1"
 join documents as "t2" on ((t1.document_id = t2.id and not (t2.is_deleted)) and t2.tenant_id = @p0)
 where not (t1.is_deleted)

-- SQL Server
select t1.id
 from attachments as [t1]
 join documents as [t2] on ((t1.document_id = t2.id and not ((t2.is_deleted) = 1)) and t2.tenant_id = @p0)
 where not ((t1.is_deleted) = 1)

A subquery prepared as part of an outer query is filtered the same way:

var rows = ctx.From<Document>()
    .Select(d => new { d.Id, sid = ctx.From<Attachment>()
        .Where(a => a.DocumentId == d.Id)
        .Select(a => a.Id)
        .First() })
    .ToList();
-- PostgreSQL
select t1.id, (select t2.id
 from attachments as "t2"
 where (t2.document_id = t1.id and not (t2.is_deleted))
limit 1) as "sid"
 from documents as "t1"
 where (not (t1.is_deleted) and t1.tenant_id = @p0)

-- SQL Server
select t1.id, (select top(1) t2.id
 from attachments as [t2]
 where (t2.document_id = t1.id and not ((t2.is_deleted) = 1))) as [sid]
 from documents as [t1]
 where (not ((t1.is_deleted) = 1) and t1.tenant_id = @p0)

The main source of a join is the first table, so a filter declared for T1 applies to Item1 exactly as in a plain query.

The disable scope is carried by the query that starts it, so a type listed in a selective IgnoreFilters is disabled wherever it appears in that query, and IgnoreFilters() disables every filter everywhere in the query — including eagerly-loaded children declared with LoadWith, at every nesting level. This holds identically in split and single-query (EagerLoadMode.SingleQuery) mode: each level's effective scope is the union of its own IgnoreFilters scope and its parent's, IgnoreFilters() (All) absorbs that union, and a selective scope disables only filters on the entity types it names. A root IgnoreFilters therefore reaches every descendant, and a scope applied inside a child reaches that child and all of its own descendants. See Eager loading.

UPDATE and DELETE (DML)

A filter declared for the target entity applies to a mutation of that entity exactly as to a read:

Statement Filter
CreateUpdateBuilder<T>().Where(...).Update() and-ed into the WHERE with the predicate
Update(entity) (key form) WHERE <pk> = @p and <filter> — a row excluded by the filter is not updated
CreateDeleteBuilder<T>().Where(...).Delete() and-ed into the WHERE with the predicate
Delete(entity) (key form) WHERE <pk> = @p and <filter> — a row excluded by the filter is not deleted
CreateUpdateBuilder<T>().Set(...).Update() with no Where every filtered row is updated (the filter still applies)
CreateDeleteBuilder<T>().All() explicit full-table delete: no filter is applied
CreateUpdateJoinBuilder(...) / join Delete() the target (first table) and every joined source are filtered

For the key forms, the filter is and-ed to the key equality, so ctx.Delete(entity) returns 0 if the filter excludes that key. The filter is injected into the mutation's prepared condition, so the rendered SQL reflects the effective filter set. Unlike a read, a mutation is neither prepared nor plan-cached, so there is no plan key for the filter to take part in.

UpdateBuilder<T>, DeleteBuilder<T> and UpdateJoinBuilder<TProjection> expose the same four IgnoreFilters overloads as the read builder and with the same semantics (type-only, key-only, key-and-type intersection, empty is a no-op). Unlike the read builder, which returns a copy, a DML builder is stateful: the call applies to the statement being built and repeated calls accumulate:

// Ignore only the soft-delete filter; the tenant filter still applies.
ctx.CreateDeleteBuilder<Document>()
    .IgnoreFilters(["soft-delete"])
    .Where(d => d.IsDeleted)
    .Delete();

// Ignore every target filter, then update only the named column.
ctx.CreateUpdateBuilder<Document>()
    .IgnoreFilters()
    .Set(d => d.Archived, true)
    .Update();

Those rules are visible in the rendered statement — the active filter and the predicate share one WHERE, and IgnoreFilters(["soft-delete"]) removes only the soft-delete term:

-- PostgreSQL
update documents set is_deleted = @p0 where ((id = 10 and not (is_deleted)) and tenant_id = @p1)

delete from documents where (is_deleted and tenant_id = @p0)

-- SQL Server
update documents set is_deleted = @p0 where ((id = 10 and not ((is_deleted) = 1)) and tenant_id = @p1)

delete from documents where ((is_deleted) = 1 and tenant_id = @p0)

INSERT / MERGE do not inject a target filter through a source FROM (the target has no FROM); under an active filter a full MERGE constrains the target inside the statement, and the key-upsert forms that cannot do so refuse (see Write-target isolation). INSERT … SELECT filters its source as any read — and the written rows are validated instead (see INSERT and MERGE (validation)).

INSERT and MERGE (validation)

Neither an INSERT nor a MERGE injects a target filter through a source FROM — the target has no FROM — so a write is never silently restricted. A full MERGE (and the SQL Server key upsert, which renders as the same form) instead constrains the target atomically inside the statement, while an ON CONFLICT / ON DUPLICATE KEY / in-memory key upsert — which cannot carry the predicate — refuses (see Write-target isolation). Independently, the values about to be written are validated against the target entity's active filters (minus the IgnoreFilters scope) before the statement runs; a violation raises QueryFilterException (a DataContextException).

Statement What is validated
CreateInsertBuilder<T>().Value(...) / Values(entity) / batch Values(...) every written row against the target filters
CreateBulkInsertBuilder<T>() every row in the source
CreateMergeBuilder<T>().Using(...) the source rows of every MERGE branch — insert, update-only and delete-only
CreateInsertBuilder<T>().Values(source, mapping) (INSERT … SELECT) a server-side pre-check of the source rows
CreateMergeBuilder<T>().Using(query) (query-sourced MERGE) a server-side pre-check of the source rows

An INSERT never carries a target filter; an INSERT … SELECT filters only its source:

// A target INSERT is never filtered; the written row is validated instead.
ctx.CreateInsertBuilder<Document>()
    .Values(new Document { Id = 1, TenantId = 1, IsDeleted = false })
    .Insert();

// INSERT … SELECT filters its source as a read and pre-checks the written rows.
ctx.CreateInsertBuilder<ArchivedDocument>()
    .Values(ctx.From<Document>().Where(d => d.Id > 0), d => new { d.Id, d.TenantId, d.IsDeleted })
    .Insert();
-- PostgreSQL
insert into documents (id, tenant_id, is_deleted) values (@p0, @p1, @p2)

insert into archived_documents (id, tenant_id, is_deleted)
select id, tenant_id as "TenantId", is_deleted as "IsDeleted"
 from documents
 where (((id > 0) and not (is_deleted)) and tenant_id = @p0)

-- SQL Server
insert into documents (id, tenant_id, is_deleted) values (@p0, @p1, @p2)

insert into archived_documents (id, tenant_id, is_deleted)
select id, tenant_id as [TenantId], is_deleted as [IsDeleted]
 from documents
 where (((id > 0) and not ((is_deleted) = 1)) and tenant_id = @p0)

On the supported full-MERGE form the source-value validation covers every branch combination — an update-only or delete-only MERGE validates its incoming source rows just like one that inserts, and a non-passing source row throws QueryFilterException before any mutation, even when the statement would not insert a row.

For a materialised entity the filter is evaluated directly against it. For the column/value forms only the written columns carry a value, so validation is fail-closed: if an active filter reads a column the statement does not write, the write is rejected with a QueryFilterException rather than let through — a database default could satisfy or violate the filter, and nextorm does not guess. Write the column explicitly, or disable the filter for the statement with IgnoreFilters.

The expression INSERT form (WhenNotMatchedInsert(assignments) and the full-MERGE ThenInsert(assignments), each taking an initializer whose right-hand sides are translated expressions) is validated differently: the row about to be written is built from expressions, so it cannot be checked against the target filters before the statement runs. Under an active target filter this form therefore fails closed with NotSupportedException — MERGE expression INSERT with active target filters is not supported. Use IgnoreFilters() explicitly. The refusal is a metadata decision: no command is executed, and a materialised source costs no round-trip (a query source may run its pre-check read first, as above). Opt out with IgnoreFilters() — or with a scope that covers the target entity's filter — when the write should proceed.

INSERT … SELECT and a query-sourced MERGE are pre-checked with a separate existence query over the source (looking for a row the target filter rejects). This is a TOCTOU guard, not a transactional one: between the check and the write a concurrent writer can still change the rows, and validation does not make the write atomic. Put both in the same transaction when the race matters.

A multi-row or bulk write is not atomic either. A synchronous bulk source is validated in full before the first batch is sent; an asynchronous bulk source is validated row by row as it streams, so a later violation can leave earlier rows already written. The order is the same for every bulk-insert mode and every path (native load or INSERT ... VALUES): validation always runs before that path's first write.

IgnoreFilters on the InsertBuilder<T>, BulkInsertBuilder<T> and MergeBuilder<T> disables the corresponding filters from validation too, with the same four overloads and semantics as the read builder:

ctx.CreateInsertBuilder<Document>()
    .IgnoreFilters(["soft-delete"])
    .Values(document)
    .Insert();

A filter declared in the builder-function form (FilterFunc) cannot be validated against a written row — it is evaluated only while a query plan is built — so a write whose active target filter is declared as a function is rejected (fail-closed) unless the filter is disabled with IgnoreFilters; declare the filter as a predicate (FilterLambda) when the write must be validated. INSERT … SELECT still filters its source as a read, and UPDATE / DELETE are unaffected (see Builder-function filters).

Write-target isolation

A write is filtered on two sides, and they are independent:

  • the source of an INSERT … SELECT or a query-sourced MERGE is an ordinary read, so a filter declared for the source entity is injected into the source query as usual;
  • the target has no FROM, so its filter is not part of any source query. Under an active, non-ignored target filter the operation is constrained atomically in the statement where the dialect can express it; where it cannot, the command refuses with NotSupportedException before any mutation. A capability/form refusal happens before any read, but a query-sourced merge may first run its source pre-check read before a translation/render refusal. The predicate is never silently dropped: an active filter that cannot be translated to a target predicate is a fail-closed error, not a bypass.

For the full MERGE form (SQL Server, PostgreSQL) the target predicate is injected into the MERGE ... ON condition and — on SQL Server, which has the arm — appended to every WHEN NOT MATCHED BY SOURCE branch. A target row hidden by the filter is therefore never matched, never updated and never deleted by the merge, including its delete arm. The SQL Server key upsert renders as the same MERGE form and takes the same predicate, so it is filtered too.

ctx.CreateMergeBuilder<Document>()
    .Using(new Document { Id = 1, TenantId = 1, IsDeleted = true })
    .OnKeys()
    .WhenMatched().ThenUpdate()
    .WhenNotMatched().ThenInsert()
    .Merge();

On a full MERGE, the active target filter joins the ON search condition:

-- PostgreSQL
merge into documents as target using (values (@p1, @p2, @p3)) as source (id, tenant_id, is_deleted)
 on target.id = source.id and ((not (target.is_deleted) and target.tenant_id = @p0))
 when matched then update set tenant_id = source.tenant_id, is_deleted = source.is_deleted
 when not matched then insert (id, tenant_id, is_deleted) values (source.id, source.tenant_id, source.is_deleted)

-- SQL Server
merge into documents as target using (values (@p1, @p2, @p3)) as source (id, tenant_id, is_deleted)
 on target.id = source.id and ((not ((target.is_deleted) = 1) and target.tenant_id = @p0))
 when matched then update set target.tenant_id = source.tenant_id, target.is_deleted = source.is_deleted
 when not matched then insert (id, tenant_id, is_deleted) values (source.id, source.tenant_id, source.is_deleted);

Form rule. The atomic form is a real multi-branch MERGE, which requires actual WhenMatched()/WhenNotMatched() branches. Calling .On(...) does not turn the key-upsert shortcut (OnKeys() + WhenMatchedUpdate() + WhenNotMatchedInsert()) into a full MERGE; the two are distinct forms. On a provider that has no full-MERGE support (SQLite, MySQL, MariaDB, in-memory), a branchless .On(...) under an active filter therefore fails closed with NotSupportedException before any source read.

PostgreSQL is a server prerequisite, not a client-side guard. PostgreSQL 15+ is required for the server to execute the general MERGE; nextorm does not discover or configure the server version and performs no version read. Capability-based rendering is version-independent — the library emits the statement from the dialect's capability flags alone, and an older server rejects it server-side. There is no library-side "PostgreSQL < 15 refuses" path.

Provider / form Target filter under an active, non-ignored filter
SQL Server — full MERGE and key upsert (MERGE) injected into ON and into every WHEN NOT MATCHED BY SOURCE arm
PostgreSQL (server 15+) — full MERGE injected into ON (PostgreSQL has no WHEN NOT MATCHED BY SOURCE)
PostgreSQL, SQLite — key upsert (ON CONFLICT) NotSupportedException — fail closed
MySQL, MariaDB — key upsert (ON DUPLICATE KEY) NotSupportedException — fail closed
In-memory — key upsert NotSupportedException — fail closed
Any provider — no active filter, or the filter is disabled native behavior, no extra predicate

The capability refusal is a metadata decision, before any connection, command or read (zero database round-trips); the exception message points to IgnoreFilters() or to a full-MERGE-capable provider/form. It is deliberate fail-closed behavior, not a bug.

IgnoreFilters bypasses the target filter for the statement, with the same four overloads as elsewhere (all-or-nothing, by entity type, by key, and the key/type intersection). A scope that disables the target entity's filter — or all filters — restores the provider's native upsert without the predicate; a selective scope that does not cover the target filter leaves it active, so an ON CONFLICT / ON DUPLICATE KEY upsert still refuses.

Limits of target isolation

  • Existence oracle on a unique-index collision (insert arm only). When a MERGE's insert arm attempts a row whose key collides with a target row the filter hides, the provider reports its native unique-constraint error for whichever unique index it hits — not only the PK/merge key — revealing that a hidden target row exists; it cannot be removed without dropping the unique constraint. The composite-key mitigation (include the tenant/filter column) applies to the primary/merge key only, since it prevents a hidden row's key from colliding with a visible source key but does not cover other unique indexes. An update-only or delete-only MERGE never inserts, so a hidden row is a silent no-op (the filtered-out row is simply not matched) and exposes no oracle.
  • Source-value validation is a separate read. For INSERT … SELECT and a query-sourced MERGE, the written source values are checked by a separate existence query (QueryFilterValidator) with a TOCTOU window. It validates source values, not target rows, so it does not reveal hidden target existence and it does not make the write atomic (see INSERT and MERGE (validation)).
  • The refusal above is the intended safe outcome when the dialect cannot isolate the target atomically.

With no filter configured the write path is unchanged: no predicate is added and no operation refuses.

Builder-function filters (FilterFunc)

A filter can also be declared as a builder function instead of a predicate: a Func<EntityBuilder<T>, IDataContext, EntityBuilder<T>>. nextorm invokes it once, while the query plan is built, passing the live IDataContext; the function calls Where on the fresh builder and nextorm merges only that predicate into the main source WHERE or the join ON, exactly like a predicate filter.

ctx.From<Document>(m => m.HasQueryFilter(
    (b, c) => b.Where(d => d.TenantId == (int)c.Properties["tenant"])));

// Keyed form — the function occupies the same slot as a predicate filter.
ctx.From<Document>(m => m.HasQueryFilter(
    "tenant", (b, c) => b.Where(d => d.TenantId == (int)c.Properties["tenant"])));

Read the value through the IDataContext inside the predicate — or use SqlFunctions.Parameter<T>(idx) — so it stays a bound parameter and the generated SQL is shared across contexts, exactly as for a predicate filter. A chain of Where calls is allowed.

Attribute

QueryFilterAttribute.FilterFunc names a static member of the attributed type that returns the function; a declaration sets either FilterLambda or FilterFunc, not both:

[QueryFilter(FilterKey = "tenant", FilterFunc = nameof(TenantFilter))]
public sealed class Document
{
    public int Id { get; set; }
    public int TenantId { get; set; }

    public static Func<EntityBuilder<Document>, IDataContext, EntityBuilder<Document>> TenantFilter()
        => (b, c) => b.Where(d => d.TenantId == (int)c.Properties["tenant"]);
}

IgnoreFilters

The function form takes part in IgnoreFilters exactly like a predicate filter: it carries the same key (or QueryFilters.AnonymousKey), so keyed, type-only and intersection scopes disable it the same way — IgnoreFilters(["tenant"]), IgnoreFilters(typeof(Document)) and an all-or-nothing IgnoreFilters() all apply.

The function must not snapshot a runtime value

The function runs only when the plan is built, so a runtime value must be read through the IDataContext inside the predicate. Capturing it into a local before the Where call is rejected with NotSupportedException: the value would be baked into the cached plan (and into the in-memory evaluation) and reused by every later execution and every other context, so a plan-cache hit would silently return the wrong rows.

// Rejected: `tenant` is snapshotted before the Where call, so it would be frozen into the plan.
ctx.From<Document>(m => m.HasQueryFilter((b, c) =>
{
    var tenant = (int)c.Properties["tenant"];
    return b.Where(d => d.TenantId == tenant);
}));

Unsupported cases

  • Non-Where mutations. Only the Where predicate is merged, so a function that changes anything else on the builder — source, join, select, group, ordering, paging, eager loading or filter scope — returns a different builder, or returns no Where / a null builder, is rejected with NotSupportedException. Express the filter with Where alone.
  • Captured collections. A predicate that closes over a collection — for example ids.Contains(e.Id) — captures it as a runtime value and is rejected with NotSupportedException. The function runs only at plan build, so the collection cannot stay a bound IN list; declare the filter as a predicate (FilterLambda) or use a SqlFunctions.Parameter<T>(idx) placeholder instead.
  • Foreign captured context. A predicate that closes over an IDataContext other than the one passed to the function is rejected with NotSupportedException: reading it on a shared plan would silently return the wrong context's values. Read the function's IDataContext parameter instead.
  • INSERT / MERGE target validation. A function filter is evaluated only while a query plan is built, so it cannot be validated against a written row; a write whose active target filter is declared as a function is rejected (fail-closed) unless the filter is disabled with IgnoreFilters. Declare the filter as a predicate (FilterLambda) when the write must be validated.
  • Filter functions on unbound FromSql / raw sources are not applied (the raw SQL is passed through as written; the same holds for predicate filters). Bind the source with BindEntity<TEntity> to opt in.

In-memory provider

The in-memory provider applies the same predicate to the registered sequence before projection and joins, so soft-delete and multi-tenancy queries behave identically to SQL providers. The IgnoreFilters overloads are honoured there too. For a write — the key-upsert merge is the only write it applies — an active, non-ignored filter makes the operation refuse with NotSupportedException (fail closed; see Write-target isolation); IgnoreFilters restores the native behavior.

Filters on bound raw sources

A FromSql/From(string) source has no mapped entity type, so nextorm cannot know which columns a filter reads. Call BindEntity<TEntity> as the first operation on the source to attach entity metadata and declare the columns the raw SQL exposes; nextorm then applies each active global filter to that source best-effort:

var rows = dataContext
    .FromSql("select id, tenant_id, is_deleted from documents")
    .BindEntity<Document>(["id", "tenant_id", "is_deleted"])
    .Where(t => t.Id > 10)
    .Select(t => t.Id)
    .ToList();
-- PostgreSQL
select id
 from (select id, tenant_id, is_deleted from documents) as "t1"
 where (((t1.id > 10) and not (t1.is_deleted)) and t1.tenant_id = @p0)

-- SQL Server
select id
 from (select id, tenant_id, is_deleted from documents) as [t1]
 where (((t1.id > 10) and not ((t1.is_deleted) = 1)) and t1.tenant_id = @p0)

availableColumns are the output/SQL names of the raw select list — a configured column mapping wins, otherwise the name the projection expects — and they are compared case-insensitively. The list is a caller declaration, not a schema read: nextorm does not parse the SQL or query the schema, and binding neither adds nor renames output columns, so the columns you project remain your responsibility.

The exact signature is:

public static EntityBuilder<TEntity> BindEntity<TEntity>(
    this EntityBuilder<TableAlias> source,
    IReadOnlyCollection<string> availableColumns)

For each active filter on TEntity:

  • if every column the filter reads is declared, the filter is and-ed into the source;
  • if a required column is missing, the filter is skipped (no exception) and a RawSourceFilterSkipped warning is logged on the NextORM.QueryFilters logger category with reason MissingColumns and the missing names — the physical mapped column names the filter reads, not the caller's declared output names;
  • with an empty declared-column list, a filter with a proven zero-column dependency (a predicate that reads no mapped column, such as a constant) is applied; a column-dependent or undetermined filter is skipped with a warning (reason UndeterminedColumns).

Filters are evaluated independently — one skipped filter does not stop the others — and IgnoreFilters disables the selected filters before this analysis, so an ignored filter is never reported. The warning is emitted once per skipped filter while the query plan is prepared (a cache miss); a plan-cache hit does not re-emit it. The message carries only the entity type name, the source ordinal, the filter key, the reason and the missing column names (physical mapped column names only) — never SQL text, table names, parameter values or captured values. Wire an ILoggerFactory and enable the NextORM.QueryFilters category at Warning to see it (categories are topic-based, so filtering that category is enough):

using var loggerFactory = LoggerFactory.Create(builder => builder
    .AddFilter("NextORM.QueryFilters", LogLevel.Warning)
    .AddConsole());

var ctx = new SqliteDataContext(builder => builder.UseLoggerFactory(loggerFactory));

BindEntity must be the first operation after the source is created; a later call (after Where/Select/Join/projection) throws InvalidOperationException. Another source shape, or binding to TableAlias, throws NotSupportedException; a null source or column collection throws ArgumentNullException, and a null/blank entry throws ArgumentException (an empty collection is allowed, and the receiver is not mutated — a new typed builder is returned). WithSql, PrepareFromSql and ExecuteRaw are unchanged and do not take part, and the in-memory provider still rejects a raw source.

Not a security guarantee. nextorm trusts the column list you supply: a filter whose columns you forget to declare is silently skipped, and the raw source then returns rows the filter would have excluded. Treat BindEntity as a convenience for composing filters over a trusted raw source, not as an enforcement or isolation boundary; keep row-level authorization in the SQL itself when it must be enforced.

Joined bound sources and the main source

Binding is per source, not per entity type: each joined raw source uses its own BindEntity<TEntity> binding and its own declared column list, and never borrows the main source's binding or another occurrence's. The same entity type joined twice with different declared columns keeps both bindings independent. A joined source's compatible filters are merged into that join's ON condition; a skipped filter on one occurrence warns separately and leaves the other occurrences and the other filters unaffected, and an outer join's predicates stay in ON and are never moved into WHERE.

The main source's filters are always evaluated against the main source's own binding, even when the command is a joined projection. In that case the retained predicates are re-rooted onto the projection's main alias, Item1 (Projection<T1, …>), and placed in the WHERE clause; they are never resolved from a join-side binding or alias.

SourceOrdinal identifies where a skip happened: the main source is 0, and join index j (zero-based) is j + 1, counting all joins — including unbound ones — so the ordinal is the join's position, not the number of bound sources.

A CROSS/CROSS APPLY join has no ON clause, so a bound raw source on such a join has no predicate to attach to: its compatible filters are placed in WHERE instead, and nextorm never fabricates an ON for a join that has none. OUTER APPLY/PASTE are left unchanged, because moving their predicates into WHERE would change the result.

Metadata

A declared filter is exposed through IQueryFilterMetadata:

public interface IQueryFilterMetadata
{
    string Key { get; }                    // QueryFilters.AnonymousKey ("") for an anonymous filter
    LambdaExpression? Lambda { get; }      // the predicate, or null for a builder-function filter
    Delegate? Func { get; }                // the builder-function declaration, or null for a predicate filter
}

Key is QueryFilters.AnonymousKey for an anonymous filter and the declared key for a named one. A predicate filter reports its predicate in Lambda and null in Func; a builder-function filter reports null in Lambda and the Func<EntityBuilder<T>, IDataContext, EntityBuilder<T>> declaration in Func. A filter that reports both non-null is rejected.

Func has a default interface implementation returning null, so an external implementation that does not declare Func keeps compiling.

Limitations

  • Process-global, first-registration-wins. Filters are registered process-globally and the first registration for an entity type wins (consistent with nextorm metadata): a later From<T>(cfg) / HasQueryFilter for the same type is ignored — filters are not scoped to a DataContext.
  • Disable scope follows the entry builder. The selective scope is carried by the builder that starts the query; calling IgnoreFilters on a builder that is then used as a join source is not propagated. Use one of the type/key overloads on the query's entry builder instead. Eagerly-loaded LoadWith children are the exception: they inherit the entry builder's scope by union at every nested level (see Eager loading).
  • Key-form mutations apply filters but do not expose IgnoreFilters. Update(entity) and Delete(entity) honour the target filter, but their immediate terminal has no fluent IgnoreFilters; use the predicate form (CreateUpdateBuilder<T>().Where(...) / CreateDeleteBuilder<T>().Where(...)) when you need to disable a filter on a mutation.
  • Plan lifetime. A context value read by a filter is captured as a runtime parameter (plan-cache safe). The prepared plan retains the first IDataContext instance for its lifetime (bounded, one per plan shape).
  • Open adapter units. The ClickHouse no-EF-adapter slice and the MariaDB-specific shared-connection certification are tracked open units in milestone 1.0.9-b; until they land, use the supported EF adapters (see EF Core query-filter bridge).