DynamicWhere.ex
DynamicWhere.exv3.2.0·docs

Breaking Changes & Known Limitations

DynamicWhere.ex is intentionally opinionated about how queries are shaped. The twenty-nine points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

Behaviour changes in 3.2.0
Points 25 to 29 changed in 3.2.0, and each is visible to code written for 3.1.0. A guarded query that sends no Selects synthesizes its projection whenever a denied value can reach the result, and the projection keeps what the source carries: the assigned members of a projected row, and an entity's columns, owned and complex members, while an entity's navigations and the objects of a row in memory are left out (point 25). Selects naming a member is gated against every denial beneath it, and a narrowing that cannot be built is refused (point 26). A declared default order reaches a projection that builds T and assigns every field the default names (point 27). Every async terminal gains overloads that take a CancellationToken, so ToListAsync(filter, default) no longer compiles, and the async dynamic Filter and the async Summary read through EF Core's asynchronous operators (point 28). And a type in an application namespace that starts with System is policed (point 29).
Behaviour changes in 3.1.0
Eleven behaviours changed in 3.1.0. Each one fixes a defect, and each one is visible to a caller that depended on the old shape. Date comparisons now read the member's type before building the predicate (point 14) and accept a value only in ISO 8601, a year-first form, or a format the deployment declares (point 15); an unpaged PageCount is now 1 rather than TotalCount (point 16); the Select constructor refusal carries a stable code instead of an English sentence (point 17); a guarded query is refused unless its context was prepared (point 18); a segment's condition sets are combined, ordered and paged in the database (point 19); a member named Root, It or Parent is read as that member rather than as the row, and ParsingConfig.Default is no longer read (point 20); a guarded request whose condition groups nest deeper than MaxConditionDepth, or a guarded segment with more sets than MaxConditionSets, is refused (point 21); under the strict tier a result no longer carries the policy trace (point 22), and a field that does not exist is refused exactly as a denied one is, with no field named (point 23); and a guarded condition carrying more values than MaxConditionValues, or a guarded summary computing more aggregates than MaxAggregates, is refused, while a Count is now charged to the query budget (point 24).
Upgrade to 2.1.4
Releases before 2.1.4 did not escape condition values before embedding them in the generated expression. A value carrying a \ or a " ended its string literal early — a search term ending in \ threw ParseException: ')' or ',' expected, and a crafted value could close the literal and append predicate logic of its own, returning rows the filter should never have matched. See point 12.

1. Parameterless Constructor Required for Select Projection

Select<T>(fields) requires T to have a parameterless (default) constructor. If T does not have one, a LogicException is thrown, with SelectTypeMustHaveParameterlessConstructor as its Message and the type's name on Subject — see point 17 for what that message used to be. Most EF Core entity classes have parameterless constructors by default.

Hard requirement
Records with positional parameters and classes whose only constructor takes required arguments are not usable as the target type for Select<T> or for the typed Filter projection. Use SelectDynamic instead, or add an explicit parameterless constructor to your DTO.

2. Segment Operations are Async-Only

ToListAsync<T>(Segment) is the only entry point for segment queries. There is no synchronous ToList<T>(Segment) variant. The condition sets are combined into one query that the database orders and pages (point 19). Under ApplyPolicy, DwCaps.MaxConditionSets (default 10) bounds how many sets one request may carry.

No synchronous overload
If you need to compose UNION / INTERSECT / EXCEPT across multiple condition sets you must use the async pipeline. See Segment and ToListAsync<T>(Segment).

3. Case-Insensitive Operators use .ToLower()

All I* operators (e.g., IContains, IEqual) normalize both sides via .ToLower(). This works correctly with SQL Server (COLLATE is typically case‑insensitive), but be aware of potential performance or behavior differences on case‑sensitive database collations (e.g., PostgreSQL with C locale).

Mind your collation
On case‑sensitive collations the provider may not be able to use an index for a LOWER(column) predicate, which can turn a fast seek into a table scan. If you target PostgreSQL with C locale, consider a functional index on LOWER(column) or use the case‑sensitive operator variants.

4. Enum Filtering Matches the Member Name, Whatever the Column Stores

DataType.Enum compares the member name you send ("Pending"), and it works whether the column stores names or integers: the dynamic LINQ parser converts the name to the enum value before EF Core translates the comparison. DataType.Enum has no value-format check at all, so nothing is rejected at validation time.

Substring operators need a string member
Contains, StartsWith and EndsWith (and their Not forms) only bind on a member that really is a string. On an enum-typed member the parser has no such method to bind and throws ParseException from System.Linq.Dynamic.Core — as does a name that is not a member of the enum. For enums use Equal, NotEqual, In, NotIn, IsNull or IsNotNull.

5. Having Clause Fields Reference Aliases, Not Entity Properties

In a Summary, Having is itself a ConditionGroup, so the path is Having.Conditions[].Field — and each field in its nested SubConditionGroups as well. Every one of them must match an AggregateBy.Alias, not an entity property path.

Aliases only
A Having condition that references an entity property directly (e.g., "UnitPrice" instead of the alias "AvgPrice") throws HavingFieldMustExistInAggregateByAlias. See the error code reference.

6. GroupBy Flattens Dotted Field Names in Results

Dotted GroupBy fields (e.g., Category.Name) produce flattened alias keys in the dynamic result objects (e.g., CategoryName). Order fields in Summary.Orders should use the dotted form; the library handles alias mapping internally.

Dotted in → flattened out
Inside the result, access the grouped column as row.CategoryName — not row.Category.Name. When writing Summary.Orders entries, keep the dotted form ("Category.Name") and the library will map it to the flattened alias for you.

7. Collection Navigation Auto-Wraps with .Any()

When a condition's Field path traverses a collection property, the library automatically inserts .Any() lambdas. This means the filter checks if any item in the collection matches — there is no built‑in .All() support.

