DynamicWhere.ex
3.2.0
dotnet add package DynamicWhere.ex --version 3.2.0
NuGet\Install-Package DynamicWhere.ex -Version 3.2.0
<PackageReference Include="DynamicWhere.ex" Version="3.2.0" />
<PackageVersion Include="DynamicWhere.ex" Version="3.2.0" />
<PackageReference Include="DynamicWhere.ex" />
paket add DynamicWhere.ex --version 3.2.0
#r "nuget: DynamicWhere.ex, 3.2.0"
#:package DynamicWhere.ex@3.2.0
#addin nuget:?package=DynamicWhere.ex&version=3.2.0
#tool nuget:?package=DynamicWhere.ex&version=3.2.0
DynamicWhere.ex
JSON-driven queries for Entity Framework Core.
A powerful, versatile library for dynamically composing complex filter, sort, paginate, group, aggregate, and set-operation (Union / Intersect / Except) expressions in Entity Framework Core applications — all driven by simple JSON objects from any front-end or API consumer.
Full reference, JSON cookbook, and tuning guide → doc.dynamicwhere.com
Using an AI coding agent?
Point it at doc.dynamicwhere.com/llms.txt — the entire API surface in one plain-text file: every public type and member of the four packages, the JSON on the wire, every error string, the whole policy layer, the cache, and the traps that produce code which compiles and is quietly wrong.
Read https://doc.dynamicwhere.com/llms.txt before writing any
DynamicWhere.ex code. It is the complete API surface.
Works with Claude, Copilot, Cursor, Codex or anything else that can read a URL. If yours cannot, copy it from the reference page.
Why DynamicWhere.ex?
Stop concatenating LINQ predicates by hand. Your front-end sends one JSON shape; the back-end calls a single extension method. You get back a strongly-typed, paginated result.
- JSON in →
IQueryable<T>out. No string LINQ. No manual expression trees. - Three composable shapes —
Filter,Segment,Summary— cover where, set operations, and group-by reporting. - Twenty-eight extension methods on
IQueryable<T>andIEnumerable<T>, every async one with overloads that take aCancellationToken. - Nested navigation through references and collections, with auto-wrapped
.Any()lambdas where needed. - Heterogeneous
Condition.Values— pass raw numbers, booleans, strings; normalized perDataType. - Thread-safe reflection cache with FIFO / LRU / LFU eviction and five tuned presets.
- Field-level policies (new in 3.0) — decide per caller what may be filtered, sorted, selected, grouped, aggregated and seen. Opt-in: nothing enforces until you ask.
- Free Forever. Targets .NET 6, 7, 8, 9, 10.
Install
dotnet add package DynamicWhere.ex --version 3.2.0
Or via Package Manager:
Install-Package DynamicWhere.ex -Version 3.2.0
Dependencies (restored automatically):
| Package | Version |
|---|---|
Microsoft.EntityFrameworkCore |
6.0.22 |
System.Linq.Dynamic.Core |
1.6.7 |
Microsoft.Extensions.Configuration.Abstractions |
6.0.0 |
Microsoft.Extensions.Configuration.Binder |
6.0.0 |
Microsoft.Extensions.DependencyInjection.Abstractions |
6.0.0 |
Quick Start
Front-end / API body — pure JSON:
{
"conditionGroup": {
"connector": "And",
"conditions": [
{ "sort": 1, "field": "Price", "dataType": "Number", "operator": "GreaterThan", "values": [50] },
{ "sort": 2, "field": "Category.Name", "dataType": "Text", "operator": "IEqual", "values": ["electronics"] }
],
"subConditionGroups": []
},
"selects": ["Id", "Name", "Price", "Category.Name"],
"orders": [{ "sort": 1, "field": "Price", "direction": "Descending" }],
"page": { "pageNumber": 1, "pageSize": 10 }
}
Back-end — one method call:
using DynamicWhere.ex.Source;
using DynamicWhere.ex.Classes.Complex;
using DynamicWhere.ex.Classes.Result;
app.MapPost("/products/search", async (Filter filter, AppDbContext db) =>
{
FilterResult<Product> result = await db.Products.ToListAsync(filter);
return Results.Ok(result);
});
Response shape (FilterResult<Product>):
{
"pageNumber": 1,
"pageSize": 10,
"pageCount": 5,
"totalCount": 42,
"data": [
{
"id": 7,
"name": "Laptop Pro",
"price": 1299.99,
"isActive": false,
"createdAt": "0001-01-01T00:00:00",
"category": { "id": 5, "name": "Electronics" }
}
],
"queryString": null
}
A typed row is a whole Product: selects decides which members are read, and the rest hold their defaults. ToListAsyncDynamic returns only the selected members.
That's the whole loop. Full walk-through in Quick Start.
What's inside
Three composable shapes
| Shape | Pipeline | Use when |
|---|---|---|
Filter |
where → order → page → select | Standard list / search / detail endpoints |
Segment |
set1 ∪/∩/∖ set2 ∪/∩/∖ set3 → order → page | UNION / INTERSECT / EXCEPT across multiple condition sets |
Summary |
where → group → having → order → page | Aggregate reporting (GROUP BY + SUM / AVG / COUNT …) |
Twenty-eight extension methods
Projection, filtering, composition, and materialization on IQueryable<T> and IEnumerable<T>:
| Group | Methods |
|---|---|
| Projection | .Select<T>(fields) · .SelectDynamic<T>(fields) |
| Filtering | .Where<T>(Condition) · .Where<T>(ConditionGroup) |
| Composition | .Order<T> · .Page<T> · .Group<T> · .Filter<T> · .FilterDynamic<T> · .Summary<T> |
| Materialization | .ToList<T>(Filter) · .ToListAsync<T>(Filter) · .ToListDynamic<T>(Filter) · .ToListAsyncDynamic<T>(Filter) · .ToList<T>(Summary) · .ToListAsync<T>(Summary) · .ToListAsync<T>(Segment) |
Every async terminal also has overloads that take a CancellationToken, which reaches the count and the read. ToList(Filter), ToListDynamic(Filter) and ToList(Summary) also run on an IEnumerable<T>.
Full signatures, validations, and return types → Extension Methods Reference.
Operators & data types
Twenty-eight comparison operators across seven data types — case-sensitive and case-insensitive variants of every text operation:
- Equality:
Equal·IEqual·NotEqual·INotEqual - Substring:
Contains·IContains·NotContains·INotContains·StartsWith·IStartsWith·EndsWith·IEndsWith(+Not*andI*of each) - Set:
In·IIn·NotIn·INotIn - Range:
GreaterThan·GreaterThanOrEqual·LessThan·LessThanOrEqual·Between·NotBetween - Null:
IsNull·IsNotNull
Data types: Text · Guid · Number · Boolean · DateTime · Date · Enum. Full matrix → DataType reference.
Aggregations
Count · CountDistinct · Sumation · Average · Minimum · Maximum · FirstOrDefault · LastOrDefault. With optional Having post-filter referencing aggregate aliases.
Nested navigation
Dotted paths through reference and collection properties, with .Any() lambdas inserted automatically where the path crosses a collection.
{ "field": "Orders.OrderItems.ProductName", "dataType": "Text",
"operator": "IContains", "values": ["laptop"] }
Becomes:
Orders.Any(i1 => i1.OrderItems.Any(i2 =>
i2.ProductName != null && i2.ProductName.ToLower().Contains("laptop")))
Sorting takes the same paths. .Any() yields a boolean, so ordering reduces each collection segment to one comparable value instead — the smallest element ascending, the largest descending:
{ "sort": 1, "field": "OrderItems.Product.Name", "direction": "Ascending" }
Becomes:
OrderItems.Min(Product.Name) asc
Rows with an empty collection sort as null (or the type default for non-nullable value types). A path may not end on a collection of entities — sort by a scalar inside it (Tags ✗ → Tags.Value ✓).
Field-level policies
New in 3.0. Entirely opt-in — a project with no policy attributes and no DwPolicy.Configure call behaves exactly as 2.1.5.
The library accepts a JSON Filter from any caller and turns it into a query. Policies add the missing question: who is asking, and what are they allowed to see?
// Once, at startup.
DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, HashSalt = secret });
// Once per request.
var caller = await DwPolicy.PrepareAsync(
new DwPolicyContext()
.WithSubject(DwSubjectKind.User, userId)
.WithSubject(DwSubjectKind.Role, "Support"));
// Then query through the guarded handle instead of the raw IQueryable.
var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter);
Requests are sanitized before the query is built; results are transformed after they materialize. The query engine itself is unchanged.
[DwEntity(RequirePolicy = true)] // an unguarded read throws instead of returning rows
public class Employee
{
[DwMask(MaskStrategy.Email), DwNoOrder]
public string Email { get; set; } // s*************@c******.com on the way out
[DwForceWhere(Operator.Equal, Value = "true")]
public bool IsActive { get; set; } // ANDed into every guarded query, asked for or not
[DwGeneralize(GeneralizeMode.Round, Step = 5000, AllowAggregate = true, MinGroupSize = 5)]
[DwNoOrder, DwAudit, DwCost(10)]
public decimal Salary { get; set; } // rounded, aggregatable only over groups of 5+
[DwDenied]
public JsonDocument? WorkSchedule { get; set; } // absent from /schema, rejected by POST /rules
}
Six features, per field: Where · Select · Order · Group · Aggregate · Segment.
| Attribute | What it does |
|---|---|
[DwDeny], [DwDenied], [DwNoWhere], [DwNoSelect], [DwNoOrder], [DwNoGroup], [DwNoAggregate] |
Refuse features for a field |
[DwOperators] |
Restrict which operators may target it |
[DwAlias] |
Give it a public name, renamed back on the way out |
[DwForceWhere] |
Add a predicate to every guarded query — tenant scope, soft delete, ownership |
[DwRequireWhere] |
Make a filter on it mandatory |
[DwMask] |
Obscure the value — 9 strategies: Full Partial Email Phone Regex Fixed Hash Null Tokenize |
[DwMutate], [DwDefault], [DwGeneralize], [DwTruncate], [DwFormat] |
The other five transforms |
[DwDescribe], [DwAllowedValues], [DwCost], [DwAudit] |
Schema discovery, query budget, audit trail |
Sealed by default
Attributes cannot be lifted by a runtime rule unless you mark them Overridable = true. Six precedence levels decide every field, sealed attributes first and overridable attributes last, with dynamic user, role, tenant and global rules in between.
Configuration, and a field picker that fits on a screen
The whole posture binds from appsettings.json, environment variables or a vault. A key nothing answers to refuses to start, because a misspelt MinGropSize sitting in a file doing nothing is exactly the failure the rest of this layer exists to prevent.
builder.Services.AddDwPolicies(
builder.Configuration.GetSection("DynamicWhere:Policies"),
options => options.Entities.Expose<Employee>("Employee"));
POST /dw-policies/schema describes an entity for a filter UI, two levels deep by default and drillable a subtree at a time. The response is flat with a parent on every field and node, so a tree is one grouping pass on the client. → Admin API
Rules without a redeploy
An optional store supplies rules at runtime, split into a cached broad zone and a per-request narrow zone. In-memory ships in the core package; Redis and Entity Framework Core are separate packages. A store can never grant a field the source code seals.
The control you would not guess: MinGroupSize
SUM, MAX and MIN run in SQL, against the stored value, before any mask can apply — so MAX(Salary) over a department of one returns that person's exact pay. Aggregating a transformed field is therefore denied by default, opted into with AllowAggregate = true, and bounded by MinGroupSize, which suppresses any group smaller than k.
It defaults to 5. Write MinGroupSize = 1 to switch it off and it is off, in production, with nothing refused and nothing warned about — the setting starts unset rather than at one precisely so that "off" and "never configured" stay different sentences. → Security & k-anonymity
Hiding a value you still want to group by
Hash and Tokenize both keep a column groupable and joinable while hiding what is in it. The difference is where the secret lives.
A hash is computed from the value, with HMAC-SHA256 keyed by HashSalt — at least 16 characters, or it is refused where it is written. Whoever holds that salt can recompute every digest the deployment ever emitted.
A token is drawn at random and written into TokenVault, so the only way back is to read the vault: a store you can lock, move and revoke separately from the data. Three ship — in-memory in the core package, Redis and Entity Framework Core in the providers — all held to one conformance suite.
new DwPolicyOptions { HashSalt = secret, TokenVault = new RedisTokenVault(redis) }
Neither closes equality, and that is the point of both: the same value maps to the same output so the column stays usable, which also means anyone who can write a chosen value and read it back learns that one value's stand-in. → Transforms
The four packages
| Package | What it adds |
|---|---|
DynamicWhere.ex |
Everything above |
DynamicWhere.ex.Policies.Redis |
Rules in Redis, pub/sub invalidation with a poll behind it |
DynamicWhere.ex.Policies.EntityFrameworkCore |
Rules in any EF Core provider |
DynamicWhere.ex.Policies.AspNetCore |
Admin API — schema, rules, explain, simulate, health. Refuses to mount without a named authorization policy |
Full guide → doc.dynamicwhere.com/docs/policies
Reflection cache
A thread-safe ConcurrentDictionary-backed cache across three stores (TypeProperties · PropertyPath · CollectionElementType) eliminates reflection overhead on repeated queries. Three eviction strategies, and five preset factories beside the default:
| Preset | MaxSize | Eviction | Use case |
|---|---|---|---|
new CacheOptions() |
1000 | LRU | General purpose |
ForHighMemoryEnvironment() |
5000 | LRU | Servers with ample RAM |
ForLowMemoryEnvironment() |
250 | LFU | Constrained environments |
ForDevelopment() |
100 | FIFO | Testing & debugging |
ForHighFrequencyAccess() |
2000 | LFU | Repeated queries on same types |
ForTemporalAccess() |
1500 | LRU | Recent-access-heavy workloads |
using DynamicWhere.ex.Optimization.Cache.Source;
CacheExpose.Configure(CacheOptions.ForHighMemoryEnvironment());
CacheExpose.WarmupCache<Product>("Name", "Category.Name", "Price");
Full tuning guide → Cache & Optimization.
Error handling
Every validation failure throws LogicException with a structured error code. Catch at your API boundary and surface as a 400:
try
{
var result = await db.Products.ToListAsync(filter);
return Results.Ok(result);
}
catch (LogicException ex)
{
return Results.BadRequest(new { code = ex.Message });
}
Full code reference → Error Codes.
Documentation
The complete reference — every enum, class, extension method, validation rule, JSON example, and cache option — lives on the official site:
→ doc.dynamicwhere.com
| Section | What's there |
|---|---|
| Getting Started | Introduction, installation, quick start |
| Enums | Every DataType, Operator, Connector, Direction, Intersection, Aggregator, Cache enum |
| Classes | Condition, ConditionGroup, ConditionSet, OrderBy, GroupBy, AggregateBy, PageBy, Filter, Segment, Summary, Result types |
| Extension Methods | All 28 methods with signatures, validations, examples |
| Validation Rules | What's checked and what throws |
| JSON Cookbook | 13 copy-pasteable end-to-end examples |
| Field-Level Policies | Attributes, precedence, masking, dynamic rules, admin API, k-anonymity |
| Cache & Optimization | Architecture, stores, options, presets, monitoring |
| Error Codes | Every LogicException message |
| Breaking Changes | Known limits and migration notes |
Version 3.2.0 highlights
Upgrade note — the security fixes and the three changes alter what code written for 3.1.0 does, and the new overloads can stop a call from compiling. Read these before bumping.
- Fixed (security): a field denied beneath a member reached a caller who sent no
Selects. A guarded query synthesizes a projection for a denied field, and it did so only when a simple field at the top of the type was denied. With every denial beneath a member, the whole row came back with the denied value in it: in a list or nested object of a row projected beforeApplyPolicy, 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 aFilterand aSegment. Such a denial now synthesizes the projection whenever its value can reach the result. On an entity that means beneath a column, an owned or complex member, or a navigation the query loads throughInclude, an automatic include or a lazy loader. A denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as before. - Fixed (security): what a query loads, and what a member holds, was read too narrowly. An include named from the root and reached through
Select(o => o.Customer),SelectManyorJoin, a projection behind anotherSelect, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or any property each loaded a denied value the gate read as unloaded. An injectedDbContextand EF Core 7's asynchronous loader delegate loaded one too, and so did 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'sAsExpandable, 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, a subclass's held by a base-typed member, an open generic one's — was not read at all, nor was a[DwDenied]on an override, on a member hidden withnewor on an interface member's implementation, and under a"*"deny with exact allows a path the walk never asked about (past four segments, around a cycle, with no setter) resolved as allowed.Selectsnaming an entity navigation returned a denial in its owned chain past four segments or in a convertedDictionary<string, T>column. All of these are read now: from the EF Core model for an entity, so only what loads counts, and from every loaded subtype for a projected or in-memory row. A denial on an override, a public member hidden withnewor an implementation, through a variant instantiation too, applies to the base type's or the interface's path, on every row and in every clause. - Fixed (security): a denied member that holds no simple value came back. A field denied at the top of the type 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 it came back.
- Fixed (security): an application namespace starting with
Systemgot no policy. The attribute walker read any namespace starting with "System" as the framework's, so an application namespace such asSystemsCorp.Payrollgot no policy beneath its types, and a[DwDenied]field there was returned, filterable and sortable. OnlySystemand the namespaces beneath it are the framework's now. - Fixed (security): a navigation narrowed around its own denied key got the key back. Under the convenience tier,
Selectsnaming a navigation whose key (Id) is denied was narrowed to the allowed fields beneath it, and the core's typed projection added the key back. Such a narrowing is refused withFieldDeniedForSelectin both tiers, as naming a sibling of the key already was. A navigation named through another, such asMain.Lead, now gates the key ofMain, which the projection adds; it did not. - Fixed (security):
Selectscould name a member whose denials the gate did not see. A member typed as a collection the core does not unwrap —IReadOnlyList<T>,IReadOnlyCollection<T>,Collection<T>or an application's own — returned every field beneath it, denied ones included, in both tiers, because the projection gate read collections through a narrower list than the attribute walker. It reads them the same way now, and a narrowing the core cannot project is refused withFieldDeniedForSelect. Denials beneath a named member are also read from the policy's own rules, so a denied property with no setter and a rule on a path reached through a cycle are found. A member carrying a field denied where no path reaches it — deeper than the walker, inside a framework collection such asDictionary<string, T>, or on a subtype — is refused under the strict tier, and under the convenience tier narrowed where the core can narrow it and refused where it cannot. - Changed: the synthesized projection keeps what the source carries. It kept simple fields only, so every nested object and list of a row projected before
ApplyPolicycame back null or empty as soon as any field was denied. A row a projection builds — the outermostSelectconstructs it, in an object initializer or with a constructor, as indb.Roles.Select(r => new RoleRow { … })— keeps the members its initializer assigns. An entity, or aSelectthat hands back an entity such asdb.Orders.Select(o => o.Customer), 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 asbyte[]orList<string>. 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, recorded asDroppedwith a reason startingleft out whole. An entity's navigations, the objects of a row in memory, and a value EF Core does not map are left out, and the type needs a public parameterless constructor for the typed projection, as it already did. When a derived type in the model, or a loaded subclass of a row in memory, declares a denied field, the rows come back as the queried type, so a derived type's allowed fields are dropped too; query the derived type withOfType<T>()to keep them. A member that can hold an object of any type, a geometry or a JSON bag say, asks for no projection on its own, and a chain that reaches its rows through a navigation,SelectMany,JoinorGroupBycounts every navigation as loaded only when it 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. - Changed:
[DwEntity(DefaultOrder)]reaches a projection that builds the row. A guarded query over a projected source takes the default when the outermostSelectbuilds the type in an object initializer and assigns every field the default names a column, at every level of a nested path — a mapped member read directly, through reference navigations or throughEF.Property:Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt })for"CreatedAt desc, Id". A computed value or any other projection still leaves the query in its own order. ASelect, or aFilterwithSelects, composed on the guarded handle keeps the rest of the chain unordered, and a composedFilterthat sent orders gets no default later in the chain, as a composedOrderalready did not. - Changed: the async dynamic
Filterand the asyncSummaryread through EF Core.ToListAsyncDynamicandToListAsync(Summary)read with EF Core'sToListAsyncinstead of Dynamic LINQ'sToDynamicListAsync, which had no token to pass on, and the summary counts withCountAsyncwhere it counted synchronously. So on an EF Core query a canceled token now reaches the database. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread. - New: a
CancellationTokenon every async terminal, guarded and unguarded:ToListAsyncandToListAsyncDynamicwith aFilter,ToListAsyncwith aSummary, andToListAsyncwith aSegment. 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.ToListAsync(filter, default),ToListAsyncDynamic(filter, default)andToListAsync(summary, default)no longer compile, becausedefaultfits bothgetQueryStringand the token: writefalse, a token, or a named argument. A reflection lookup ofToListAsyncDynamicby name alone now finds three methods where it found one, and one ofToListAsyncfinds more than it did. - Known limits. Once a projection is needed, an entity's navigations are left out, included ones too; under the convenience tier name one in
Selectsto get it narrowed, and under the strict tier name its allowed fields. A forced scope declared on a list's element type filters the rows that hold the list, never its elements, soSelectsnaming the list returns every element and a synthesized projection leaves the list out; scope the elements where the row is built. A member typedobject, a framework interface or a collection that is not generic (IEnumerable,ArrayList) is opaque to the policy, and a framework generic holding a policed type, such asDictionary<string, LineDto>, has no paths beneath it: hold such values in a list of the policed type. A member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded. Rows in memory can be any loaded subtype, so they are projected whenever one declares a denied field. A denial on an override or anewmember counts for every loaded subtype, a class EF Core does not map included. A repository or specification method in a reshaping lambda runs once more per guarded read, and on EF Core 6 a reshaped query whose projection EF Core 6 cannot translate fails guarded where it ran unguarded./simulatehas no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value.
Version 3.1.0 highlights
Upgrade note — eleven behaviour changes, listed first. Read these before bumping.
- Fixed (security): members named
Root,ItorParent. The expression parser read them as itsroot/it/parentkeywords, soRoot.Nameaddressed the row's ownName, andParentthrew. UnderApplyPolicya projection ofRoot.Namereturned a[DwDenied]column, and a[DwForceWhere]scope reached through such a navigation filtered the wrong column. Expressions are now parsed with the keywords off, through a configuration of the library's own:ParsingConfig.Defaultis no longer read. The words the parser does keep —new,iif,np,isnull,is,as,cast,true,false,null— are refused by name when one begins a field path, in every clause and guarded or not, withLogicExceptionFieldPath[{path}]StartsWithReservedName. Nine of them used to throw, and a member namedNullwas read as the null literal, so the query returned no rows and no error. Only a path's first segment is affected:Owner.Newnames the member, and the remedy for such a column is to rename the property and map it with[Column("New")]. - Fixed:
DateTimeOffsetcolumns. Every comparison on aDateTimeOffsetmember threw, andDataType.Dateon any nullable date member threw with it. The predicate is now built from the member's own type — a null guard only where the member can be null, a literal of the member's type,.Value.Dateunder the guard — andIsNull/IsNotNullon a non-nullable date member of the entity itself answerfalse/true. Reached through a navigation, they test the navigation. Verified against Npgsqltimestamptz. - Changed: a date value is ISO 8601 or a declared format, never a guess. The server's culture used to decide, so
01/09/2026was 1 September on one server and 9 January on another. Now ISO 8601 extended calendar dates (2026-09-01, with or without a time and zone) and year-first dates are accepted everywhere; a day/month-first date is refused with the newAmbiguousDateFormatunless the deployment declares its order once —DwDates.Configure(o => o.Formats.Add("dd/MM/yyyy")).DateTimeOffsetvalues are normalised to UTC, andDateOnlycolumns can be filtered at all.Configurerefuses a format whose own text ISO 8601 or a year-first date already reads, such asyyyy-MM-dd'T'HH:mm:ss'Z': declaring one could only change what such a value means. - Changed: an unprepared context is refused with or without a store.
ApplyPolicy(ctx)throwsPolicyContextNotPreparedfor a context that never went throughDwPolicy.PrepareAsync, with or without a store configured. An attributes-only deployment used to accept it and would have started refusing the day it gained a store. - Changed:
Segmentset operations run in the database.Intersectreturned nothing,Exceptremoved nothing andUnioncounted a row once per set whenever the query was untracked, projected withSelects, or guarded byApplyPolicy— the sets were combined in memory by object reference. They are now one query:UnionandIntersectcombine the sets' conditions andExceptmatches rows by primary key, then the rows are ordered, paged and counted in SQL like a filter, so only the page is read. Sorting follows the database collation, andOrdersapply beforeSelects. - Changed:
PageCounton an unpaged result is1on filter, summary and segment results alike — it wasTotalCountfor the first two and0for a segment with condition sets. - Changed: two new caps refuse guarded requests 3.0.0 ran.
DwCaps.MaxConditionDepth(default 10) bounds how deeply condition groups nest, andDwCaps.MaxConditionSets(default 10) how many condition sets aSegmentmay carry, empty sets included. A guarded request nested eleven levels deep, or a segment with eleven sets, is now refused withCapExceededunless the deployment raises the cap. Unguarded calls are not affected. - Changed: two more caps, and a
Countthat costs.DwCaps.MaxConditionValues(default 1000) bounds the values one condition carries — anInwas one comparison per value for the price of one condition — andDwCaps.MaxAggregates(default 50) the aggregates one summary computes. A guarded request over either is refused withCapExceeded. An aggregate with no field, such as aCount, is now chargedDefaultFieldCosttowardMaxQueryCost; it was free. Every count cap is checked before any field name is resolved, so an oversized request is refused withCapExceededeven when it also names a field that does not exist. Unguarded calls are not affected. - Changed: a stable code where a sentence was.
Selecton a type it cannot construct throwsSelectTypeMustHaveParameterlessConstructor, with the type name on the newLogicException.Subject. - Changed: the strict tier keeps the policy trace off results. Under
DwTier.Strict,FilterResult<T>.Policy,SummaryResult.PolicyandSegmentResult<T>.Policyare null unlessDwPolicyOptions.IncludeTraceInResult = true. The trace names every dropped field, the attribute or rule that sealed it and every injected predicate, and an API that serializes its result hands all of that to the caller.PolicyQueryable<T>.LastTracestill holds it, and the convenience tier still returns it unless the option isfalse. - Changed: under the strict tier an unknown field and a denied field answer alike. A name that matches nothing is refused like a
[DwDenied]field, with that clause'sFieldDeniedFor…code, instead ofLogicExceptionConditionMustHasValidFieldName. Every such refusal carriesFieldPath"*"and noRuleIdorSourceOrigin, and a cap refusal names no path, so a caller can no longer list the columns they may not see one guess at a time. The side doors are shut too: inside a segment every field refusal isFieldDeniedForSegment,MaxQueryCostis checked after the field gates so a[DwCost]weight cannot tell a hidden field from a missing one, andMissingContextValuenames neither the scope's column nor its context key. The trace keeps the real path; the convenience tier and dry runs are unchanged. - Fixed (security): a long
Inlist ended the process.InandNotIn(andIIn/INotInon text) joined their values into one flat||/&&chain, one level of expression nesting per value, and EF Core walks that tree recursively: a single condition carrying about seven hundred values overflowed the request thread's stack, guarded or not, and a stack overflow cannot be caught. A list longer than 32 values is now a balanced tree of short chains; a list of 32 or fewer is written exactly as before, and the rows returned are the same. - Fixed: a local
DateTimenames its own moment on aDateTimeOffsetmember. A C#DateTimewhoseKindisLocal—DateTime.Now, or one Newtonsoft.Json read from text with an offset — placed inValuesunderDataType.DateTimeis written with its offset (2026-09-17T15:00:00+03:00). ADateTimeOffsetmember reads text with no zone as UTC, so a zonelessDateTime.Nowwould filter hours away on any host outside UTC. UnderDataType.Date, onDateTimeandDateOnlymembers, and for any otherKind, no zone is written; text values are read as sent. - New:
DwCaps.DefaultPageSize(off by default) bounds a guarded query that sends no page —MaxPageSizeonly ever bounded a caller who had asked for one. - New:
[DwForceWhere(..., AllowNull = true)]injects(field op value OR field IS NULL)in a group of its own, so a caller'sOrcannot merge with it: the scope for a record that belongs to one tenant or to none. The context value is still required.AllowNullwithIsNull/IsNotNullis refused on the attribute, throughForcedPredicateand in a stored rule. On a member that can never be null only the attribute is refused; a rule there, written without the type to hand, injects the comparison alone. The startup check now reports a refused attribute along with every other malformed[DwForceWhere]. Stored rules carry it asforced.allowNull. - New:
[DwEntity(DefaultOrder = "CreatedAt desc, Id")]is the order a guarded query takes when its caller sends none — through theFilterandSegmentterminals, the composableFilterandFilterDynamic, andPageon a source nothing has ordered or projected. The caller's own orders win, an already-ordered or projected query keeps its order, and a field this caller may not order by — or, in a segment, may not use in one — is left out and recorded in the trace, never refused. An audited field the default keeps is recorded as a use, as a caller's own order is; a field left out is not. Unguarded calls ignore it, and a[DwEntity]on a derived type replaces its base type's, so repeatDefaultOrderandRequirePolicythere. - New:
DwPolicyOptions.AuditRefusals(off by default) writes every refused guarded query to the caller's audit buffer, drained toIDwAuditSinklike a[DwAudit]event, so a caller probing for columns leaves a record.DwAuditEventgainsErrorCode, and the event names the field by its canonical path — under the strict tier too, although the caller's refusal said"*"— cut to 256 characters with control, format, line separator and paragraph separator characters escaped, so an invented name cannot forge a log line or reverse the text after it. - Fixed: a healthy policy store nobody wrote to refused every guarded query fifteen minutes after its last write; the composable
Groupon a guarded query returned the small groups the k-anonymity floor suppresses; it and the composableSummaryhanded back the floor's own count column; a forced null check built withForcedPredicate.FromContext, in code or in a stored rule, failed every guarded query on its type, and is now refused where it is built; and every invalid field name a caller sent kept an access record in the reflection cache for the life of the process, so unique invented names grew memory without limit.
Version 3.0.0 highlights
- New: field-level policies. A layer that decides what each caller may filter, sort, select, group, aggregate and see — attributes for the compile-time half, an optional store for the runtime half. See above.
- New: three companion packages.
Policies.RedisandPolicies.EntityFrameworkCorehold rules;Policies.AspNetCoremounts the admin API, explain, simulate and health, and refuses to map without a named authorization policy. - No API breaks. The 2.x API is untouched and nothing enforces until you opt in.
FilterResult<T>andSummaryResulteach gain one nullablePolicyproperty, null when the query was not guarded. Two things to know: the package takes three newMicrosoft.Extensions.*dependencies, andPolicyExceptionderives fromLogicException, so an existingcatch (LogicException)now also receives policy refusals. - Worth knowing before you turn it on: gating costs nothing measurable, but transforming every row of a large result costs about 1.6x in time and 7x in allocations, because each value is rebuilt after materialization rather than in SQL.
MinGroupSizeships on at 5, so a guarded summary suppresses groups under five until you say otherwise — see Security and Configuration.
Version 2.1.5 highlights
- Fixed: the XML documentation shipped with the package. It drives IntelliSense in your IDE, and three defects degraded it — an unescaped generic argument in the
Select<T>comment truncated its remarks and returns text, threeToList/ToListAsyncoverloads were missing thegetQueryStringdescription, andCacheReporting.GetQuickHealthSummarydocumented a parameter it does not take. The library now builds with zero warnings. No API or behaviour changes.
Version 2.1.4 highlights
Security and correctness fix — upgrade recommended for everyone.
- Fixed: values carrying a backslash or a double quote broke the query. Condition values are embedded in the generated dynamic LINQ expression as string literals, and were not escaped. A search term ending in
\— the reported case was an Arabic term typed into a search box — escaped its own closing quote, so the parser ran on into the rest of the expression and threwSystem.Linq.Dynamic.Core.Exceptions.ParseException: ')' or ',' expected. Values are now escaped and matched literally,\and"included, across everyTextandEnumoperator. - Fixed: a crafted value could rewrite the predicate. The same missing escape let a value close its literal and append clauses of its own —
x") || (1==1) || Name.Contains("yturned aContainsfilter into an always-true predicate and returned every row. Values can no longer break out of their literal. - Fixed:
AggregateBy.Aliascould inject extra projection columns. The alias was only checked for dots, so"Total, 1 as Leaked"appended a term to the generatedSelect. Aliases must now be plain identifiers — a leading letter or underscore, then letters, digits, or underscores, with non-Latin letters allowed. Anything else already failed to parse, so nothing that worked is rejected; malformed aliases now throwAggregationMustHasValidAliasat validation time.
Version 2.1.3 highlights
- MIT licensed. Free forever for commercial and personal use, no license acceptance required.
Version 2.1.2 highlights
- Fixed: ordering across collection navigations.
OrderBy.Field = "Tags.Value"on aList<Tag>threwNo property or field 'Value' exists in type 'List\1'. Collection segments are now reduced to a single comparable value —Minascending,Max` descending — at any nesting depth. See Nested navigation.
Version 2.1.0 highlights
- Heterogeneous
Condition.Values—List<object>with type-safe coercion. Send raw numbers and booleans without quoting. JSON callers are unaffected; C# code assigning aList<string>no longer compiles. - Five tuned cache presets — pick
ForHighMemoryEnvironment,ForLowMemoryEnvironment,ForDevelopment,ForHighFrequencyAccess,ForTemporalAccess, or the defaultnew CacheOptions(). - Official documentation site launched at
doc.dynamicwhere.com.
See Breaking Changes & Known Limitations for the complete migration / caveat list.
Compatibility
- .NET: 6, 7, 8, 9, 10
- EF Core providers: SQL Server, PostgreSQL (Npgsql), MySQL (Pomelo), SQLite — anything that supports
ToQueryString()for the optionalgetQueryString: trueflag. - Enum storage: either.
DataType.Enummatches by member name (any case) or by number, and translates against anintcolumn as readily as astringone. What it does not do is the string operators:Containsand friends throw against an enum-typed member, so astringcolumn that merely holds enum names wantsDataType.Text. - Case-insensitive operators: emit
.ToLower()on both sides. Works well on SQL Server's default collation; watch for case-sensitive PostgreSQLClocale.
Links
- Documentation: doc.dynamicwhere.com
- NuGet: nuget.org/packages/DynamicWhere.ex
- Source: github.com/Sajadh92/DynamicWhere.ex
- Issues: github.com/Sajadh92/DynamicWhere.ex/issues
License
MIT — Free Forever. Copyright © 2023-2026 Sajjad H. Al-Khafaji.
Free for commercial and personal use, forever. No license acceptance required, no attribution beyond keeping the copyright notice, no restrictions on redistribution.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net6.0
- Microsoft.EntityFrameworkCore (>= 6.0.22)
- Microsoft.Extensions.Configuration.Abstractions (>= 6.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 6.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 6.0.0)
- System.Linq.Dynamic.Core (>= 1.6.7)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on DynamicWhere.ex:
| Package | Downloads |
|---|---|
|
DynamicWhere.ex.Policies.Redis
A Redis-backed policy store for DynamicWhere.ex field-level policies. Holds runtime rules in Redis and invalidates through pub/sub, so a policy change reaches every instance in milliseconds rather than at the next poll. Implements IDwPolicyStore and IDwPolicyWritableStore, and passes the same store conformance suite the in-memory and Entity Framework stores do. Broad rules load whole; user-level rules are fetched per caller, so a million users cost nothing at startup. Also ships RedisTokenVault, the durable store behind MaskStrategy.Tokenize, so a tokenized column still lines up after a restart and across instances. Requires DynamicWhere.ex. Full reference at https://doc.dynamicwhere.com. |
|
|
DynamicWhere.ex.Policies.EntityFrameworkCore
A database-backed policy store for DynamicWhere.ex field-level policies, built on Entity Framework Core with no raw SQL — so one package serves SQL Server, PostgreSQL, and any other EF Core provider. Implements IDwPolicyStore and IDwPolicyWritableStore, and passes the same store conformance suite the in-memory and Redis stores do. Also ships EfTokenVault, the durable store behind MaskStrategy.Tokenize, so a tokenized column still lines up after a restart. Ships the model rather than the migrations: apply the three entity configurations to your own DbContext and generate migrations for your own provider, or use the standalone DwPolicyDbContext. Version changes are polled from a single-row table, guarded by a concurrency token so two writers cannot lose an update. Requires DynamicWhere.ex. Full reference at https://doc.dynamicwhere.com. |
|
|
DynamicWhere.ex.Policies.AspNetCore
The administrative surface for the DynamicWhere.ex field-level policy layer: schema discovery, rule management, explain, simulate and health endpoints, a ClaimsPrincipal adapter, and per-request audit draining. Endpoints refuse to map without a named authorization policy. Requires DynamicWhere.ex. Targets .NET 6+. Full reference at https://doc.dynamicwhere.com. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 3.2.0 | 50 | 9/20/2026 | |
| 3.1.0 | 50 | 9/18/2026 | |
| 3.0.0 | 136 | 9/14/2026 | |
| 2.1.5 | 739 | 8/9/2026 | |
| 2.1.4 | 107 | 8/9/2026 | |
| 2.1.3 | 136 | 8/7/2026 | |
| 2.1.2 | 113 | 8/7/2026 | |
| 2.1.1 | 148 | 5/14/2026 | |
| 2.1.0 | 129 | 5/14/2026 | |
| 2.0.0 | 170 | 4/14/2026 | |
| 2.0.0-beta.4 | 1,066 | 3/6/2026 | |
| 2.0.0-beta.3 | 131 | 2/26/2026 | |
| 2.0.0-beta.2 | 113 | 2/23/2026 | |
| 2.0.0-beta.1 | 118 | 2/1/2026 | |
| 1.8.4 | 613 | 10/12/2024 | |
| 1.8.3 | 279 | 8/8/2024 | |
| 1.8.2 | 289 | 7/10/2024 | |
| 1.8.1 | 1,450 | 3/29/2024 | |
| 1.8.0 | 475 | 2/11/2024 | |
| 1.7.2 | 316 | 2/9/2024 |
v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken.
Security fix: a field denied for Select only beneath a member was not enforced when the caller sent no Selects. A guarded query synthesizes a projection for a denied field, and it did so only when a simple field at the top of the type was denied. With every denial beneath a member, 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. Such a denial now synthesizes the projection whenever its value can reach the result. On an entity that means beneath a column, an owned or complex member, or a navigation the query loads through Include, an automatic include or a lazy loader; a denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as before.
Security fix: what a query loads, and what a member holds, 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 any property each loaded a denied value the gate read as unloaded. An injected DbContext and EF Core 7's asynchronous loader delegate loaded one too, and so did 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, a subclass's held by a base-typed member or an open generic one's, was not read at all, nor was a [DwDenied] on an override, on a member hidden with new or on an interface member's implementation, and under a "*" deny with exact allows a path the walk never asked about (past four segments, around a cycle, with no setter) resolved as allowed. Selects naming an entity navigation returned a denial in its owned chain past four segments or in a converted Dictionary<string, T> column. All of these are read now: from the EF Core model for an entity, so only what loads counts, and from every loaded subtype for a projected or in-memory row. Rows whose derived type declares a denied field come back as the queried type. A denial on an override, a public member hidden with new or an implementation, through a variant instantiation too, applies to the base type's or the interface's path, on every row and in every clause.
Security fix: a field denied at the top of a type whose own type is not a simple value, such as a byte array, a list, an owned object or a JSON column, synthesized no projection either, so with nothing else denied it came back.
Security fix: the attribute walker read any namespace starting with "System" as the framework's, so an application namespace such as SystemsCorp.Payroll got no policy beneath its types, and a [DwDenied] field there was returned, filterable and sortable. Only System and the namespaces beneath it are treated as the framework's now.
Security fix: under the convenience tier, Selects naming a navigation whose element key (Id) is denied was narrowed to the allowed fields beneath it, and the core's typed projection added the key back. Such a narrowing is refused with FieldDeniedForSelect in both tiers, as naming a sibling of the key already was. A navigation named through another, such as Main.Lead, now gates the key of Main, which the projection adds; it did not.
Security fix: Selects naming a member typed as a collection the core does not unwrap, such as IReadOnlyList<T>, returned every field beneath it, denied ones included, in both tiers. The projection gate read collections through a narrower list than the attribute walker that puts policy on the fields beneath; it now reads them the same way. Denials beneath a named member are also read from the policy's own rules, so a denied property with no setter and a rule on a path reached through a cycle are found, and a member carrying a field denied where no path reaches it, deeper than the walker, inside a framework collection such as Dictionary<string, T> or on a subtype, is refused under the strict tier, and under the convenience tier narrowed where the core can narrow it and refused where it cannot.
Behaviour change: the projection synthesized for a guarded query that sends no Selects keeps what the source carries. It used to keep simple fields only, so every nested object and list of a row projected before ApplyPolicy came back null or empty as soon as any field was denied. 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>. 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, recorded as Dropped with a reason starting "left out whole". An entity's navigations, the objects of a row in memory, and a value EF Core does not map are left out, and the type needs a public parameterless constructor for the typed projection, as it already did. A member that can hold an object of any type, a geometry or a JSON bag say, asks for no projection on its own; a member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded; and a chain reaching its rows through a navigation, SelectMany, Join or GroupBy counts every navigation as loaded only when it 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.
Behaviour change: [DwEntity(DefaultOrder)] applies to a projected source whose outermost Select builds the type in an object initializer and assigns every field the default names a column, at every level of a nested path: a mapped member read directly, through reference navigations or through EF.Property. A computed value or any other projection still leaves the query in its own order. A Select, or a Filter with Selects, composed on the guarded handle keeps the rest of the chain unordered, and a composed Filter that sent orders gets no default later in the chain, as a composed Order already did not.
New: every asynchronous terminal has overloads taking a CancellationToken, guarded and unguarded: ToListAsync and ToListAsyncDynamic with a Filter, ToListAsync with a Summary, and ToListAsync with a Segment. The token reaches both the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged, so code compiled against 3.1 still binds. ToListAsync(filter, default) no longer compiles, because default fits both overloads, and a reflection lookup of one of these methods by name alone finds more overloads than it did.
Change: ToListAsyncDynamic and the asynchronous Summary read through EF Core's ToListAsync instead of Dynamic LINQ's ToDynamicListAsync, which had no token to pass on, and the Summary counts through CountAsync where it counted synchronously. So on an EF Core query a canceled token now reaches the database. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread.
v3.1.0 — Date comparisons that work on every date member, segments combined in the database, five new caps, a stable code where a sentence used to be, preparation enforced whether or not a store is configured, a policy bypass through members named Root, It or Parent closed, a long In list that ended the process fixed, the names the expression parser keeps refused by name, a strict tier that no longer discloses its trace or which fields exist, forced predicates that can let rows with no value through, a declared default order for guarded queries, and refused queries written to the audit.
Security fix: a member named Root, It or Parent was read as a System.Linq.Dynamic.Core keyword. Root.Name and It.Name filtered, sorted, grouped, aggregated and projected the row's own Name, Parent threw, and an alias named root, it or parent failed in Having and Summary orders. Under ApplyPolicy the gate decided on the path the caller named while the query read the row's own column: a dynamic projection of Root.Name returned a [DwDenied] Name, a filter on it tested the denied column, and a [DwForceWhere] scope reached through such a navigation filtered the row's own column. Every expression is now parsed with a library-owned ParsingConfig with the context keywords off. ParsingConfig.Default is no longer read, so a host's changes to it no longer reach DynamicWhere queries.
Security fix: a long In list ended the process. In, NotIn, IIn and INotIn on text, and In and NotIn on Guid, number and enum members, joined their values into one flat chain, one level of expression nesting per value, and EF Core and the expression compiler walk a query tree recursively: a single condition carrying about seven hundred values overflowed the request thread's stack, guarded or not, and a stack overflow cannot be caught. A list longer than 32 values is now nested as a balanced tree of flat chains of at most 32 terms. 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.
Behaviour change: a field path beginning with one of the expression parser's own words is refused by the library. The words are new, iif, np, isnull, is, as, cast, true, false and null, in any letter case, and only as a path's first segment: Owner.New names the member, and so do it, root and parent, whose keywords are off, and every predefined type name such as String, Math, Guid or Uri. The refusal is LogicException with the message FieldPath[{path}]StartsWithReservedName and the segment on Subject, raised where a path is validated, so conditions, orders, selects, group and aggregate fields and a DefaultOrder entry answer alike, guarded or not; under the strict tier it arrives as that clause's field denial, as every unusable name does, and the startup scan reports a DefaultOrder entry naming one. Before, seven of the words raised the parser's ParseException, true and false an InvalidOperationException, and null was read as the null literal, so the query returned no rows and no error; a typed Selects entry naming such a member worked and is now refused with the rest. Rename the property and map the column with [Column].
New: DwDates.Configure refuses a declared format whose own text ISO 8601 or a year-first date already reads, such as yyyy-MM-dd or yyyy-MM-dd'T'HH:mm:ss'Z'. Declaring one could only change what such a value means: a quoted Z is a letter, not a zone, so the format reads 12:00 as a wall time where ISO 8601 reads an instant, and on a DateTime member the ISO reading converts to the host's local time, so off UTC the two disagreed and every such value was refused as AmbiguousDateFormat on that host alone. The refusal is the same on every host, and it runs after the checks that name a sharper reason.
Fix: comparisons on a DateTimeOffset member threw. The date predicate carried a null guard whether or not the member could be null and compared every date member against a DateTime literal, so any comparison on a non-nullable DateTimeOffset threw InvalidOperationException, one on a nullable DateTimeOffset threw ParseException, and DataType.Date on any nullable date member threw ParseException. The predicate is now built from the member's own type: a null guard only where the member can be null, a literal of the member's type, .Value.Date under the guard on a nullable member, and IsNull / IsNotNull answering false / true on a non-nullable member of the entity itself. A non-nullable member reached through a navigation guards the navigation instead, so IsNull / IsNotNull and NotEqual answer by it as 3.0.0 did, forced scopes included.
Behaviour change: a date value must be ISO 8601 (a date, optionally a time, a fraction, and Z or an offset such as +03:00, +0300 or +03; a lowercase t or z, a comma before the fraction and fractions beyond seven digits are accepted too), a year-first date such as 2026/09/01, or a format the deployment declares once with DwDates.Configure(o => o.Formats.Add("dd/MM/yyyy")) or from configuration. The server's culture used to decide, so "01/09/2026" was 1 September on one server and 9 January on another. A numeric date that leads with a day or a month is now refused with the new AmbiguousDateFormat unless its order is declared, whatever its numbers — so a client finds out on its first request, not on the fifth of the month. Other forms the lenient parser accepted, such as "12:00" as today at noon, are InvalidFormat. Validation reads a date the same way the builder does. Two declared formats that read one text as different dates are refused at configuration, and so are a malformed format, one that cannot read back what it writes (hh without tt), one with no year, which the parser would complete from the clock, one with a day but no month, and two that put the day and the month in opposite orders; a single value where the list of formats belongs refuses to bind.
Change: a C# DateTime, DateTimeOffset or DateOnly placed in Values is written as year-first text instead of the month-first "09/01/2026 12:30:00", so a C# caller is never refused for an unambiguous value. A DateTime whose Kind is Local (DateTime.Now, or a value Newtonsoft.Json read from text with an offset), under DataType.DateTime on a DateTimeOffset member or a HAVING alias over one, is written with its offset, such as 2026-09-17T15:00:00+03:00, so it filters on the moment it holds: a DateTimeOffset member reads text with no zone as UTC, which would put a zoneless DateTime.Now hours away on any host outside UTC. Every other DateTime is written with no zone — under DataType.Date, so DateTime.Today compares the day it was written for; on DateTime and DateOnly members; and for a Kind of Utc or Unspecified. Text values are read as sent.
Fix: no comparison on a DateOnly member worked under either date data type (IsNull and IsNotNull did), while the policy schema told filter UIs to use DataType.Date for them. They compare as a day, against a constructor rather than DateOnly.Parse, which reads "2026-09-01" as the year 1483 on a Thai server.
Fix: a HAVING condition on a date alias gets the same predicate as a member of that type. The alias's type comes from the aggregate behind it — Minimum, Maximum, FirstOrDefault and LastOrDefault return one of the values they read — so HAVING on the latest of a DateTimeOffset column works, where it threw. A DateTimeOffset value is normalised to UTC and a value with no zone is read as UTC. DateTime members keep their previous time-zone behaviour.
Behaviour change: PageCount on an unpaged result is 1, or 0 with no rows, on filter, summary and segment results alike. It used to equal TotalCount for a filter or summary — one page per row — and to be 0 for a segment with condition sets.
Behaviour change: ToListAsync(Segment) combines its condition sets into one query the database answers. Union and Intersect join the sets' conditions, Except removes its set's rows with NOT EXISTS on the primary key, and a type with no primary key uses SQL UNION, INTERSECT and EXCEPT. The sets used to be loaded into lists and combined in memory by object reference, which 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 per set. Ordering, paging, projection and TotalCount now run in the database exactly as for a filter, so only the requested page is read, Orders apply before Selects, and text sorts by the database collation rather than .NET string comparison. The provider has to translate a correlated EXISTS; a keyless type also needs every column to be comparable.
Behaviour change: ApplyPolicy(ctx) refuses a context that never went through DwPolicy.PrepareAsync with PolicyContextNotPrepared, with or without a store configured. Only a store provider used to refuse one, so an attributes-only deployment accepted the missing call and would have started refusing the day it gained a store. DwPolicyContext.IsPrepared is public; the overload taking explicit options and a resolver does not check.
Behaviour change: two new caps refuse guarded requests 3.0.0 ran. DwCaps.MaxConditionDepth (default 10) bounds how deeply condition groups nest: the top group counts as one, each level of SubConditionGroups adds one, and the caller's groups are measured before forced predicates are injected. DwCaps.MaxConditionSets (default 10) bounds how many condition sets one Segment sends, empty sets included; every set adds a condition or a subquery to the one statement a segment becomes, and a set with no conditions passes every other cap. A guarded request nested eleven levels deep, or a segment with eleven or more sets, is refused in both tiers with CapExceeded, SourceOrigin "MaxConditionDepth cap (10), request had 11", unless the deployment raises the cap. Both refuse a value below 1, freeze with the posture and bind from configuration. Unguarded calls are not affected.
Behaviour change: two more caps refuse guarded requests 3.0.0 ran, and a Count now costs. DwCaps.MaxConditionValues (default 1000) bounds the values one condition carries, comparing the largest condition of the where clause, the having clause and every segment set: an In was one comparison per value for the price of one condition and one field. DwCaps.MaxAggregates (default 50) bounds the aggregates one summary computes, through the Summary terminals and the composable Group and Summary; the group floor's own count is not counted. A guarded request over either is refused in both tiers with CapExceeded, FieldPath "*" and SourceOrigin "MaxConditionValues cap (1000), request had 1001" or "MaxAggregates cap (50), request had 51", unless the deployment raises the cap; both refuse a value below 1, freeze with the posture and bind from configuration. An aggregate with no field, such as a Count, is now charged DefaultFieldCost toward MaxQueryCost, where any number of them cost nothing. Every count cap is checked before any field name is resolved, so an oversized request that also names a field that does not exist is refused with CapExceeded, where 3.0.0 answered ConditionMustHasValidFieldName. Unguarded calls are not affected.
Behaviour change: under DwTier.Strict a guarded result no longer carries the policy trace. FilterResult<T>.Policy, SummaryResult.Policy and SegmentResult<T>.Policy carried it in both tiers, and the trace names the fields a policy dropped, the attribute or rule that sealed each one, and every injected predicate: the detail the strict tier already refused through getQueryString, sent to the caller by any API that serializes its result. New DwPolicyOptions.IncludeTraceInResult (bool?, default null) follows the tier, off under Strict and on under Convenience, and true or false overrides either. PolicyQueryable<T>.LastTrace still holds the trace. The option freezes with the posture and binds from configuration.
Behaviour change: under DwTier.Strict an unknown field and a denied field answer alike. A name matching nothing on the type was refused with LogicException ConditionMustHasValidFieldName, and a denied field with a PolicyException naming its path and the attribute or rule that sealed it, so a caller could list the columns they may not see one guess at a time. Outside a dry run an unknown name is now gated as a field denied for every feature, after the caps, and gets the refusal a [DwDenied] field gets in that clause: FieldDeniedForWhere, FieldDeniedForSelect, FieldDeniedForOrder, FieldDeniedForGroup, FieldDeniedForAggregate, or FieldDeniedForSegment anywhere in a segment. Every refusal with one of those six codes carries FieldPath "*", no RuleId and no SourceOrigin, whatever the field, and a CapExceeded refusal names no path. The trace keeps the real path and records an unknown name as Denied, with a reason that says it names nothing on the type. The same tier closes the other ways to tell them apart: inside a Segment every field refusal is FieldDeniedForSegment, whatever clause refused it; a name padded with dots or blank segments is normalized as a real path is; MaxQueryCost is checked after every field gate, so a [DwCost] weight cannot set a hidden field apart from a missing one; and MissingContextValue carries FieldPath "*" and no SourceOrigin, naming neither the scope's column nor its context key. The convenience tier, which checks the cost budget before gating, and dry runs are unchanged.
Behaviour change: ErrorCode.SelectTypeMustHaveParameterlessConstructor replaces the sentence "Select projection requires a parameterless constructor on type 'X'.", so every validation message but one is a fixed code. LogicException gains Subject, which carries the type name, and a constructor that sets it. A date refusal carries the field there, named as the caller wrote it, so an alias under ApplyPolicy is not replaced by the member behind it.
New: DwCaps.DefaultPageSize (default 0, off) gives a guarded query that sends no page a page of that size, bounded by MaxPageSize. MaxPageSize only ever bounded a caller who had already asked for a page, so the request with none returned every row. The composable Filter, FilterDynamic and Summary return the query already paged; Where, Order, Select and Group take no page and are never given one.
New: [DwForceWhere(AllowNull = true)] injects (field op value OR field IS NULL) in a group of its own, joined by And to the caller's group and to the other forced predicates, so a caller's Or cannot merge with it. It is the scope for a record that belongs to one tenant or to none, which forced predicates joined by And could not express. The context value is still required, and the widened term does not satisfy [DwRequireWhere] on the same member. Combined with IsNull or IsNotNull, or on a member that can never be null, the attribute is refused with ArgumentException. ForcedPredicate.FromConstant and FromContext refuse allowNull on IsNull or IsNotNull too, and a stored rule is refused for "allowNull": true on a null check whether or not it carries a value: a null check ignores a constant, so a widened IsNotNull would inject (field IS NOT NULL OR field IS NULL) and scope nothing. ForcedPredicate gains AllowNull and FromConstant and FromContext overloads taking it; a stored rule carries "allowNull": true in its forced object, written only when true, and a value there other than true, false or null is refused. PolicyModelValidator now reports every malformed [DwForceWhere] at startup, where it used to surface on the first query.
New: [DwEntity(DefaultOrder = "CreatedAt desc, Id")] is the order a guarded query takes when its caller sends none, through ToList, ToListAsync, ToListDynamic and ToListAsyncDynamic with a Filter, ToListAsync with a Segment, the composable Filter and FilterDynamic, and the composable Page on a source nothing has ordered or projected. The caller's own orders win and are never extended, an IQueryable already ordered keeps that order — by an OrderBy before ApplyPolicy, or by a composed Order even when the policy dropped all of its orders — a projected query, through a Select before ApplyPolicy or the guarded Select, keeps its own, and a Summary is never given one. An entry naming a field the type does not have, one that is not a field and a direction, or one the core refuses to order by, such as a collection of entities, is skipped, and a field this caller may not order by, or in a Segment may not use in a segment, is left out and recorded in the trace, never refused. A field the default keeps that is audited for Order is recorded as a use, as a caller's own order is; a field left out is not. Unguarded calls ignore the attribute and behave as in 3.0. PolicyModelValidator reports an unreadable entry, a field no query can order by and a field the type's own attributes seal against ordering as errors; an unknown field, a field only overridable attributes deny for ordering, and a field denied for segments are warnings. A [DwEntity] on a derived type replaces its base type's, as .NET attribute inheritance does, so repeat DefaultOrder and RequirePolicy there.
New: DwPolicyOptions.AuditRefusals (default false) writes every PolicyException a guarded entry point raises, and ApplyPolicy's refusal of an unprepared context, to the caller's audit buffer, drained to IDwAuditSink like a [DwAudit] event, so a caller probing for columns leaves a record. DwAuditEvent gains ErrorCode, null for a use of an audited field, and a constructor that takes it; the nine-argument constructor is unchanged. A refusal event names the field the refusal was about by its canonical path, an alias's included, and under the strict tier too, where the caller's refusal said "*"; the recorded path is cut to 256 characters, and its control, format, line separator and paragraph separator characters are escaped, so a name the caller invented cannot break or reorder a line in a log. Each refusal is recorded at most once and is never changed or swallowed, and a full buffer records nothing. Off by default because it changes what reaches a sink.
Fix: StorePolicyProvider renews MaxSnapshotAge on a poll that confirms the version it is serving. A healthy store nobody wrote to refused every guarded query one ceiling after its last write. A poll whose read was overtaken by a failed refresh reloads instead of confirming, so a stale answer cannot lift a FailClosed refusal.
Fix: the composable PolicyQueryable.Group applies the k-anonymity floor. It went straight to the engine past the summary pipeline, returning the small groups ToList(Summary) suppressed; it and the composable Summary also handed back the floor's own count column, which they no longer do.
Fix: a forced null check built from a context key failed every guarded query on its type. The key was still required, and its value landed on a null check that validation refuses. ForcedPredicate.FromContext now refuses IsNull and IsNotNull and points to FromNullCheck, and a stored rule of that shape is refused when it is read, as [DwForceWhere] already refused a ContextValue on a null check.
Fix: the reflection cache kept an access record for every field path that failed validation. Tracking ran before the path was validated, and a failed path adds no entry for eviction to remove, so under LRU, the default, or LFU every invented name a caller sent stayed recorded for the life of the process, and unique names grew memory without limit — fastest under the strict tier, which resolves every unknown name of a request. A path is now tracked only once it has validated.
v3.0.0 — Field-level policies. A new layer that decides what each caller may filter, sort, select, group, aggregate, and see. Additive: nothing enforces until you opt in, and a project with no policy attributes and no DwPolicy.Configure call behaves exactly as 2.1.5.
Entry point: query.ApplyPolicy(ctx) returns a guarded handle. Requests are sanitized before the query is built and results are transformed after they materialize; the query engine itself is unchanged.
Access control: [DwDeny] and six named sugar attributes refuse any of Where, Select, Order, Group, Aggregate or Segment per field. [DwOperators] restricts which operators may target a field. [DwEntity(RequirePolicy = true)] makes an unguarded query on the type throw instead of returning rows.
Injection: [DwAlias] gives a field a public name, renamed back on the way out. [DwForceWhere] adds a predicate to every guarded query — a tenant boundary, a soft-delete filter, an ownership check. [DwRequireWhere] makes a filter on the field mandatory.
Transformation, applied in memory after materialization: [DwMask] with nine strategies (Full, Partial, Email, Phone, Regex, Fixed, Hash, Null, Tokenize), plus [DwMutate], [DwDefault], [DwGeneralize], [DwTruncate] and [DwFormat].
Hashing and tokenizing both keep a column groupable and joinable while hiding what is in it, and differ in where the secret lives. Hash is HMAC-SHA256 keyed by DwPolicyOptions.HashSalt, which must be at least sixteen characters — whoever holds that salt can recompute every digest the deployment has emitted. Tokenize draws a random token and writes it to DwPolicyOptions.TokenVault, so the only way back is to read the vault: a store you can lock, move and revoke separately from the data. InMemoryTokenVault ships here; durable vaults are in the Redis and Entity Framework Core packages. Neither strategy hides equality, which is what makes the column usable and is documented rather than defended.
Precedence: six levels, sealed attributes first and overridable attributes last, with dynamic user, role, tenant and global rules in between. Attributes are sealed by default, so a compile-time decision cannot be lifted by a runtime rule unless you mark it Overridable.
Dynamic rules: an optional store supplies rules at runtime without a redeploy, split into a cached broad zone and a per-request narrow zone. Ships in-memory; Redis and Entity Framework Core stores are separate packages. A context must be prepared once per request with DwPolicy.PrepareAsync(ctx), and an unprepared context is refused rather than silently falling back to attributes.
k-anonymity: aggregating a transformed field is denied by default and opted into with AllowAggregate = true. DwCaps.MinGroupSize suppresses any group smaller than k, so an aggregate cannot be read off a group of one.
IT DEFAULTS TO 5 AND IS ON. A guarded grouped summary therefore suppresses any group of fewer than five rows unless you say otherwise, which is the one behaviour in this release a reader should check before upgrading — though it can change no existing caller, because the floor applies only to a guarded query and guarded queries are new here. Write MinGroupSize = 1 to switch it off and it is off, in production, with nothing refused and nothing warned about: the setting starts unset, so "off" and "never configured" stay different instructions and IsMinGroupSizeSet tells them apart. See https://doc.dynamicwhere.com/docs/policies/security.
Schema discovery: PolicySchemaBuilder describes what one caller may do with one entity, bounded by DwCaps.SchemaDepth (2), SchemaCycleLimit (2) and MaxSchemaFields (2000) rather than by the navigation cap alone — a self-referencing entity that used to enumerate 335 fields now returns 59, and the rest is reachable a subtree at a time. The response is flat with a parent on every field and node, which is a tree in adjacency form.
Configuration: the whole posture binds from IConfiguration with AddDwPolicies(section, configure). A key nothing answers to refuses to start rather than being ignored, so a misspelt cap name fails the deployment instead of silently leaving a control off. The entity catalogue, the token vault and the service provider stay in code, because they are objects rather than values.
Also: query cost budgets with [DwCost], audit trails with [DwAudit] and IDwAuditSink, field descriptions with [DwDescribe] and [DwAllowedValues], startup model validation, a dry-run mode, and a PolicyTrace on FilterResult and SummaryResult reporting what the policy did.
Breaking changes: none to the 2.x API. FilterResult<T> and SummaryResult each gain one nullable Policy property, null when the query was not guarded.
v2.1.5 — Documentation only. Fixes the XML documentation shipped with the package, which drives IntelliSense in consuming projects: an unescaped generic argument in the Select<T> comment truncated its remarks and returns text, three ToList/ToListAsync overloads were missing the getQueryString parameter description, and CacheReporting.GetQuickHealthSummary documented a parameter it does not take. The library now builds with zero warnings. No API or behaviour changes from 2.1.4.
v2.1.4 — Security and correctness fix. Recommended for all users.
Fix: condition values are now escaped before they are embedded in the generated dynamic LINQ expression. A value containing a backslash or a double quote previously ended its string literal early — a search term ending in "\" threw System.Linq.Dynamic.Core.Exceptions.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. Values now match literally, including "\" and '"'. Affects every Text and Enum operator.
Fix: AggregateBy.Alias must now be a plain identifier (a leading letter or underscore, then letters, digits, or underscores; non-Latin letters allowed). An alias containing a comma previously appended extra terms to the generated Select projection. Aliases carrying any other separator never parsed, so nothing that worked is rejected — malformed aliases now throw LogicException("AggregationMustHasValidAlias") at validation time instead of failing later.
No API changes from 2.1.3.
v2.1.3 — Licensing: now published under the MIT license (SPDX: MIT). Free forever for commercial and personal use, with no license acceptance required. No API or behaviour changes from 2.1.2.
v2.1.2 — Fix: ordering by a field path that crosses a collection navigation (e.g. "Tags.Value" on List<Tag>) threw "No property or field 'Value' exists in type 'List`1'". Collection segments are now aggregated to a single comparable value — Min ascending, Max descending — at any nesting depth, translated to SQL and safe over empty collections in memory.