DynamicWhere.ex
DynamicWhere.exv3.0.0·docs

For AI agents

Most people writing against this library now have an agent open beside them. The rest of these docs are written for a human reading one page at a time, which is the wrong shape for that: an agent needs the whole surface at once, in plain text, with the exact spellings.

So there is one file. It covers every shape, every enum member, every extension method, the full policy layer, and the traps that produce code which compiles and is quietly wrong.

How to use it

Either hand it over, or let the agent fetch it.

Read https://doc.dynamicwhere.com/llms.txt before writing any
DynamicWhere.ex code. It is the complete API surface.

That works with any agent that can read a URL. If yours cannot, use the copy button below and paste the file into your context.

Why plain text rather than a nicer page
What an agent consumes is the text. Syntax highlighting, cards and collapsible sections cost context and carry no meaning once the markup is stripped. llms.txt is also the path agents and crawlers already look for, so pointing at it needs no explanation.

What is in it

  • Shapes. Exact field names for Condition, ConditionGroup, ConditionSet, OrderBy, GroupBy, AggregateBy, PageBy, Filter, Segment, Summary and the three result types.
  • Enums, verbatim. Every member of every enum, including the case-insensitive I variants and the one-m spelling of Sumation — the two things a model guesses wrong most often.
  • All seventeen extension methods with their real signatures, and which ones have no synchronous form.
  • The policy layer. All eighteen attributes with their parameters, the six precedence levels, blocked-action semantics per tier, the transform chain order, the caps and their defaults, the configuration section, the admin endpoints and all twenty-two policy error codes.
  • Ten traps that produce silently wrong code, each with the reason. A mask without [DwNoOrder] leaking through sorting is the one an agent reproduces most often, because the attribute reads as sufficient on its own.
  • Worked examples for a filter, a summary, a segment and a fully protected entity.

The file

This is the exact content served at /llms.txt. The page reads it at build time, so the two are never out of step.

llms.txt · 498 lines
Open raw
# DynamicWhere.ex — complete reference for coding agents

> A .NET library that turns JSON filter objects into Entity Framework Core LINQ queries, plus an
> opt-in field-level policy layer that decides what each caller may filter, sort, select, group,
> aggregate and see.
>
> Version 3.0.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT
> Docs: https://doc.dynamicwhere.com

This file is the whole public surface in one pass. Everything below is verbatim from the source.
Where a name is not in this file, it does not exist — do not invent members.

```
dotnet add package DynamicWhere.ex --version 3.0.0
dotnet add package DynamicWhere.ex.Policies.Redis                 # optional
dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore   # optional
dotnet add package DynamicWhere.ex.Policies.AspNetCore            # optional
```

---

## 1. The model

Three composable shapes, all plain JSON from any caller:

- `Filter`  — where + order + page + select projection
- `Segment` — several condition sets combined with UNION / INTERSECT / EXCEPT
- `Summary` — group by + aggregate + having

Namespaces: `DynamicWhere.ex.Source` (extensions), `DynamicWhere.ex.Classes.Core`,
`DynamicWhere.ex.Classes.Complex`, `DynamicWhere.ex.Classes.Result`, `DynamicWhere.ex.Enums`.

---

## 2. Shapes

Field names are exact. `Sort` is required wherever it appears and must be unique within its list.

```
Condition            Sort:int  Field:string?  DataType:DataType  Operator:Operator  Values:List<object>
ConditionGroup       Sort:int  Connector:Connector  Conditions:List<Condition>  SubConditionGroups:List<ConditionGroup>
ConditionSet         Sort:int  Intersection:Intersection?  ConditionGroup:ConditionGroup
OrderBy              Sort:int  Field:string?  Direction:Direction          (default Ascending)
AggregateBy          Field:string?  Alias:string?  Aggregator:Aggregator
GroupBy              Fields:List<string>  AggregateBy:List<AggregateBy>
PageBy               PageNumber:int  PageSize:int

Filter               ConditionGroup:ConditionGroup?  Selects:List<string>?  Orders:List<OrderBy>?  Page:PageBy?
Segment              ConditionSets:List<ConditionSet>  Selects:List<string>?  Orders:List<OrderBy>?  Page:PageBy?
Summary              ConditionGroup:ConditionGroup?  GroupBy:GroupBy?  Having:ConditionGroup?  Orders:List<OrderBy>?  Page:PageBy?
```