No .All() support
There is no negated .Any() either. The Not operators are emitted inside the Any lambda, so NotEqual on a path through a collection means "some element does not match", not "no element matches". Universal quantification has to be expressed outside the library — split it into two queries, or apply it in memory on the materialized result. See the nested‑collection example.

8. Thread-Safe Cache, But Configuration Changes are Eventually Consistent

CacheExpose.Configure() is thread‑safe, but already‑in‑progress operations may use the previous configuration until they complete.

In-flight calls keep the old config
Treat configuration as a startup concern when possible. Hot‑swapping the cache strategy at peak traffic is safe but won't retroactively re‑classify ongoing operations. See cache configuration.
Fixed in 3.1.0: an invented field name stayed in memory
Validating a field path recorded an access for eviction before the path was validated. A path that fails adds no cache entry for eviction to remove, so under LRU — the default — or LFU every distinct invalid name a caller sent kept its record for the life of the process, and a caller sending unique invented names grew the process without limit, fastest under a strict policy, which resolves every unknown name in a request. A path is now tracked only once it has validated.

9. getQueryString Parameter Requires EF Core Provider

Passing getQueryString: true to ToList / ToListAsync calls .ToQueryString(), which needs an active EF Core database provider to produce SQL. On an in‑memory IEnumerable<T> it does not fail: QueryString holds a placeholder sentence where the SQL would be.

EF Core only
Only enable getQueryString when the source is a real DbSet<T> or an EF Core‑backed IQueryable<T>. On an in‑memory collection you get the placeholder sentence rather than SQL. Use it as a development aid, not as a production feature.

10. SelectDynamic / FilterDynamic / ToListDynamic / ToListAsyncDynamic Return Non-Generic Types

These methods return IQueryable or FilterResult<dynamic> instead of the strongly‑typed equivalents. Downstream code must work with dynamic objects. Property names in the dynamic result follow these rules:

  • Non‑dotted paths (Name, Category, OrderItems, …) are projected as‑is — access them by their exact field name at runtime.
  • Dotted paths through reference navigations (e.g., Category.Name) produce nested dynamic objects reflecting the navigation hierarchy — access them as result.Category.Name, not as a flat CategoryName.
  • Dotted paths through collection navigations (e.g., Category.Vendors.Id) generate a Select lambda per collection segment — the result is a nested collection of dynamic objects accessible as result.Category.Vendors[0].Id.
  • Multiple dotted fields sharing the same root segment (e.g., Category.Name + Category.Id) are merged into a single nested object: result.Category.Name and result.Category.Id.
  • Mixed whole‑navigation + sub‑field paths: when both "Category" and "Category.Name" are requested, the sub‑field projection takes precedence and "Category" is silently dropped.
Two different shapes — typed vs. dynamic
Note that SelectDynamic preserves the navigation hierarchy (nested), whereas the typed GroupBy result flattens dotted names (point 6). They are different on purpose — pick the extension method that matches the shape your client expects.

11. All Filter Extensions Apply Order and Page Before the Select Projection

All Filter extensions — both typed (Filter<T>, ToList<T>(Filter), ToListAsync<T>(Filter), and ToListAsync<T>(Segment) since 3.1.0) and dynamic (FilterDynamic<T>, ToListDynamic<T>, ToListAsyncDynamic<T>) — apply ordering and pagination on the typed IQueryable<T> before the select projection. This ensures that field names referenced in orders always resolve against the original entity type T, regardless of which fields are projected.

Order on the entity, project after
You can sort by a column that is not in your Select.Fields list. The library resolves orders[].field against T's original property graph, then projects to the requested subset. This is the right behaviour for almost every list endpoint — it just surprises people who expected the order field to also need to be in the projection.

12. Condition Values Become Escaped Literals, Not Query Parameters

A condition's Values are written into the generated dynamic LINQ expression as string literals. Since 2.1.4 they are escaped first — a backslash is doubled and a double quote is backslash‑escaped — so any value matches literally, \ and " included, and a value can no longer break out of its literal to alter the predicate.

The literal then reaches the provider as a constant, so EF Core inlines it into the SQL rather than binding a parameter — a Contains on "الثانية\" renders as instr(lower("p"."Name"), 'الثانية\') > 0. EF Core escapes that literal for SQL itself, so this is not a SQL injection path.

No plan-cache reuse across distinct values
Because values are inlined rather than parameterized, each distinct search term produces a distinct SQL statement. On SQL Server that means a separate plan‑cache entry per term. If a high‑cardinality free‑text filter is on a hot path, consider enabling forced parameterization at the database level.
Fixed in 3.1.0: a long In list ended the process
In, NotIn, IIn and INotIn on Text, and In and NotIn on Guid, Number and Enum, joined their values into one flat chain — f == "a" || f == "b" || … — which the expression parser reads as one level of nesting per value. EF Core and the expression compiler walk that tree recursively, so a single condition carrying about seven hundred values overflowed the request thread's stack, guarded or not, and a stack overflow ends the process: no catch can stop it. A list longer than 32 values is now nested as a balanced tree of flat chains of at most 32 terms, all joined by the same operator. A list of 32 or fewer is written exactly as before, so its predicate and its SQL do not change, and a longer list returns the same rows. Under ApplyPolicy, MaxConditionValues also bounds the values of one condition (point 24).

13. AggregateBy.Alias Must Be a Plain Identifier

The alias is emitted verbatim into the generated Select projection, so since 2.1.4 it must be a leading letter or underscore followed by letters, digits, or underscores. Letters are matched by Unicode category, so a non‑Latin alias such as "المجموع" stays valid.

Tightened in 2.1.4
Earlier releases only rejected aliases containing a dot, which let an alias holding a comma — "Total, 1 as Leaked" — append terms of its own to the projection. Aliases carrying any other separator (a space, a dash) never parsed in the first place, so nothing that previously worked is rejected; a malformed alias now throws AggregationMustHasValidAlias at validation time instead of failing later. See AggregateBy.

