DynamicWhere.ex
3.3.0
dotnet add package DynamicWhere.ex --version 3.3.0
NuGet\Install-Package DynamicWhere.ex -Version 3.3.0
<PackageReference Include="DynamicWhere.ex" Version="3.3.0" />
<PackageVersion Include="DynamicWhere.ex" Version="3.3.0" />
<PackageReference Include="DynamicWhere.ex" />
paket add DynamicWhere.ex --version 3.3.0
#r "nuget: DynamicWhere.ex, 3.3.0"
#:package DynamicWhere.ex@3.3.0
#addin nuget:?package=DynamicWhere.ex&version=3.3.0
#tool nuget:?package=DynamicWhere.ex&version=3.3.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.3.0
Or via Package Manager:
Install-Package DynamicWhere.ex -Version 3.3.0
Dependencies (restored automatically):
| Package | Version |
|---|---|
Microsoft.EntityFrameworkCore |
6.0.22 |
System.Linq.Dynamic.Core |
1.6.7 |
Microsoft.Extensions.Caching.Memory |
6.0.2 |
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?
// At startup. A second call asking for this same posture does nothing.
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 | 12 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.3.0 highlights
Upgrade note — several behaviour changes, most of them security fixes, and two shapes of malformed request that used to be a five-hundred are now a LogicException. Read these before bumping.
- New: a second host may configure the same posture.
DwPolicy.Configure, andAddDwPolicieswith it, used to throw on every call after the first, so an integration suite starting severalWebApplicationFactoryhosts over one composition root had to readIsConfiguredfirst — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding"MinGroupSize": 5from the documented sample and a host on the defaults enforce the same floor, andIncludeTraceInResultwritten as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores.AddDwPoliciesregisters the posture in force rather than the instance it just built. - Changed: under
Strict, a path the query cannot compute is refused rather than run. A member of a row's type is not always a value a database can produce: a getter such asLocalizedText.IsEmptyreads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside aSegment. Not inSelects: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed beforeApplyPolicy, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such asLengthorYear, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit'sAsExpandable(), DelegateDecompiler'sDecompile(), or a host's own registered throughReplaceService<IAsyncQueryProvider, …>— because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - Fixed (security): an audited field could be read with nothing recorded.
[DwAudit]answers who read a field, and a request that sent noSelectsreceived the whole row with nothing written down — one token past the control. Every audited member a projection the caller did not name hands back is now recorded forSelect, one event per query rather than per row. A deployment already running the control sees more events, andDwCaps.MaxAuditEventsrefuses rather than dropping a record, so raise the cap or drain per request withapp.UseDwPolicyAudit(). - Fixed (security): four refusals named a field under
Strict. A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart.AmbiguousFieldNamesaid the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace.AmbiguousGroupKeyreported the column behind the caller's alias and an origin saying its values are transformed;TransformRequiresMaterializationlisted every transformed column on the type;MissingHashSaltandMissingTokenVaultnamed the masked field a deployment had not configured for. The grouping key, the hash salt and the token vault report"*"and no origin now, and the transform refusal reports"*"and keeps an origin that names the method rather than a field.Convenienceand a dry run, whichever switch declares it, are unchanged. - Fixed (security): the audit cap answered differently for a real field. An audited field records one event per use, and the query is refused rather than the record dropped once
DwCaps.MaxAuditEventsis reached. That refusal carriedCapExceededand aSourceOriginnaming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. UnderStrict, outside a dry run, the cap now refuses with the clause's own code,FieldPath"*"and no origin; the request still fails and the trace still records which refusal it was.Convenienceand a dry run are unchanged. - Fixed (security): a path the attribute walk cannot name had no policy at all. No attribute can be placed beneath a member whose type the framework declares —
Salary.Valueon adecimal?,Secret.Length,Born.Year,Bag.Count,Lines.Counton an application's own collection class — and no fragment named such a path, so a[DwDenied] decimal?was filtered on, sorted by, grouped by with its values as the group keys, aggregated and handed back by a dynamic projection, underStrict; a transformed member gave its stored value, an audited one was read with nothing recorded, a weighted one cost the default. Such a path now takes the policy of the member it reads — denials, operators, cost and audit, from whichever provider supplied them — and never that member's alias, required filter, forced scope or description. A member only a subtype declares is not such a path. RaisingCaps.MaxNavigationDepthabove 4 opened the same hole past the walk's four segments; the attributes of the member at the end of such a path are read directly now. - Fixed (security): a transform the outbound walk reached no path to. The walk transformed along the paths the policy names, four segments of the declared types, so a
[DwMask]member five segments down an included graph, one only a subtype of the row's type declares, one on an object a dictionary holds and the far side of a cycle came back exactly as stored — default configuration, default caps, underStrict. The rows are walked by run-time type as well now, and a member that declares a transform and was not transformed along a named path is transformed by its own attributes, once. Only what can lead to such a member is read, so a model that declares neither a transform nor an audit forSelectanywhere pays for no second pass. The four methods that hand back a query for the caller to run —SelectDynamic,Group,FilterDynamic,Summary— are refused withTransformRequiresMaterializationon such a type too, where they used to hand over the query and its rows as stored. - Fixed (security): an audited member no path names was read with nothing written down.
[DwAudit]records a use by path, in the gate, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so it came back inside a row or a navigation returned whole, unrecorded. The same pass that finds such members for transforms now reports them: one event per path per query, forSelect, withEffectMaskwhere the member is transformed as well. AtDwCaps.MaxAuditEventsit fails closed as the gate does and the rows are withheld. A deployment already running the control sees more events for such models. - Fixed (security): a forced scope could depend on which member was declared first. The attribute walk returned at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again — and what a cycle leaves out,
[DwForceWhere],[DwRequireWhere]and[DwAlias], was dropped from a shorter path reaching that type directly. All three now apply on every path within four segments that is not around a cycle: a query that ran unscoped is scoped, and a required filter may now be demanded. - Fixed (security): a caller who hung up cancelled the record of what they read.
app.UseDwPolicyAudit()drained a request's events with the request's own abort token, so a client that closed the connection cancelled the write that follows the response and the events were logged and dropped. The drain has a budget of its own now, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast. - Fixed: a page number whose offset passes
Int32. The offset,(PageNumber - 1) * PageSize, was worked out in 32 bits and wrapped: a five-hundred on SQL Server and PostgreSQL, the first page again on SQLite and in memory. It is worked out in 64 bits and held toint.MaxValuenow, inPageand in the three summary methods, so a page past the last row is an empty page however far past it is. - Fixed: two Redis writers moving one rule could leave a stale copy.
RedisPolicyStore.UpsertAsyncandDeleteAsynccommit conditionally on the rule's owner entry. The writer that loses the race gets theInvalidOperationExceptiona failed commit always raised, and should write again. - Fixed: a guarded
SummarywhoseHavingcarried a null list. A request body sending"conditions": nullor"subConditionGroups": nulloverwrites the list's initializer, and the group floor's own walk overHavingwas the one reader in the gate that did not check, so such a summary failed with aNullReferenceExceptionwherever the floor is on — the default — though it ran unguarded. It runs, and the floor still applies. - Performance: a reflection-cache lookup takes no lock and allocates nothing on a hit. Every lookup of every query locked and copied the cache configuration and, under
LRU, wrote the entry's last-access time on every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2,697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. A last-access time is refreshed once it is a second old instead. - Packaging:
Microsoft.Extensions.Caching.Memory6.0.2 is named directly, the version patched for CVE-2024-43483. EF Core 6.0.22 asks for 6.0.1 or later and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. A host on EF Core 8 or later sees no change. - Changed:
LastTraceis set before a request is sanitized, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - New:
Clone()is public onFilter,SegmentandSummary. It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - Changed: a
Numbervalue is read the way the expression parser reads it. The builder writes a number into the generated expression unquoted, exactly as sent, while validation checked it withTryParsein the host's culture, so"1,000","+5",".5","NaN","Infinity"and an integer pastUInt64all passed and then threw the parser's ownParseException, which a host maps to a five-hundred;"1,5"passed on a German host and was refused on an English one; and"NaN"and"Infinity"were written in as identifiers, so on a type with a member of that name the condition compared two columns. A value is now read against the parser's own grammar, in the invariant culture, and then — in aWherecondition — against the member the condition names, which the parser itself is asked about, so1.5on anint?,1e-7on adecimaland any number on astringare refused asInvalidFormatrather than thrown at. Nothing that ran before is refused now. - Changed: a
nullentry in a request's list is a malformed request, refused as one. A body can say"conditions": [null],"orders": [null]or"selects": [null], and nothing read a list expecting that, so the null surfaced wherever it was first touched — aNullReferenceExceptionfrom the sort-order check, from the ordering, or from the copy the policy sanitizer takes, and anArgumentNullExceptionfrom the name lookup. Every method that takes a shape walks its lists first now, guarded or not: a null condition, sub-group, condition set, order or aggregate isListOf[Conditions]MustNotHasNullEntryand its kin, and a null or blankSelectsentry isConditionMustHasValidFieldName. A list that is itself null still means what it meant, andClone()copies a null entry rather than failing on it. - New: a token vault can hold a key, so a copy of the store gives no value back. A vault stores its mapping under a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole, so a backup, a replica or a dump gives back every value in it and with them the value behind every token ever issued.
RedisTokenVaultandEfTokenVaulttake a key of 16 bytes or more in a constructor of their own and store an HMAC-SHA256 instead, so the store and the key have to be taken together;InMemoryTokenVaultdraws one of its own with nothing to configure. A keyed vault adopts the token an unkeyed mapping already gave a value, so every token already issued is kept — give every instance the key before turningretireUnkeyedon. No schema change. - Docs:
[DwEntity(DefaultOrder)]'s own remarks said a projected query takes no default; it has since 3.2.0.DwCaps.MinGroupSizeships on at 5, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table.
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.Caching.Memory (>= 6.0.2)
- 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.3.0 | 8 | 9/21/2026 | |
| 3.2.0 | 131 | 9/20/2026 | |
| 3.1.0 | 56 | 9/18/2026 | |
| 3.0.0 | 137 | 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,069 | 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,452 | 3/29/2024 | |
| 1.8.0 | 475 | 2/11/2024 |
v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, a path or a member the attribute walk cannot name is still policed, a malformed request is refused as one rather than reaching the caller as a server error, a token vault can hold a key, and Clone is public on the three request types.
New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap value, the exposed entity catalogue with every name it answers to, and the kinds of policy source supplied, in order. Writing a cap's own default down is not a difference, and handing the posture in force back with a source beside it is: that call asks for the source, so it is refused rather than dropped. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing.
Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null.
Security fix and behaviour change: [DwAudit] records a read the request did not name. A request sending no Selects receives the row, and only a field it spelled out used to be recorded, so that caller read every audited member with nothing written down — one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is recorded for Select now: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field [DwAudit] names. What is handed back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a dry run, which applies no projection, records everything the row carries, a denied member included. MaxNavigationDepth also stops answering an alias with its own code under the strict tier: the cap counts the canonical path, so a name the caller wrote as one token is refused as an unknown name is, while a caller who wrote the path themselves still meets the cap. A deployment already running the control sees more events, and DwCaps.MaxAuditEvents refuses rather than dropping a record, so raise the cap or drain per request with app.UseDwPolicyAudit().
Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. AmbiguousGroupKey, MissingHashSalt and MissingTokenVault report "*" and no origin; TransformRequiresMaterialization reports "*" and keeps an origin naming the method and what to call instead, never a field. The convenience tier and a dry run, whichever switch declares it, are unchanged.
Security fix and behaviour change: under the strict tier, outside a dry run, a query that exhausts the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and a SourceOrigin naming the cap. An audited field records an event per use and the query is refused rather than the record dropped once DwCaps.MaxAuditEvents is reached, which is fail-closed and stays that way; but a name that matches nothing is never audited and never reaches the cap, so the two answers told a caller, one guess per request, which names are real and audited. The refusal now carries the clause's own code, FieldPath "*" and no origin, and the trace records which refusal it really was. The convenience tier and a dry run still answer CapExceeded.
New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not.
Security fix and behaviour change: a path the attribute walk cannot name takes the policy of the member it reads. The walk descends into an application's own types and nowhere else, so no attribute can be placed beneath a member whose type the framework declares — Salary.Value and Salary.HasValue on a nullable decimal, Secret.Length on a string, Born.Year on a DateTime, Bag.Count on a dictionary, Lines.Count on an application's own collection class, the collection's own member and not an element's. The pipeline validates each of them and the provider translates each, and no fragment named them, so they resolved as allowed: a denied nullable decimal was filtered on, sorted by, grouped by with its values as the group keys, aggregated through MAX and handed back by a dynamic projection, under the strict tier. A transformed member gave its stored value the same way, an audited member was read with nothing recorded, a weighted member cost the default, and an operator restriction did not hold. All of it is in 3.2.0 and earlier, in both tiers. Such a path now takes every fragment of the member it reads, whichever provider supplied it, an attribute and a store rule alike: the deny effects per feature, the operator restriction, intersected, the cost weight and the audited features. It does not take what is said to the caller about the member — the alias, the required filter, so a filter on TenantId.Value does not satisfy a [DwRequireWhere] on TenantId, the forced scope, and the descriptive facts. A rule naming the sub-path itself still applies alongside. One feature is one feature: [DwNoWhere] on Born refuses a filter on Born.Year and still allows a grouping by it, and a member nothing denies is read beneath exactly as before, so Name.Length still runs. A member only a subtype of the navigated type declares is not such a path: it is decided by the fragments naming it, so a grant of Zone under a "*" deny does not grant what a subtype of Zone's type declares.
Security fix and behaviour change: past the attribute walk's depth, the member at the end of the path is read directly. Caps.MaxNavigationDepth defaults to 4, the depth the walk reads to, and a host may raise it; a request naming five or more segments then reached what no attribute fragment covered, and a denied member at segment five was filtered on, grouped by and returned under the strict tier. Its attributes are read now: the deny family, the operator list, the transform stages, the cost weight, the audit set, the description and the allowed values. What is declared about the queried entity itself is left out there, as it is around a cycle: the alias, the required filter and the forced scope. Only a resolver that reads attributes does this, which every resolver DwPolicy.Configure builds does, and default configuration was never exposed to it.
Behaviour change: beneath a transformed member there is no member to apply the chain to, Bonus.Value being a decimal where Bonus is what is rounded, so Select, Group and Aggregate on such a path are refused, with a Selects entry dropped under the convenience tier as any field denied for Select is. A transformed member past the walk is itself a member, so it comes back transformed wherever a row carries it, a typed projection and a generated row alike: the chains of the members a projection names past the walk are handed to the outbound walk beside the type's own list. Only a grouping key and an aggregated field are refused there, because a summary's own transform finds a generated row's columns by the type's list, which stops at four segments. Filtering and ordering run on stored values on both kinds of path, so they follow the member's own decision, as they do along a named path.
Security fix: a transform the outbound walk reached no path to is applied to the rows themselves. That walk transformed along the paths the policy names, the declared types four segments deep, and a value sitting elsewhere in the materialized rows came back exactly as stored: a masked member five segments down an included or in-memory graph, while one four segments down was masked; a masked member only a subtype of the row's type declares, on rows read as the base type, in memory or in a table-per-hierarchy mapping; a masked member of an object a dictionary holds; and the far side of a cycle. With no Selects, with a navigation named whole in Selects, and in a dynamic projection holding a real object — default configuration, at the default caps, under the strict tier. The rows are walked by run-time type as well now, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once: an object reached both ways is not transformed twice. Only members that declare a transform, or that can lead to one, are read, so a navigation whose type can reach neither a transform nor an audit for Select is never touched and a lazy loader behind it is not woken, and a model that declares neither anywhere pays for no second pass. A transformed member with no setter fails the query with InvalidOperationException, as one along a named path does; the trace records the path with its stages and a note saying the member declared it and no path of the policy names it; no rule can speak to such a member, since no path names it; a resolver built over no AttributePolicyProvider reads no attribute here either; and it runs in a dry run, as transforms always have. A member typed as object, or a collection that is not generic, says nothing about what it holds and is still not read into.
Security fix and behaviour change: [DwAudit] records a member no path of the policy names, once the rows show it. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about: handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down, two of four audited members in the probe recorded. The outbound walk's second pass reports each audited member it meets where no path names it, and the terminal records it: one event per path per query, not per row, for Select, with the effect it came back with, Mask where the member is transformed as well and Allow otherwise, and the path through the rows as its field path. Only a member its own [DwAudit] audits for Select, and only where the projection carries it, since a member the projection left out is not a read. A member the declared types hold within four segments is the gate's and is left to it, and so is a path the projection spells out however long it is, so neither is recorded twice. At Caps.MaxAuditEvents it fails closed as the gate does and the rows are withheld: under the strict tier outside a dry run the clause's own refusal with FieldPath "*", the segment's code inside a segment, and CapExceeded otherwise. Recorded in a dry run too, read only by a resolver that reads attributes, and a model that declares neither an audit for Select nor a transform anywhere pays for no second pass. A deployment already running the control sees more events for such models.
Security fix and behaviour change: SelectDynamic, Group, FilterDynamic and Summary on the guarded handle hand back a query for the caller to run, which the library never sees materialized, so they are refused with TransformRequiresMaterialization on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them, on a member only a subtype declares or five segments down, was handed the query and its rows exactly as stored: the same gap the outbound walk's second pass closed for the terminals, one method call away from them. The refusal asks what a row of the type can hold as well, any transform attribute anywhere in what the type can reach, read only by a resolver that reads attributes. With no named column to list it names the clause, FieldPath "*", in both tiers, where under the convenience tier it otherwise lists the transformed columns. A type nothing transforms anywhere still gets its query.
Security fix: the attribute walk returned at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the same walk, and what a cycle leaves out — the forced scope, the required filter and the alias — was left out of a shorter path reaching that type directly. Which of two members was declared first decided whether a forced tenant scope on a navigated type applied. The three now apply on every path within four segments that is not around a cycle, as documented, so a query that ran unscoped is scoped and a required filter may now be demanded.
Fix: the page offset, the page number less one times the page size, was worked out in 32 bits and wrapped for a large enough page number. A negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. It is worked out in 64 bits and held to int.MaxValue now, in Page and in the three summary methods, guarded or not, and a page past the last row is an empty page however far past it is, as it always was for a page number that did not wrap. The policy layer caps the page size and never the page number, so a guarded query took the same path.
Fix: a guarded Summary whose Having carried a null Conditions or SubConditionGroups list failed with a NullReferenceException wherever the group floor is on, Caps.MinGroupSize of 2 or more, which the default of 5 is, although the same summary ran unguarded. A request body sending either as null overwrites the list's initializer, and the floor's own walk over Having, which looks for its reserved alias, was the one reader in the gate that did not check. It runs now, and the floor still applies.
Performance: a property lookup takes no lock and allocates nothing on a hit. Every lookup of every query, several per field, locked and copied the cache configuration, allocated an input object for the tracking call and built a closure whether or not the entry was cached, and under LRU, which is the default, wrote the entry's last-access time on every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. A last-access time is refreshed once it is a second old instead, since eviction only asks which entries are oldest; CacheExpose.GetCacheConfigOptions still returns a copy.
Packaging: Microsoft.Extensions.Caching.Memory 6.0.2 is named directly, the version patched for CVE-2024-43483 (GHSA-qj66-m88j-hmgj). EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through this package and its three companions. A host on EF Core 8 or later already resolves a newer one and sees no change.
Docs: [DwEntity(DefaultOrder)]'s own remarks still read as though a default order does not reach a projected query. It has since 3.2.0. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows unless the deployment sets it to 1; the aggregation documentation now leads with that rather than leaving it to the caps table.
Fix and behaviour change: a DataType.Number value is read as the expression parser reads it, not as the host's culture does. The builder writes a number into the generated expression unquoted, exactly as sent, while validation checked it with byte, short, int, long, float, double and decimal TryParse in the host's culture, and the two disagreed. "1,000", "5-", "+5", ".5", "5.", "-.5", "1.e5", "NaN", "Infinity", "-Infinity" and an integer past UInt64, or below Int64 when negative, all passed validation and then threw the parser's own ParseException, which a host maps to a server error; "1,5" passed on a German host and was refused on an English one; and "NaN" and "Infinity" were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns instead of filtering. A value is read in two steps now. First the parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction with a digit on both sides of the point, and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no NaN and no Infinity; an integer must fit UInt64, or Int64 when negative, while a real has no bound, so 1e400 still reads as infinity. A suffix such as 5L or 5m, a hexadecimal literal and a minus standing apart from its digits are refused as they always were, though the parser would read them: nothing is accepted now that was not accepted before. Then, in a WHERE condition and for the operators that write the value into a comparison, whether that literal compares with the member the condition names — the parser itself is asked, against the member's declared type, a collection at the end of the path standing for itself and one along the path for its elements. Refused there, where the parser used to throw: a literal written with a point and no exponent on a nullable integral member, where a non-nullable int still takes 1.5; an exponent form on a decimal or a nullable decimal, and a real with more digits than a decimal holds; an integer above Int64.MaxValue on a signed integral member, because such a literal reads as an unsigned long which none of them converts to; a negative number on an unsigned long; any number on a string, bool, Guid, DateTime or char member, or on a collection of simple values; and a nullable enumeration under an ordering operator, where equality still works. A HAVING condition reads the grammar and stops, since an alias has no member type to ask about. Every refusal is a LogicException with InvalidFormat, the same in both tiers under a policy, where a denied field is still refused by the gate before any value is read. Nothing that ran before is refused now: every value refused is one the parser refused. An endpoint that mapped ParseException to a server error now gets a refusal it can turn into a four-hundred. A number a C# caller places in Values is still written in the invariant culture, except that double.NaN is now InvalidFormat, and JavaScript's JSON.stringify writes 0.0000001 as 1e-7, which a decimal member refuses: send it as a string.
Fix and behaviour change: a null entry in a request's list is a malformed request, refused as one. A body can say "conditions": [null], "subConditionGroups": [null], "conditionSets": [null], "orders": [null], "aggregateBy": [null] or "selects": [null]. Nothing read a list expecting that, so the null surfaced wherever it was first touched: a NullReferenceException from the sort-order check, from the ordering, or, under a policy, from inside the copy the sanitizer takes before it reads anything, and an ArgumentNullException for a null aggregate, a null summary order and, from the name lookup, a null or blank Selects entry. A host maps those to a server error, for a request that was simply malformed. Every method that takes a shape walks its lists before anything else reads them now, with or without a policy, in both tiers, synchronous and asynchronous: the composable Where over a condition group, Order over a list, Select, SelectDynamic, Group and Summary, and every terminal for a Filter, a Segment and a Summary. Under a policy the walk runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A null condition, sub-group, condition set, order or aggregate is a LogicException naming the list, ListOf[Conditions]MustNotHasNullEntry and its kin. A Selects entry that is null or blank is ConditionMustHasValidFieldName, the refusal a null or blank grouping field has always had. A list that is itself null still means what it meant; a condition set whose condition group is null is still an ArgumentNullException, as is a null Summary.GroupBy; and a null element inside a condition's Values still reads as the empty string. Clone copies a null entry as a null entry rather than failing on it, so the refusal belongs to the method that runs the request and reads the same for a copy. Code matching on NullReferenceException, or on the parameter names "name", "order" and "aggregate", needs updating.
New: a token vault can hold a key, so a copy of the store gives no value back. A vault stores its mapping under the scope and a digest of the value, and unkeyed that digest is a plain SHA-256. A tokenized column is nearly always drawn from a space small enough to hash whole — phone numbers, national identifiers, card numbers — so a backup, a replica or a dump of the store gives back every value in it, and with them the value behind every token ever issued. DwToken.KeyFor(scope, value, key) is an HMAC-SHA256 under a key of at least DwToken.MinimumKeyLength, sixteen bytes, over the scope, one zero byte and the value, written as DwToken.KeyedPrefix, "hmac:", the scope in the clear and sixty-four lowercase hexadecimal characters, so one value tokenized in two scopes shares no digest. DwToken.RequireKey refuses a key that is null or short and returns a copy of it, so a caller clearing or reusing its array cannot re-key a running vault. RedisTokenVault and EfTokenVault take the key in a constructor of their own, with a retireUnkeyed switch; the existing constructors and the unkeyed KeyFor are unchanged. InMemoryTokenVault draws a random thirty-two byte key of its own per instance, with nothing to configure and no API change, since its mappings die with the process anyway. A keyed vault meeting a value with no keyed mapping looks up the unkeyed mapping too and writes the token found there under the keyed key, so every token already issued is kept; the unkeyed mapping stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value. Roll out in two steps: give every instance the key, then turn retireUnkeyed on. An instance still running without the key mints a new token for a value whose unkeyed mapping is gone. No schema change: a keyed key is at most 326 characters against the 512 the Key column holds, and the cost is one more round trip or read for a value the store has not seen.
Earlier releases, in brief. The full notes of each are on the releases page, https://github.com/Sajadh92/DynamicWhere.ex/releases, and every behaviour change is on https://doc.dynamicwhere.com/docs/breaking-changes.
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.
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.
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.
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.
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.