Results:

```
FilterResult<T>      PageNumber:int  PageSize:int  PageCount:int  TotalCount:int
                     Data:List<T>  QueryString:string?  Policy:PolicyTrace?
SegmentResult<T>   : FilterResult<T>
SummaryResult        same, with Data:List<dynamic>
```

`Policy` is null unless the query went through `ApplyPolicy`. `QueryString` is null unless the call
passed `getQueryString: true`.

---

## 3. Enums — verbatim

```
DataType       Text Guid Number Boolean DateTime Date Enum

Operator       Equal IEqual NotEqual INotEqual
               Contains IContains NotContains INotContains
               StartsWith IStartsWith NotStartsWith INotStartsWith
               EndsWith IEndsWith NotEndsWith INotEndsWith
               In IIn NotIn INotIn
               GreaterThan GreaterThanOrEqual LessThan LessThanOrEqual
               Between NotBetween IsNull IsNotNull

Connector      And Or
Direction      Ascending Descending
Intersection   Union Intersect Except
Aggregator     Count CountDistinct Sumation Average Minimum Maximum FirstOrDefault LastOrDefault
```

The `I` prefix means case-insensitive: `IEqual`, `IContains`, `IIn` and so on.
`Sumation` is spelled with one `m` — that is the real member name.

---

## 4. Extension methods

On `IQueryable<T> where T : class`, namespace `DynamicWhere.ex.Source`.

```
Composable, return a query:
  Select<T>(List<string> fields)                -> IQueryable<T>
  SelectDynamic<T>(List<string> fields)         -> IQueryable
  Where<T>(Condition condition)                 -> IQueryable<T>
  Where<T>(ConditionGroup group)                -> IQueryable<T>
  Order<T>(OrderBy order)                       -> IQueryable<T>
  Order<T>(List<OrderBy> orders)                -> IQueryable<T>
  Page<T>(PageBy page)                          -> IQueryable<T>
  Group<T>(GroupBy groupBy)                     -> IQueryable
  Filter<T>(Filter filter)                      -> IQueryable<T>
  FilterDynamic<T>(Filter filter)               -> IQueryable
  Summary<T>(Summary summary)                   -> IQueryable

Terminal, execute:
  ToList<T>(Filter, bool getQueryString = false)          -> FilterResult<T>
  ToListAsync<T>(Filter, bool getQueryString = false)     -> Task<FilterResult<T>>
  ToListDynamic<T>(Filter, bool getQueryString = false)   -> FilterResult<dynamic>
  ToListAsyncDynamic<T>(Filter, ...)                      -> Task<FilterResult<dynamic>>
  ToList<T>(Summary, bool getQueryString = false)         -> SummaryResult
  ToListAsync<T>(Summary, ...)                            -> Task<SummaryResult>
  ToListAsync<T>(Segment)                                 -> Task<SegmentResult<T>>
```

`ToList` and `ToListDynamic` also exist on `IEnumerable<T>` for in-memory collections.
There is no synchronous `ToList` for `Segment`.

---

## 5. Field paths

Dotted paths cross references and collections. A collection segment is auto-wrapped in `.Any()`.

```
"Name"                      scalar
"Contact.Email"             reference navigation
"Orders.Total"              collection navigation, becomes Orders.Any(o => o.Total ...)
"Orders.Items.Sku"          nested collections
```

Ordering across a collection aggregates to one comparable value: `Min` ascending, `Max` descending.

---

## 6. Validation

Every shape is validated before a query is built; failures throw `LogicException` carrying a string
error code. The rules that catch people out:

- `Sort` must be unique within any list of conditions, sub-groups or condition sets.
- Condition sets after the first must specify an `Intersection`.
- `Values` count must match the operator: 1 for most, 2 for `Between`/`NotBetween`, 0 for
  `IsNull`/`IsNotNull`, 1+ for the `In` family.
- `PageNumber` and `PageSize` must be 1 or greater.
- A `Having` clause may only reference aggregate aliases declared in the same `GroupBy`.

Full list: https://doc.dynamicwhere.com/docs/errors

---

## 7. 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.

### Lifecycle