14. Date Comparisons Resolve the Member's Type

Before 3.1.0 every DataType.Date and DataType.DateTime condition produced the same string per operator, whatever the member actually was — for GreaterThanOrEqual, {field} != null && {field} >= DateTime.Parse("…"). The builder now reads the member's CLR type first and emits the null guard, the literal, and the .Date access that type can actually take.

  • The null guard is emitted only for a member that can be null. A non-nullable member reached through a navigation — Approval.ApprovedAt — guards each navigation instead: Approval != null && ….
  • A DateTimeOffset member is compared against a DateTimeOffset literal; a DateTime member against a DateTime literal; a DateOnly member against a DateOnly, as a day under both data types.
  • A nullable member is unwrapped with .Value under its guard, so DataType.Date emits {field}.Value.Date — or {field}.Value on a DateOnly?, which is already a day.
  • A Having condition names an aggregate alias rather than a member, so the type comes from what the alias stands for: a Minimum, Maximum, FirstOrDefault or LastOrDefault returns one of the values it read, so it has that member's type — nullable if the member is. On PostgreSQL a Having on Maximum of a timestamptz column becomes HAVING max(col) > TIMESTAMPTZ '…'.
Fixed: DateTimeOffset members were unusable
Until 3.1.0 every comparison on a DateTimeOffset member threw. On a non-nullable one the null guard compared a struct against null and failed with InvalidOperationException: The binary operator NotEqual is not defined for the types 'System.DateTimeOffset' and 'System.Object'. On a nullable one the literal was built as the wrong type — for GreaterThanOrEqual, ParseException: Operator '>=' incompatible with operand types 'DateTimeOffset?' and 'DateTime'. And DataType.Date on any nullable date member threw, because a nullable has no .Date — on a DateTime?, ParseException: No property or field 'Date' exists in type 'DateTime?'. All of these now work.
Fixed: DateOnly members could not be compared
Until 3.1.0 no comparison on a DateOnly member worked. Only IsNull and IsNotNull did. DataType.Date asked it for a .Date it does not have — ParseException: No property or field 'Date' exists in type 'DateOnly' — and DataType.DateTime compared it against a DateTime literal — ParseException: Operator '==' incompatible with operand types 'DateOnly' and 'DateTime'. Both data types now compare a DateOnly as a day, and a nullable DateOnly is guarded like any other nullable date. On PostgreSQL an Equal becomes WHERE "Day" = DATE '2026-09-01'.
IsNull answers a constant on a non-nullable member of the entity itself
With the guard gone, there is nothing left for IsNull and IsNotNull to test on a non-nullable date member of the entity itself, so they answer with the constant the guard already implied: IsNull is false and IsNotNull is true. On PostgreSQL that reaches the database as WHERE FALSE and, for IsNotNull, as no predicate at all. On a non-nullable DateTimeOffset, where both used to throw like every other operator, they now answer. Reached through a navigation, as in Approval.ApprovedAt, they test the navigation instead: IsNull matches the rows with no approval, because a provider reads the member of a missing approval as NULL.
Unchanged: the guard sits outside the comparison
On a nullable member the guard still wraps the whole comparison, so a null row fails every comparison — including the negative ones. A row whose date is unset does not match NotEqual and does not match NotBetween. Combine with IsNull under an Or if you want the unset rows back.

15. Date Values Are ISO 8601, Year-First, or a Declared Format

Condition values for the two date types are now read against an explicit list of formats, never the lenient .NET parser, and re-emitted in round-trip form — at validation and in the builder alike, as the member's own date type. Every deployment accepts ISO 8601 extended calendar dates (2026-09-01, optionally with a time after a T or a space, a fraction, and Z or an offset) and year-first dates with / or . (2026/09/01, 2026.09.01), plus any format the deployment declares. The other ISO 8601 forms are InvalidFormat: basic (20260901), week (2026-W36-2), ordinal (2026-244) and reduced precision (2026-09, 2026-09-01T12). The shipped predicate used to carry your raw text into a DateTime.Parse that the runtime evaluated in the host's culture, so the same filter meant different days on two servers; the host's culture and calendar now play no part.

Day-first and month-first values are now refused
A numeric date that leads with a day or a month — "01/09/2026", "15/09/2026", "09/15/2026", "01.09.2026", "1/9/26", with or without a time — is refused with the new code AmbiguousDateFormat, whatever its numbers, with the field on LogicException.Subject. A server whose culture used to read such values refuses them now, unless it declares the form. The refusal goes by shape on purpose: refusing only values with two valid readings would fail on the 5th of the month and pass on the 15th, so a client would find out in production instead of on its first request. Send ISO 8601 — "2026-09-15", "2026-09-15T12:00:00Z" — or declare the form your clients send once at startup, with DwDates.Configure(o => o.Formats.Add("dd/MM/yyyy")); see DataType → Date formats.
Values the lenient parser guessed at are now InvalidFormat
Anything that is neither an accepted form nor a day-first or month-first date is refused with InvalidFormat. That includes values the lenient parser used to accept without a word: "12:00" was today at noon, "1/9" a day of the current year, and "Sep 2026" and "1 September 2026" were 1 September.
Zones: DateTimeOffset normalizes to UTC, DateTime does not
On a DateTimeOffset member the value is normalized to UTC, and a value carrying no zone is read as UTC — which is what keeps DataType.Date comparing the calendar day you wrote rather than the day it happens to be on the server. On PostgreSQL DataType.Date translates to date_trunc('day', col AT TIME ZONE 'UTC'). DateTime members keep the previous behaviour: a value carrying a zone is converted to the host's local time, which is the reading a timestamp without time zone column is compared against.
C# date objects in Values are written year-first
A DateTime, DateTimeOffset or DateOnly placed in Condition.Values from C# is now written as year-first text — "2026-09-01T12:30:00", "2026-09-01T12:30:00+03:00", "2026-09-01" — instead of the month-first invariant form "09/01/2026 12:30:00", so a C# caller is never refused for sending an unambiguous value. It also ends a silent misreading: that month-first text used to be parsed back in the host's culture, so on a day-first server new DateTime(2026, 9, 1) filtered on 9 January.
A local DateTime keeps its offset on a DateTimeOffset member
A C# DateTime whose Kind is Local — DateTime.Now, or the value Newtonsoft.Json produces from a string carrying an offset — placed in Values with DataType.DateTime against a DateTimeOffset or DateTimeOffset? member, or against a Having alias over such a member's aggregate, is written with its offset: "2026-09-17T15:00:00+03:00". It filters on the moment it holds. Written with no zone it would be read as UTC, which on a host at UTC+3 names a moment three hours later, with no error. Everything else keeps no zone, on purpose. Under DataType.Date a local DateTime is written without one, so DateTime.Today compares the day it was written for — on a host ahead of UTC, local midnight on the 17th is still the 16th in UTC. A DateTime or DateOnly member gets none. A DateTime of Kind Utc or Unspecified gets none, and on a DateTimeOffset member it is read as UTC. Text values — every JSON string System.Text.Json binds — are never touched.

