DynamicWhere.ex 3.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package DynamicWhere.ex --version 3.0.0
                    
NuGet\Install-Package DynamicWhere.ex -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DynamicWhere.ex" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DynamicWhere.ex" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="DynamicWhere.ex" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DynamicWhere.ex --version 3.0.0
                    
#r "nuget: DynamicWhere.ex, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DynamicWhere.ex@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DynamicWhere.ex&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=DynamicWhere.ex&version=3.0.0
                    
Install as a Cake Tool

DynamicWhere.ex

JSON-driven queries for Entity Framework Core.

NuGet Version NuGet Downloads License .NET Docs

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 shape and field name, every enum member verbatim, all seventeen methods, the whole policy layer, 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 shapesFilter, Segment, Summary — cover where, set operations, and group-by reporting.
  • Seventeen extension methods on IQueryable<T> and IEnumerable<T>.
  • Nested navigation through references and collections, with auto-wrapped .Any() lambdas where needed.
  • Heterogeneous Condition.Values — pass raw numbers, booleans, strings; normalized per DataType.
  • Thread-safe reflection cache with FIFO / LRU / LFU eviction and six 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.0.0

Or via Package Manager:

Install-Package DynamicWhere.ex -Version 3.0.0

Dependencies (restored automatically):

Package Version
Microsoft.EntityFrameworkCore 6.0.22
System.Linq.Dynamic.Core 1.6.7

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;

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, "category": { "name": "Electronics" } }
  ],
  "queryString": null
}

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 …)

Seventeen 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)

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* and I* 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 six tuned presets:

Preset MaxSize Eviction Use case
Default 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 17 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.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.Redis and Policies.EntityFrameworkCore hold rules; Policies.AspNetCore mounts the admin API, explain, simulate and health, and refuses to map without a named authorization policy.
  • No breaking changes. The 2.x API is untouched. FilterResult<T> and SummaryResult each gain one nullable Policy property, null when the query was not guarded. Nothing enforces until you opt in.
  • 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. MinGroupSize ships 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, three ToList / ToListAsync overloads were missing the getQueryString description, and CacheReporting.GetQuickHealthSummary documented 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 threw System.Linq.Dynamic.Core.Exceptions.ParseException: ')' or ',' expected. Values are now escaped and matched literally, \ and " included, across every Text and Enum operator.
  • 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("y turned a Contains filter into an always-true predicate and returned every row. Values can no longer break out of their literal.
  • Fixed: AggregateBy.Alias could inject extra projection columns. The alias was only checked for dots, so "Total, 1 as Leaked" appended a term to the generated Select. 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 throw AggregationMustHasValidAlias at 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 a List<Tag> threw No 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.ValuesList<object> with type-safe coercion. Send raw numbers and booleans without quoting. Backward-compatible with List<string> callers.
  • Six tuned cache presets — pick ForHighMemory, ForLowMemory, ForDevelopment, ForHighFrequencyAccess, ForTemporalAccess, or the default.
  • 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
  • EF Core providers: SQL Server, PostgreSQL (Npgsql), MySQL (Pomelo), SQLite — anything that supports ToQueryString() for the optional getQueryString: true flag.
  • Enum storage: assumed stored as strings. Use DataType.Number if your column stores integers.
  • Case-insensitive operators: emit .ToLower() on both sides. Works well on SQL Server's default collation; watch for case-sensitive PostgreSQL C locale.


License

MITFree 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

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.

MaskStrategy.Tokenize is deferred to a later release; the other eight strategies ship.

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.