```csharp
// 1. Once at startup. Refused on a second call.
builder.Services.AddDwPolicies(
    builder.Configuration.GetSection("DynamicWhere:Policies"),
    options =>
    {
        options.Entities.Expose<Employee>("Employee");
        options.TokenVault = new InMemoryTokenVault();
    });

// or without configuration binding:
DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Strict, HashSalt = secret }, providers);

// 2. Once per request, never once per query.
DwPolicyContext caller = await DwPolicy.PrepareAsync(
    new DwPolicyContext()
        .WithSubject(DwSubjectKind.User, userId)
        .WithSubject(DwSubjectKind.Role, "Support")
        .WithSubject(DwSubjectKind.Tenant, tenantId)
        .WithValue("TenantId", tenantId));

// 3. Per query. The only entry point.
FilterResult<Employee> result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter);
```

`ApplyPolicy` returns a guarded handle mirroring every method above. An unprepared context is
refused by any store provider. Requests are sanitized before the query is built; results are
transformed after they materialize. The query engine itself is unchanged.

### Attributes — all 18

```
Access control
  [DwDeny(PolicyFeature features)]        refuse any combination of the six features
  [DwDenied]                              refuse all six
  [DwNoWhere] [DwNoSelect] [DwNoOrder] [DwNoGroup] [DwNoAggregate]
  [DwOperators(Allow = Operator[], Deny = Operator[])]
  [DwEntity(RequirePolicy = true)]        class-level; an unguarded query throws

Injection
  [DwAlias("public_name")]                 accepted anywhere a path is, renamed back on output
  [DwForceWhere(Operator, Value = "...", ContextValue = "...")]
  [DwRequireWhere(Operators = Operator[])]

Transformation                             all six carry AllowAggregate and MinGroupSize
  [DwMask(MaskStrategy, KeepStart, KeepEnd, MaskChar, PreserveLength,
          Pattern, Replacement, Text, TokenScope)]
  [DwMutate(typeof(T))]                    T : IValueTransformer
  [DwDefault] / [DwDefault("value")]
  [DwGeneralize(GeneralizeMode, Step, Part, Decimals)]
  [DwTruncate(length, Ellipsis = "...")]
  [DwFormat("fmt")]

Discovery, budget, audit
  [DwDescribe(Label, Description, Group, Order)]
  [DwAllowedValues("a", "b", "c")]
  [DwCost(weight)]
  [DwAudit(PolicyFeature features)]

Every policy attribute also carries Overridable (default false).
```

### Policy enums — verbatim

```
PolicyFeature     None=0 Where=1 Select=2 Order=4 Group=8 Aggregate=16 Segment=32 All=63   [Flags]
MaskStrategy      Full Partial Email Phone Regex Fixed Hash Null Tokenize
GeneralizeMode    Round Bucket DatePart Truncate
DatePart          Year Quarter Month Day
DwTier            Convenience Strict
DwSubjectKind     Global Tenant Role User Custom
PolicyEffect      Allow Mask Deny
PolicyAction      Allowed Denied Dropped Masked Injected Mutated Defaulted Generalized
PolicyLevel       SealedAttribute=1 DynamicUser=2 DynamicRole=3 DynamicTenant=4
                  DynamicGlobal=5 OverridableAttribute=6
StoreFailureMode  LastKnownGood FailClosed StaticOnly
```

### Precedence

Lowest number wins. Ties break by specificity (exact field beats wildcard), then Priority
descending, then strongest effect (Deny > Mask > Allow). A multi-role conflict resolves to Deny.

Attributes are **sealed by default**: no runtime rule can lift one unless it is marked
`Overridable = true`.

### Blocked-action semantics

| Caller asked for            | Convenience | Strict |
|-----------------------------|-------------|--------|
| WHERE on a denied field     | throw       | throw  |
| WHERE with a denied operator| throw       | throw  |
| ORDER on a denied field     | drop        | throw  |
| SELECT a denied field       | drop        | throw  |
| GROUP BY a denied field     | throw       | throw  |
| AGGREGATE a denied field    | throw       | throw  |
| `[DwRequireWhere]` missing  | throw       | throw  |
| over any cap                | throw       | throw  |

All selects dropped raises `AllSelectsDenied` rather than selecting everything.

### Transforms

Chain order is fixed: mutate → generalize → format → mask → truncate. `[DwDefault]` short-circuits
all of them. Every stage but `Null` emits text, so a mask is only valid on a member text can be
assigned to; a numeric member uses `[DwGeneralize]`.