16. PageCount on an Unpaged Result Is 1

When a Filter or Summary carries no Page, PageCount is now 1 — the one page the whole result occupies — and 0 when nothing matched. It used to equal TotalCount: the calculation divided by a page size of 1 whenever none was sent, so a 5,000-row result reported 5,000 pages of one row each.

Check anything that renders a pager
A client that draws page links straight from PageCount drew one link per row on every unpaged endpoint and now draws a single link. Applies to FilterResult<T> — typed and dynamic, sync and async — to SummaryResult, and to SegmentResult<T>, which used to report 0 for an unpaged request with condition sets. PageNumber and PageSize are unchanged — both still report 0 when no page was sent. The exception is a guarded query when the deployment sets DwCaps.DefaultPageSize: the query is given page 1 at that size, and reports it.

17. Select's Constructor Refusal Is Now a Stable Code

The refusal in point 1 used to arrive as an English sentence — Select projection requires a parameterless constructor on type '{T}'. — which a caller could not match on, because the type name was interpolated into it. The Message is now the fixed code SelectTypeMustHaveParameterlessConstructor, and the type's name rides on a new property, LogicException.Subject (string?). LogicException gained a second constructor for it, LogicException(string message, string? subject).

Two things to update
Middleware that string-matched the old sentence stops matching, and anything that scraped the type name out of the message must read Subject instead. The count of stable codes went from 27 to 28 (30 with point 15's AmbiguousDateFormat and point 20's FieldPath[{path}]StartsWithReservedName), leaving one validation failure whose message is a sentence rather than a code — Unsupported combination of DataType '{type}' and Operator '{op}'. See the error code reference.
Guarded queries reach it too
A member carrying [DwNoSelect] makes the policy layer synthesize a projection for a query that sent none — since 3.2.0 whatever the member holds, and beneath another member when its value can reach the result (point 25) — so a typed guarded query on a type with no parameterless constructor raises the same code, even though the caller never asked for a Select. The dynamic terminals project through SelectDynamic and are not affected.

18. A Guarded Query Requires a Prepared Context

A query guarded through ApplyPolicy whose DwPolicyContext never went through DwPolicy.PrepareAsync is refused with a PolicyException carrying PolicyContextNotPrepared — whether or not a policy store is configured, and at the ApplyPolicy call itself, before any terminal runs. A store provider already refused one, because it had no pinned snapshot to answer from; with attributes alone nothing refused it, so the same missing call was a failure in one deployment and silence in another. DwPolicyContext.IsPrepared is public, so you can assert it yourself.

The explicit-options overload does not check
The ApplyPolicy overload that takes explicit options and a resolver is exempt: a host composing its own options owns preparation. The check applies to the overloads that read the ambient DwPolicy configuration, because that is where PrepareAsync is the documented ceremony. A store handed to the explicit overload still refuses an unprepared context on its own.

19. A Segment's Sets Are Combined in the Database

ToListAsync(Segment) turns its condition sets into one query. Union and Intersect join the sets' conditions with OR and AND; Except removes its set's rows with NOT EXISTS on the primary key; and a type with no primary key uses SQL UNION / INTERSECT / EXCEPT. The combined query is then ordered, paged, projected and counted exactly like a Filter.

Until 3.1.0 each set was loaded into a list, and the lists were combined in memory by object reference. That was right only for a tracking query with no Selects. With AsNoTracking(), with Selects, and under ApplyPolicy, which always runs untracked, Intersect returned nothing, Except removed nothing and Union counted a row once for every set that matched it. Every row of every set was read before the page was cut.

  • Results. Untracked, projected and guarded segments return the rows their sets describe. A tracking query without Selects returns the same rows it did.
  • Ordering. Sorting runs in the database, so text follows its collation rather than .NET's string comparison, and NULLs fall where the provider puts them. Orders apply before Selects, so an order field no longer has to be selected. A segment with no Orders comes back in whatever order the database chooses, as a filter does.
  • Reads. Only the requested page is read, plus one COUNT for TotalCount.
  • Providers. On a type with a primary key, only Except needs the provider to translate a correlated EXISTS. A type with no primary key needs every column to be comparable — not PostgreSQL json or SQL Server xml — and support for the SQL set operators its sets use.

20. Members Named Root, It or Parent Are Read as Members

System.Linq.Dynamic.Core reads it, root and parent as keywords, in any letter case, wherever an identifier can stand, and the library writes member paths into its expressions as they are named. Before 3.1.0 it parsed with those keywords on:

  • A navigation named Root or It was read as the row itself. Root.Name filtered, sorted, grouped and aggregated — and through SelectDynamic projected — the row's own Name.
  • A navigation named Parent threw ParseException.
  • An AggregateBy.Alias named root, it or parent failed in Having and in Summary.Orders.

Every expression is now parsed with a ParsingConfig the library owns: the parser's defaults with AreContextKeywordsEnabled = false, so it, root and parent name members like any other identifier. No setting restores the keyword reading.

Fixed: a policy decided on one column while the query read another
Under ApplyPolicy the gate decided on the path the caller named while the database read the row's own column. A dynamic projection of Root.Name returned the values of a [DwDenied] Name, a filter on Root.Name tested the denied column, and a [DwForceWhere] scope reached through a navigation named Root filtered the row's own column instead of the linked record's.
ParsingConfig.Default is no longer read
The library used to parse through the shared ParsingConfig.Default. It no longer reads that instance, so a change a host makes to it does not reach DynamicWhere queries, and the library's own configuration does not reach the host's dynamic LINQ. No setting carries a host's changes to ParsingConfig.Default into the library's parsing.
A path that starts with one of the parser's own words is now refused by name
The parser reads its functions and literals before it looks for a member, and it still does: new, iif, np, isnull, is, as, cast, true, false and null, in any letter case, shadow the first segment of a field path. So 3.1.0 refuses such a path itself, with a LogicException whose message is FieldPath[{path}]StartsWithReservedName and whose Subject carries that first segment, trimmed. It is raised where a path is validated, so a condition Field, an Orders entry, a Selects entry, a GroupBy.Fields entry, an AggregateBy.Field and the member a [DwAlias] stands for all answer alike — guarded or not. A DefaultOrder entry naming one is skipped instead, as an unreadable entry is, because a default order never refuses a query; the startup scan reports it. A member that cannot be reached cannot be filtered, sorted, grouped, aggregated or projected: rename the CLR property and keep the column with [Column("New")].
What a member with one of those names did before 3.1.0
Nothing announced itself. New, Iif, Np, IsNull, Is, As and Cast raised the parser's ParseException, True and False an InvalidOperationException, and Null was read as the null literal — so the predicate compared null with the caller's value and the query returned no rows and no error. A typed Selects entry naming such a member used to work, because a typed projection is built without the parser; it is refused now too, so one rule covers every clause. Under ApplyPolicy the Convenience tier and any dry run give the new code, while the Strict tier outside a dry run answers with the clause's FieldDeniedFor* code and FieldPath "*", as it answers for every name it cannot use. The startup scan reports a DefaultOrder entry naming one as an error.
Names that only look reserved
Only the first segment is the parser's: Owner.New names the member, because the parser looks for a member after a dot. An alias named after one of these words still works — only the path it stands for is checked. And it, root, parent and outerIt, whose keywords this point turns off, are members like any other identifier, as is every predefined type name the parser knows: String, Boolean, Char, Byte, SByte, Int16, Int32, Int64, UInt16, UInt32, UInt64, Single, Double, Decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Math, Convert, Uri, Object and Enum.

21. MaxConditionDepth and MaxConditionSets Refuse Guarded Requests 3.0 Ran

Two caps are new in 3.1.0. Only a query guarded through ApplyPolicy enforces them; an unguarded call is not affected. Both default to 10, refuse a value below 1, freeze with the rest of the posture, and bind from configuration as Caps:MaxConditionDepth and Caps:MaxConditionSets.

CapWhat it countsRefusal
DwCaps.MaxConditionDepthHow deeply condition groups nest. The top group counts as 1 and each level of SubConditionGroups adds 1, counted on the caller's groups before any forced predicate is injected — on a Filter's group, on the deeper of a Summary's conditions and its Having, and on each Segment set separately.PolicyException with CapExceeded and SourceOrigin "MaxConditionDepth cap (10), request had 11"
DwCaps.MaxConditionSetsHow many condition sets one Segment sends, empty sets included.PolicyException with CapExceeded and SourceOrigin "MaxConditionSets cap (10), request had 11"

Nothing bounded either shape before. MaxConditions counts conditions and says nothing about how deeply their groups nest, and a set with no conditions passes every other cap while still adding to the one statement a segment becomes.

A request 3.0 ran can be refused
A guarded filter nested eleven groups deep, or a guarded segment carrying eleven or more condition sets, ran on 3.0.0 and is refused on 3.1.0. A deployment whose clients send such requests raises the cap, in code or from configuration:
DwPolicy.Configure(new DwPolicyOptions
{
    Caps = { MaxConditionDepth = 20, MaxConditionSets = 25 },
}, providers);
{
  "DynamicWhere": {
    "Policies": {
      "Caps": { "MaxConditionDepth": 20, "MaxConditionSets": 25 }
    }
  }
}

22. The Strict Tier Keeps the Policy Trace Off the Result

On 3.0.0 every guarded terminal put its PolicyTrace on the result, in both tiers: FilterResult<T>.Policy from ToList, ToListAsync, ToListDynamic and ToListAsyncDynamic with a Filter, SummaryResult.Policy from ToList and ToListAsync with a Summary, and SegmentResult<T>.Policy from ToListAsync with a Segment. The trace names every field a policy dropped, the attribute or rule that sealed each one, and every predicate injected on the caller's behalf — the detail the strict tier already refuses to hand over through getQueryString — and an API that serializes a result sends it to the caller.

DwPolicyOptions.IncludeTraceInResult (bool?, default null) now decides. null follows the tier: off under DwTier.Strict, on under DwTier.Convenience. true or false overrides the tier in either one. It freezes with the posture and binds from the configuration key IncludeTraceInResult.

Under the strict tier result.Policy is null
Code that reads Policy from a strict-tier guarded result now reads null. The trace is still recorded, on the PolicyQueryable<T>.LastTrace of the handle that ran the query, and audit events are written exactly as before. Set IncludeTraceInResult = true to put the trace back on the result.
var guarded = db.Employees.ApplyPolicy(caller);
var result  = await guarded.ToListAsync(filter);