`Hash` is HMAC-SHA256 keyed by `DwPolicyOptions.HashSalt`, which must be at least 16 characters.
`Tokenize` draws a random token from `DwPolicyOptions.TokenVault`, scoped to the field path unless
`TokenScope` names a shared one. Vaults: `InMemoryTokenVault` (core), `RedisTokenVault`,
`EfTokenVault`. Both strategies produce 32 lowercase hex characters and both preserve equality, so
the column still groups and joins.

### Caps — `DwPolicyOptions.Caps`

```
MaxPageSize          1000
MaxConditions        50
MaxOrderFields       10
MaxNavigationDepth   4
MaxQueryCost         1000
DefaultFieldCost     1
MaxAuditEvents       10000
MinGroupSize         5       k-anonymity floor; write 1 to switch it off
SchemaDepth          2       levels a schema request walks by default
SchemaCycleLimit     2       times one type may repeat on one path
MaxSchemaFields      2000
```

Frozen at startup. Every cap refuses a value below 1 except `DefaultFieldCost`, which accepts 0.

### Configuration

```jsonc
{
  "DynamicWhere": {
    "Policies": {
      "Tier": "Strict",
      "DryRun": false,
      "StoreFailure": "LastKnownGood",
      "MaxSnapshotAge": "00:15:00",
      "RefreshInterval": "00:00:30",
      "Caps": { "MinGroupSize": 5, "SchemaDepth": 2, "MaxSchemaFields": 2000 }
    }
  }
}
```

A key nothing answers to refuses to start. The entity catalogue, the token vault and the service
provider cannot come from configuration — they are objects, and stay in code.

### Admin endpoints — `app.MapDwPolicyAdmin(...)`

Refuses to map without a named authorization policy. There is no default.

```
POST   /dw-policies/schema      { entity, paths?, depth? }   fields for a filter UI
GET    /dw-policies/rules?subject=
POST   /dw-policies/rules                                    upsert; sealed fields rejected
DELETE /dw-policies/rules/{id}
POST   /dw-policies/explain     { entity, field?, paths?, depth? }
POST   /dw-policies/simulate    { entity, filter }           sanitized clause, not executed
GET    /dw-policies/health
```

`/schema` is a POST because `paths` is a list and a comma is legal in a `[DwAlias]`.

### Policy error codes — `PolicyException.ErrorCode`

```
1  FieldDeniedForWhere        12 MissingContextValue
2  FieldDeniedForSelect       13 AmbiguousFieldName
3  FieldDeniedForOrder        14 QueryStringDenied
4  FieldDeniedForGroup        15 AmbiguousGroupKey
5  FieldDeniedForAggregate    16 TransformRequiresMaterialization
6  FieldDeniedForSegment      17 StoreUnavailable
7  AllSelectsDenied           18 PolicyContextNotPrepared
8  OperatorNotAllowed         19 QueryCostExceeded
9  CapExceeded                20 GroupTooSmall
10 PolicyRequired             21 MissingHashSalt
11 RequiredFilterMissing      22 MissingTokenVault
```

`PolicyException` derives from `LogicException`, so existing catch blocks keep working.

---

## 8. Traps that produce silently wrong code

Read this section before generating policy attributes.

1. **A mask without `[DwNoOrder]` leaks through sorting.** Sorting runs against the stored value in
   SQL, so paging a masked column in order ranks the real values. Pair them.

2. **`AllowAggregate` without a group floor is a hole.** Aggregation runs in SQL before any
   transform, so `MAX` over a group of one returns that row's exact value. `MinGroupSize` is the
   other half; neither works alone.

3. **What protects a masked-but-filterable column is `[DwOperators]`**, not the mask. Restrict it to
   `Equal` and `In` so a caller can confirm a value they know and cannot sweep for one they do not.

4. **A forced predicate wraps, never merges.** `(A OR B) AND TenantId = 5`, not
   `(A OR B OR TenantId = 5)`. The library does this correctly; do not hand-build the equivalent.

5. **`[DwDenied]` on a navigation does not deny the fields beneath it.** The denial is on the
   navigation object. Deny the children or use a wildcard rule.

6. **Tokenized columns only join across entities when both sides name the same `TokenScope`.**
   Without it each field gets its own tokens by design.

7. **Neither `Hash` nor `Tokenize` hides equality.** Anyone who can write a chosen value and read
   the column back learns that value's stand-in. If the caller has no reason to group or join on the
   column, use `Fixed`, `Null`, or deny it.