PolicyTrace? sent     = result.Policy;      // null under DwTier.Strict, unless IncludeTraceInResult = true
PolicyTrace? recorded = guarded.LastTrace;  // recorded whatever the setting says

23. The Strict Tier Answers an Unknown Field and a Denied Field Alike

On 3.0.0 a guarded query told a field that does not exist from one the caller may not use. A name that matched nothing on T failed validation with LogicException ConditionMustHasValidFieldName before any policy decision was made, and a denied field was refused with a PolicyException naming the field — and, where one source decided, that rule or attribute on RuleId and SourceOrigin. A caller probing the strict tier learned which columns exist, including the ones they may never read, one guess at a time, and each refusal confirmed the guess.

Under DwTier.Strict, outside a dry run, the two now answer alike:

  • A name that matches nothing is gated as a field denied for every feature, at the step where a denial is raised — after the caps — so it gets the code a [DwDenied] field gets in that clause: FieldDeniedForWhere, FieldDeniedForSelect, FieldDeniedForOrder, FieldDeniedForGroup or FieldDeniedForAggregate. A name padded with dots or blank segments — NoSuchColumn...., . . . . X — is normalized the way a real path is, so it gets the refusal a padded real field gets rather than failing MaxNavigationDepth.
  • Inside a segment every field refusal is FieldDeniedForSegment, with Feature Segment, whichever clause refused it: a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say FieldDeniedForOrder where a name that matches nothing says FieldDeniedForSegment. Filters and summaries keep their per-clause codes.
  • Every refusal with one of those six codes carries FieldPath = "*", RuleId = null and SourceOrigin = null, whatever the field — a real denied field and an alias included — so the message is the same too: FieldDeniedForWhere: field '*', feature 'Where', tier 'Strict'.
  • A CapExceeded refusal names no path either. MaxNavigationDepth and MaxAuditEvents, the two caps that named a field, used to report its canonical path, which confirmed that the path exists. They report "*", and SourceOrigin still names the cap.
  • MaxQueryCost is checked after every field has passed its gate, not before. A field weighted by [DwCost] that the caller may not use is refused as denied before its weight can count, exactly as a name that does not exist is, so the budget cannot tell the two apart. An allowed weighted field is still refused with QueryCostExceeded.
  • MissingContextValue has FieldPath "*" and a null SourceOrigin, so it names neither the scope's column nor the context key it reads — together they describe how rows are partitioned.
  • The trace keeps the real path and reason: an unknown name is recorded as Denied, with the reason names nothing on followed by the type's name. With AuditRefusals on, the audit event names the field, the scoped field of a MissingContextValue, or the unknown name the caller sent.
Check anything that reads FieldPath or matches the validation code
Under the strict tier an error response built from PolicyException.FieldPath now says *, and a handler that matched ConditionMustHasValidFieldName for a misspelt field receives a PolicyException instead. It still derives from LogicException, so an existing catch still catches it. Inside a segment, a handler that matched a clause's code receives FieldDeniedForSegment, and a MissingContextValue carries neither the column nor its key. No setting restores the old answer under the strict tier. The convenience tier is unchanged — an unknown field fails validation, a refusal names the field with its RuleId and SourceOrigin, and the cost budget is checked before gating — and a dry run refuses nothing, so an unknown name fails validation there too.

24. MaxConditionValues and MaxAggregates Refuse Guarded Requests 3.0 Ran

Two more caps are new in 3.1.0. As with point 21, only a query guarded through ApplyPolicy enforces them; an unguarded call is not affected. MaxConditionValues defaults to 1000 and MaxAggregates to 50. Both refuse a value below 1, freeze with the rest of the posture, and bind from configuration as Caps:MaxConditionValues and Caps:MaxAggregates.

CapWhat it countsRefusal
DwCaps.MaxConditionValuesThe values one condition carries. The condition carrying the most is the one compared, wherever it sits: a Filter's conditions, a Summary's conditions and its Having, and every set of a Segment.PolicyException with CapExceeded, FieldPath "*" and SourceOrigin "MaxConditionValues cap (1000), request had 1001"
DwCaps.MaxAggregatesThe AggregateBy entries one summary sends, through the Summary terminals and the composable Group and Summary. The count the group-size floor adds for itself is not the caller's and is not counted.PolicyException with CapExceeded, FieldPath "*" and SourceOrigin "MaxAggregates cap (50), request had 51"

Nothing bounded either shape before. An In or a NotIn is one comparison per value, so a single condition could hand the database a predicate of any size while spending one condition from MaxConditions and one field from the cost budget. Every aggregate is a column of every group, and one with no field — a Count — named nothing a [DwCost] weight could be set on, so any number of them cost nothing.

That Count is now charged as well: an aggregate with no Field costs DwCaps.DefaultFieldCost toward MaxQueryCost, where it used to cost nothing. A guarded summary that sat just under its budget can now go over it and be refused with QueryCostExceeded.

Every count cap — MaxConditions, MaxConditionDepth, MaxConditionSets, MaxConditionValues, MaxAggregates, MaxOrderFields and MaxPageSize — is now checked before any field name is resolved, because resolving every name of an oversized request is the work the caps exist to refuse; MaxNavigationDepth still runs once names are resolved. A request that is too large and names a field that does not exist is refused with CapExceeded in both tiers, where 3.0.0 resolved names first and answered ConditionMustHasValidFieldName.

A request 3.0 ran can be refused
A guarded summary computing more than fifty aggregates, or one whose Count aggregates now take it over MaxQueryCost, ran on 3.0.0 and is refused on 3.1.0. So is a guarded condition carrying more than a thousand values — which on 3.0.0 could end the process instead (point 12). A deployment whose clients send such requests raises the cap, in code or from configuration:
DwPolicy.Configure(new DwPolicyOptions
{
    Caps = { MaxConditionValues = 5000, MaxAggregates = 100, MaxQueryCost = 2000 },
}, providers);
{
  "DynamicWhere": {
    "Policies": {
      "Caps": { "MaxConditionValues": 5000, "MaxAggregates": 100, "MaxQueryCost": 2000 }
    }
  }
}

25. A Guarded Query That Sends No Selects Keeps What the Source Carries

A request with no Selects returns whole rows, denied fields included, so a guarded query synthesizes a projection when a denied field could reach the result. 3.2.0 changed when it does so and what the projection keeps. The rules are on Policy configuration.

  • When. A field denied at the top of T asks for it whatever the field holds. A field denied beneath a member asks for it when its value can reach the result: on an entity, beneath a column, an owned or complex member, or a navigation the query loads through an Include, an automatic include or a lazy loader; on a projected row, beneath a member the initializer assigns; in memory, beneath any member. A chain that reaches its rows through a navigation, a SelectMany, a Join or a GroupBy counts every navigation as loaded when it also has an include, or a lambda that builds an object, gets one from an application's method, or captures a query with its own include or projection. A field a subtype of T declares, one a subtype of a member's type declares, and one beneath a member EF Core does not map, count too. A member that can hold an object of any type asks for nothing on its own. It does so in both tiers, typed and dynamic, for a Filter and a Segment. Until 3.2.0 only a simple field denied at the top of T asked for one. A denial beneath a navigation nothing loads never leaves the database, so an entity whose only denials sit there is read exactly as in 3.1.0.
  • What. The allowed members, which replace the allowed scalars. A row a projection builds keeps the members its initializer assigns. An entity keeps its mapped columns, converted and JSON ones included except a converted one that can hold an object of any type, its owned and complex members, and every collection of simple values such as byte[] or List<string>. Rows in memory keep their values. A member holding an object is kept whole when nothing it can hold is denied, narrowed to the allowed fields where the core's narrowing translates, and otherwise left out whole.
Fixed (security): a denial beneath a member was not enforced
With every denied field beneath a member and none at the top of T, nothing was synthesized, and the whole row came back with the denied value in it: in a list or nested object of a row projected before ApplyPolicy, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member — typed and dynamic, in both tiers, for a Filter and a Segment.
Fixed (security): what a query loads was read too narrowly
An include named from the root and reached through Select(o => o.Customer), SelectMany or Join, a projection behind another Select, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or a property of any name each loaded a denied value the gate read as unloaded, and so did an injected DbContext or EF Core 7's asynchronous loader delegate, and a reshaping lambda that got its row from an application's method or from a captured query or object. An application's own collection class hid its own denied members, and a guarded query through a provider wrapping EF Core's, such as LinqKit's AsExpandable, ran tracking, so the context filled in navigations it already held and a masked value became a pending change. A field a subtype declares — a derived entity's, or a subclass's held by a base-typed member — was not read at all, nor was a [DwDenied] on an override, on a public member hidden with new or on an interface member's implementation, and under a "*" deny a path the walk never asked about was allowed. Each came back.
Rows of a derived type come back as T
When a type the model derives from T, or a loaded subclass of a row in memory, declares a denied field, the rows are projected to T, so a derived type's allowed fields are dropped too, and a member declared as a base type is narrowed to it. Over an abstract T the typed terminals fail with SelectTypeMustHaveParameterlessConstructor; the dynamic ones return its members. Query the derived type, OfType<Company>(), to keep its fields. Rows in memory can be any loaded subtype, so there the rows are projected whenever one declares a denied field. A [DwDenied] on an override, on a public member a subtype hides with new, or on an interface member's implementation, through a variant instantiation too, denies the base path for every row, in every clause.
Fixed (security): a denied member that holds no simple value came back
A field denied at the top of T whose own type is not a simple value — a byte array, a list, an owned object, a JSON column — synthesized no projection either, so with nothing else denied the whole row came back with it.
Nested objects and lists come back
In 3.1.0, as soon as any field was denied, every nested object and list of a row projected before ApplyPolicy came back null or empty, and so did an entity's columns holding an object, its owned and complex members and its collections of simple values. They are returned now, whole or narrowed, except a converted value that can hold an object of any type, which the policy cannot see into. A member that cannot be narrowed is left out whole, and the trace records a Dropped decision whose reason starts left out whole.
What a projection leaves out
Once a projection is needed it leaves out an entity's navigations, included ones too, since projecting one would load it: under Convenience name the navigation in Selects to get it narrowed, and under Strict name its allowed fields. It leaves out the objects a row in memory holds, since a kept object is the caller's own and a transform would change it in place, and a value EF Core does not map, which EF Core could compute only by reading the whole entity, the denied columns included. A member with no setter and a member named with one of the parser's words are left out too. A typed query projects into T, so T needs a public parameterless constructor for it, as it already did (point 1).
A forced scope on a list's element type filters rows, not elements
A forced scope declared on a list's element type asks for no projection on its own. It filters the rows that hold the list, never its elements, so Selects naming the list returns every element, those the scope excludes included, as in every release. A projection needed for another reason leaves such a list out whole. Scope the elements where the row is built.

26. Selects Naming a Member Is Gated Against Every Denial Beneath It

When Selects names a navigation with a denied field beneath it, the Convenience tier replaces the entry with the allowed fields beneath it, and the Strict tier refuses it. Since 3.2.0 the gate finds every denial beneath the member, and refuses, with FieldDeniedForSelect, a narrowing it cannot build as gated. See A navigation named in Selects.