8. **A context is prepared once per request, not per query.** A query on an unprepared context is
   refused with `PolicyContextNotPrepared`.

9. **Masking happens after materialization on detached objects.** Never apply it to tracked
   entities; `ApplyPolicy` handles the detaching.

10. **The group floor suppresses rows, it does not refuse the query.** A summary whose every group
    is below the floor returns an empty list, not an error.

---

## 9. Worked examples

### Filter

```csharp
using DynamicWhere.ex.Source;

var filter = new Filter
{
    ConditionGroup = new ConditionGroup
    {
        Sort = 1,
        Connector = Connector.And,
        Conditions =
        {
            new Condition { Sort = 1, Field = "Department", DataType = DataType.Text,
                            Operator = Operator.Equal, Values = { "Engineering" } },
            new Condition { Sort = 2, Field = "Salary", DataType = DataType.Number,
                            Operator = Operator.Between, Values = { 50000, 120000 } },
        },
    },
    Orders = new List<OrderBy> { new() { Sort = 1, Field = "HireDate", Direction = Direction.Descending } },
    Page = new PageBy { PageNumber = 1, PageSize = 25 },
    Selects = new List<string> { "Id", "FirstName", "Department" },
};

FilterResult<Employee> result = await db.Employees.ToListAsync(filter);
```

### Summary

```csharp
var summary = new Summary
{
    GroupBy = new GroupBy
    {
        Fields = { "Department" },
        AggregateBy =
        {
            new AggregateBy { Field = "Salary", Alias = "Total", Aggregator = Aggregator.Sumation },
            new AggregateBy { Field = "Id", Alias = "Headcount", Aggregator = Aggregator.Count },
        },
    },
};

SummaryResult result = await db.Employees.ToListAsync(summary);
```

### Segment

```csharp
var segment = new Segment
{
    ConditionSets =
    {
        new ConditionSet { Sort = 1, ConditionGroup = active },
        new ConditionSet { Sort = 2, Intersection = Intersection.Except, ConditionGroup = onLeave },
    },
};

SegmentResult<Employee> result = await db.Employees.ToListAsync(segment);
```

### A policy-protected entity

```csharp
[DwEntity(RequirePolicy = true)]
public class Employee
{
    public Guid Id { get; set; }

    public string FirstName { get; set; } = string.Empty;

    // Confirmable, not searchable, and unreadable: the operators stop a sweep,
    // the token stops the read, and [DwNoOrder] stops the sort from ranking it.
    [DwAlias("Code")]
    [DwOperators(Allow = new[] { Operator.Equal, Operator.In })]
    [DwMask(MaskStrategy.Tokenize)]
    [DwNoOrder]
    public string EmployeeCode { get; set; } = string.Empty;

    [DwMask(MaskStrategy.Email)]
    [DwNoOrder]
    public string Email { get; set; } = string.Empty;

    // Rounded on the way out, aggregatable only over groups of five or more.
    [DwGeneralize(GeneralizeMode.Round, Step = 5000, AllowAggregate = true, MinGroupSize = 5)]
    [DwNoOrder, DwAudit, DwCost(10)]
    public decimal Salary { get; set; }

    // Every guarded query is scoped to this, asked for or not.
    [DwForceWhere(Operator.Equal, Value = "true")]
    public bool IsActive { get; set; }

    [DwDenied]
    public JsonDocument? WorkSchedule { get; set; }
}
```

---

## 10. Where to read more

```
https://doc.dynamicwhere.com/docs                      overview
https://doc.dynamicwhere.com/docs/examples             13 JSON recipes
https://doc.dynamicwhere.com/docs/policies             the policy layer
https://doc.dynamicwhere.com/docs/policies/use-cases   scenarios and attribute combinations
https://doc.dynamicwhere.com/docs/policies/security    the seven inference channels
https://doc.dynamicwhere.com/docs/errors               every error code
https://github.com/Sajadh92/DynamicWhere.ex            source
```
It says what the library does, not what it should do
The reference is generated against version 3.0.0 and states behaviour, including the parts that are deliberately blunt — that neither hashing nor tokenization hides equality, for instance. If an agent proposes a design this file says is unsafe, the file is the one to trust. For the reasoning behind any rule, the human pages carry it: start at Use cases or Security.