Selects namesUntil 3.1.0Since 3.2.0
A navigation whose key, Id, is deniedThe convenience tier narrowed the key away, and the core's typed projection, which adds the key of every nested node it builds, put it back.Refused in both tiers, as naming a sibling of the key already was.
A navigation named through another, Main.Lead, when Main.Id is deniedKept, and the projection added Main's key.Refused: the key of every node the path passes through is gated.
A member typed as a collection the core does not unwrap — IReadOnlyList<T>, IReadOnlyCollection<T>, Collection<T> or an application's own — with a denied field beneath itEvery field beneath it came back, the denied ones included, in both tiers: the projection gate read collections through a narrower list than the attribute walker, and found nothing beneath the member.The gate reads collections the way the walker does. The strict tier refuses the denied field, and the convenience tier's narrowing, which the core cannot project, is refused too.
A member that carries a field denied where no path reaches it: deeper than four segments, inside a framework generic such as Dictionary<string, T>, declared by a subtype of its type, in an entity navigation's owned chain or converted column, or, under a "*" deny, on a path the walk never asks aboutReturned, the denied field included.Refused under Strict. Under Convenience narrowed where the core can narrow it, which builds the declared type, and refused where it cannot.
A member with a denied property that has no setter beneath it, or a rule on a path reached through a cycleNot found, so the member came back with it.Found: the gate reads the providers' rules as well as the walk.

A member that cannot be narrowed at all — a column, a complex property or a member stored as JSON, a member of a row in memory, or one a projection builds some way the core cannot narrow — is refused in both tiers when something beneath it is denied.

Fixed (security): named members carried denied values out
A request that ran on 3.1.0 can now be refused. Under the Convenience tier the refusal names the denied key, the first denied field beneath the member, or, for a denial no path names, the member itself; under Strict its FieldPath is "*". A request that sends no Selects is not refused for such a member: its synthesized projection narrows the member or leaves it out whole (point 25).
What the policy cannot see into
A member typed object, a framework interface or a collection that is not generic, such as IEnumerable, ArrayList or an application's own, is opaque to the policy: it never asks for a projection, a synthesized projection over a projected row or rows in memory leaves it out, and naming it returns whatever it holds. A framework generic holding a policed type, such as Dictionary<string, LineDto>, has no paths beneath it: naming it is refused in both tiers where the core cannot narrow it, narrowed away under Convenience beneath a navigation, and a synthesized projection leaves it out. Hold such values in a list of the policed type instead.

27. DefaultOrder Reaches a Projection That Builds T

In 3.1.0 a Select anywhere in the chain kept a guarded query in its own order, because a default applied after a projection could name a field the projection left out, which EF Core cannot translate. Since 3.2.0 only the outermost Select counts, because it makes the rows the default orders. When it builds T in an object initializer and assigns every field the [DwEntity(DefaultOrder)] names a column, at every level of a nested path, the default applies. A column is a member the EF Core model maps on the entity the Select reads, read directly, through reference navigations or through EF.Property; in memory any assigned field is one. A value the projection computes, by any method or operator, even one EF Core could translate, a member the model does not map, a constructor with arguments, a default field the initializer does not assign, or a nested path through anything but an initializer still leaves the query in its own order: ordering by it could fail where the unguarded query ran.

[DwEntity(DefaultOrder = "CreatedAt desc, Id")]
public class TicketRow
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Title { get; set; } = string.Empty;
}

// 3.1.0: unordered. 3.2.0: ordered by CreatedAt desc, Id.
var rows = db.Tickets
    .Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title })
    .ApplyPolicy(caller)
    .ToList(new Filter());
  • A projection composed on the guarded handle — the guarded Select, or a guarded Filter whose Selects is set — keeps the rest of the chain unordered, even when it keeps every default field. So guarded.Select(["Id", "Title"]).Page(page) pages as it did in 3.0.0, unordered.
  • A Filter composed on the handle that sent orders gets no default later in the chain, even when the policy dropped every one of them, as a composed Order already did not.
A projected query that ran unordered can now be ordered
A guarded query over such a projection that sends no orders now comes back in the declared order, where it used to come back in the database's. Paging through it is stable if the default ends with a unique field.

28. Every Async Terminal Takes a CancellationToken

Since 3.2.0 every asynchronous terminal, guarded and unguarded, has overloads that take a CancellationToken: ToListAsync and ToListAsyncDynamic with a Filter, ToListAsync with a Summary, and ToListAsync with a Segment. The token reaches the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged, so code compiled against 3.1 still binds. That brings the extension methods to 28. A reflection lookup by name alone finds more overloads than it did, and where it found one — ToListAsyncDynamic, on the extension class and on the guarded handle — it now finds several, so Type.GetMethod given only the name throws AmbiguousMatchException; pass the parameter types.

ToListAsync(filter, default) no longer compiles
default fits both bool getQueryString and the new CancellationToken overload, so the call is ambiguous (CS0121). So are ToListAsyncDynamic(filter, default) and ToListAsync(summary, default), on a query and on the guarded handle alike. Write false, a token, or a named argument.
await query.ToListAsync(filter, default);              // CS0121 since 3.2.0
await query.ToListAsync(filter, false);                // as 3.1 read it
await query.ToListAsync(filter, cancellationToken);    // the new overload
The dynamic and summary reads go through EF Core
ToListAsyncDynamic and the async Summary read through EF Core's ToListAsync instead of Dynamic LINQ's ToDynamicListAsync, which had no token to pass on, and the async Summary counts through CountAsync where it counted synchronously. So on an EF Core query a canceled token now reaches the database. The rows and the counts are the same. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread.

29. A Type in a Namespace That Starts with System Is Policed

The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with System for the framework's, so an application namespace such as SystemsCorp.Payroll or SystemX.Domain got no policy beneath its types. A [DwDenied] field on such a type, reached through a member, was returned, filterable and sortable. Only System and the namespaces beneath it are the framework's now.

Fixed (security): an application namespace was read as the framework's
A guarded request that filtered on, sorted by or selected such a field ran on 3.1.0. It is now refused or dropped, as for any denied field.

See also