DynamicWhere.ex is intentionally opinionated about how queries are shaped.
- The twenty-nine points below cover constraints, surprises, and corner cases —
+ The thirty-two points below cover constraints, surprises, and corner cases —
read them before designing an API around the library so you can pick the
right entry points and avoid runtime exceptions in production.
+
+ Points 30 to 32 changed in 3.3.0. Under{" "}
+ Strict, a path that exists on the type and names no value
+ the query can compute is refused rather than run, where the provider
+ used to throw and the caller saw a five-hundred (point 30).{" "}
+ LastTrace is assigned before a request is sanitized, so a
+ refused request leaves its own trace readable (point 31). And{" "}
+ DwPolicy.Configure takes a second call asking for the
+ posture already in force, which is what lets an integration suite start
+ several hosts over one composition root (point 32).
+
Points 25 to 29 changed in 3.2.0, and each is
visible to code written for 3.1.0. A guarded query that sends no{" "}
@@ -1446,6 +1457,100 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
+
30. Under Strict, a Path the Query Cannot Compute Is Refused
+
+ A member of a row's type is not always a value a database can
+ produce. A shared type with two columns and a getter over them —{" "}
+ LocalizedText with Ar, En and{" "}
+ IsEmpty — gives two paths that translate and one that
+ cannot. The policy has nothing to say about the third:{" "}
+ [DwNoWhere] on Name matches that path and not
+ the ones beneath it. So every check passed, and EF Core threw{" "}
+ InvalidOperationException — a five-hundred where the strict
+ tier promises a refusal, and the one place the tier answered a caller
+ with neither an answer nor a refusal.
+
+
+ Since 3.3.0 such a path is refused as an unknown name
+ is: the clause's own code, FieldPath{" "}
+ "*", in a filter, an order, a projection, a group
+ and an aggregate alike. It is refused only where the whole set of
+ members a container can produce is known.
+
+
+
+
Source of the rows
Read from
Name.IsEmpty
+
+
+
An entity
the EF Core model: columns, shadow properties, owned and complex members, navigations
Refused
+
A row a Select built before ApplyPolicy
that initializer's own assignments, at every level
Refused
+
…where the Select copies the member, Name = role.Name
the model, beneath the member it copies
Refused
+
Rows in memory
nothing — the getter runs
Runs, as before
+
Anything beneath a column, converted or not
nothing — the converter decides
Runs, as before
+
A framework member: Length, Year, Count
nothing — the provider translates it
Runs, as before
+
A source the library cannot read
nothing
Runs, as before
+
+
+
+ An unmapped getter on the entity itself,{" "}
+ Display => $"{Code}:{Id}",
+ is refused for the same reason. The convenience tier and a dry run are
+ unchanged: both fail exactly as the unguarded query does, which is the
+ provider's own error.
+
+
+ A member EF Core computes through [DbFunction] or an{" "}
+ IMethodCallTranslatorPlugin is not in the model, so the
+ strict tier refuses it. Map it, or filter on the columns beneath it.
+
+
+
31. LastTrace Is Set Before a Request Is Sanitized
+
+ Since 3.3.0{" "}
+ PolicyQueryable<T>.LastTrace carries the trace of a
+ request that was refused. It used to be assigned after sanitizing
+ returned, so a refusal left it holding the previous request's
+ trace, or null on the first. A strict refusal names no field on purpose,
+ and the trace is where the real path and the reason live, so this is
+ what makes one readable. Code that read LastTrace after
+ catching a PolicyException and expected the earlier
+ request's trace reads this request's now.
+
+
+
32. Configure Takes the Same Posture Twice
+
+ Until 3.3.0 the first{" "}
+ DwPolicy.Configure won and every later call threw, so an
+ integration suite starting several{" "}
+ WebApplicationFactory hosts over one composition root had
+ to read IsConfigured first — 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; one asking for a
+ different posture still throws{" "}
+ InvalidOperationException. The comparison happens inside
+ the lock that does the configuring, so no caller needs a lock of its
+ own.
+
+
+ Compared: the tier, DryRun,{" "}
+ IncludeTraceInResult, AuditRefusals,{" "}
+ HashSalt, StoreFailure,{" "}
+ MaxSnapshotAge, RefreshInterval, every cap
+ including the group floor's opt-out, the exposed entity catalogue,
+ and the provider types in the order supplied. Not compared, and not
+ replaced: TokenVault, Services and the
+ provider instances — a second host builds its own, and no two are ever
+ the same reference.
+
+
+ In one test process that is what you want. Elsewhere, start a second
+ host only if it is. AddDwPolicies also registers the
+ posture in force rather than the instance it has just built, so a
+ container resolving DwPolicyOptions after a second
+ registration gets the first one's. Code that relied on the second
+ call throwing no longer sees the exception.
+
+
+ Filter.Clone() is public since 3.3.0. It
+ returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing is shared with the original.
+
+ {`Filter page2 = caller.Clone();
+page2.Page!.PageNumber = 2; // the caller's own filter is untouched`}
+
+ Reading one request again with a part changed, the next page or another
+ order, used to mean rebuilding it around the caller's own clauses,
+ which leaves both requests holding one condition tree: a rewrite of
+ either reaches both. The library has cloned before rewriting anything
+ since 3.0; callers could not until now. A branch the caller left null
+ stays null.
+
+ Segment.Clone() is public since 3.3.0. It
+ returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing is shared with the original.
+
+ {`Segment page2 = caller.Clone();
+page2.Page!.PageNumber = 2; // the caller's own segment is untouched`}
+
+ Reading one request again with a part changed, the next page or another
+ order, used to mean rebuilding it around the caller's own clauses,
+ which leaves both requests holding one condition tree: a rewrite of
+ either reaches both. The library has cloned before rewriting anything
+ since 3.0; callers could not until now. A branch the caller left null
+ stays null.
+
+ Summary.Clone() is public since 3.3.0. It
+ returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing is shared with the original.
+
+ {`Summary page2 = caller.Clone();
+page2.Page!.PageNumber = 2; // the caller's own summary is untouched`}
+
+ Reading one request again with a part changed, the next page or another
+ order, used to mean rebuilding it around the caller's own clauses,
+ which leaves both requests holding one condition tree: a rewrite of
+ either reaches both. The library has cloned before rewriting anything
+ since 3.0; callers could not until now. A branch the caller left null
+ stays null.
+
+
See also
diff --git a/OfficialWebsite/app/docs/extensions/summary/page.tsx b/OfficialWebsite/app/docs/extensions/summary/page.tsx
index a24e8b8..57a9366 100644
--- a/OfficialWebsite/app/docs/extensions/summary/page.tsx
+++ b/OfficialWebsite/app/docs/extensions/summary/page.tsx
@@ -21,6 +21,16 @@ export default function Page() {
returns a dynamic IQueryable.
+
+ DwCaps.MinGroupSize ships on, at 5, so a
+ guarded summary removes every group with fewer than five rows — not
+ refused, and nothing in the answer says a group was dropped. That is
+ right for anonymised reporting and surprising for an operational count.
+ Set Caps.MinGroupSize = 1 to switch the floor off,
+ deliberately. An unguarded call is never floored. See{" "}
+ k-anonymity.
+
+
Signature
{`public static IQueryable Summary(this IQueryable query, Summary summary)
where T : class`}
diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx
index 3a309d3..ea7b9a7 100644
--- a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx
+++ b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx
@@ -26,6 +26,16 @@ export default function Page() {
CancellationToken, which reaches both.
+
+ DwCaps.MinGroupSize ships on, at 5, so a
+ guarded summary removes every group with fewer than five rows — not
+ refused, and nothing in the answer says a group was dropped. That is
+ right for anonymised reporting and surprising for an operational count.
+ Set Caps.MinGroupSize = 1 to switch the floor off,
+ deliberately. An unguarded call is never floored. See{" "}
+ k-anonymity.
+
+
Signature
{`public static Task ToListAsync(
this IQueryable query,
diff --git a/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx
index c6425ec..b301c3e 100644
--- a/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx
+++ b/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx
@@ -25,6 +25,16 @@ export default function Page() {
with pagination metadata.
+
+ DwCaps.MinGroupSize ships on, at 5, so a
+ guarded summary removes every group with fewer than five rows — not
+ refused, and nothing in the answer says a group was dropped. That is
+ right for anonymised reporting and surprising for an operational count.
+ Set Caps.MinGroupSize = 1 to switch the floor off,
+ deliberately. An unguarded call is never floored. See{" "}
+ k-anonymity.
+
+
IQueryable<T> overload
{`public static SummaryResult ToList(
this IQueryable query,
diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx
index 88a27ce..075f69b 100644
--- a/OfficialWebsite/app/docs/installation/page.tsx
+++ b/OfficialWebsite/app/docs/installation/page.tsx
@@ -28,14 +28,14 @@ export default function Page() {
Levels a schema request walks when it names no depth.
SchemaCycleLimit
2
Times one type may appear on one path.
MaxSchemaFields
2000
Fields one schema response may carry before it truncates.
-
MinGroupSize
5
k-anonymity group floor. Set 1 to switch it off. See Security.
+
MinGroupSize
5
On by default. A guarded summary drops every group with fewer than five rows, and nothing in the answer says so. Set 1 to switch it off. See Security.
@@ -904,6 +904,65 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
which refuse it in any clause, while a filter still orders by it.
+
Configuring twice
+
+ The first call decides the posture. Since 3.3.0 a
+ second DwPolicy.Configure, or a second{" "}
+ AddDwPolicies, asking for the posture already in
+ force does nothing and returns; one asking for a different
+ posture still throws InvalidOperationException. The
+ comparison happens inside the lock that does the configuring, so a
+ caller needs no lock and no IsConfigured check of its own —
+ which matters, because that check is a check-then-act two hosts starting
+ at once can both pass.
+
+
+ This is what an integration suite needs. Several{" "}
+ WebApplicationFactory<Program> hosts run the same
+ composition root, and before 3.3.0 the second one threw, so every such
+ suite wrote the check itself and re-registered{" "}
+ DwPolicy.Options by hand.
+
Every value on Caps, the group floor's opt-out included
+
The provider instances
+
+
+
The exposed entity catalogue: the same types under the same names
+
+
+
+
The provider types, in the order they were supplied
+
+
+
+
+
+ The three on the right are objects a host builds for itself, and a
+ second host builds its own, so comparing them by reference would make
+ every second call a refusal. They stay as the first call left them. In
+ one test process that is what you want; start a second host in
+ production only if it is.
+
+
+ AddDwPolicies registers the posture in force rather than
+ the instance it has just built, so whatever resolves{" "}
+ DwPolicyOptions reads what the query path reads. The
+ options handed to a second call are frozen too, so nothing goes on
+ setting values that decide nothing.
+
+
Configuration from a file
Every value on the posture binds from IConfiguration. Three
diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx
index 1cdead2..3416da2 100644
--- a/OfficialWebsite/app/docs/policies/providers/page.tsx
+++ b/OfficialWebsite/app/docs/policies/providers/page.tsx
@@ -22,7 +22,7 @@ export default function Page() {
Redis
- {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.2.0`}
+ {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.3.0`}{`var redis = await ConnectionMultiplexer.ConnectAsync(connectionString);
var store = new RedisPolicyStore(redis);
@@ -37,7 +37,7 @@ DwPolicy.Configure(options, provider);`}
Entity Framework Core
- {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.2.0`}
+ {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.3.0`}{`var policyDbOptions = new DbContextOptionsBuilder()
.UseNpgsql(connection, sql => sql.MigrationsAssembly("YourProject"))
.Options;
diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx
index 40fbf3d..b43bce7 100644
--- a/OfficialWebsite/app/docs/policies/security/page.tsx
+++ b/OfficialWebsite/app/docs/policies/security/page.tsx
@@ -184,6 +184,36 @@ true order. Add [DwNoOrder] unless that is intended.`}
together describe how the rows are partitioned.
+
A path the query cannot compute
+
+ A member of a row's type is not always a value a database can
+ produce. A shared kernel type carrying two columns and a getter over
+ them gives Name.Ar and Name.En, which
+ translate, and Name.IsEmpty, which does not. The policy has
+ nothing to say about the third — [DwNoWhere] on{" "}
+ Name matches that path and not the ones beneath it — so
+ until 3.3.0 every check passed and EF Core threw. The
+ caller got a five-hundred where the strict tier promises a refusal.
+
+
+ Such a path is now refused as an unknown name is: the clause's own
+ code, FieldPath"*", so it cannot be
+ told from a misspelling or from a field the caller may not use. It is
+ refused only where the whole set of members a container can produce is
+ known — an entity's own EF Core model, and the initializers of a
+ projection composed before ApplyPolicy, including a member
+ that projection copies from the entity. Rows in memory, a framework
+ member the provider translates such as Length or{" "}
+ Year, anything beneath a column, the convenience tier and a
+ dry run are all unchanged, and fail exactly as the unguarded query does.
+
+
+ This closes no leak: the query failed, it did not answer. It removes a
+ way of telling one member from another by the shape of the failure, and
+ it keeps the tier's promise that a guarded request is answered or
+ refused. The trace records the reason.
+
+
7 and 8. The two that are not channels
Attack
Control
diff --git a/OfficialWebsite/lib/nav.ts b/OfficialWebsite/lib/nav.ts
index 077ead3..979e3b3 100644
--- a/OfficialWebsite/lib/nav.ts
+++ b/OfficialWebsite/lib/nav.ts
@@ -8,7 +8,7 @@ export const SITE = {
"DynamicWhere.ex is a free .NET library for building dynamic, JSON-driven LINQ queries on Entity Framework Core — filter, sort, paginate, project, group, aggregate, and run UNION / INTERSECT / EXCEPT set operations from your front-end. Works with ASP.NET Core on .NET 6, 7, 8, 9, and 10.",
shortDescription:
"Dynamic JSON filter, sort, paginate, group, aggregate, and set operations for EF Core. .NET 6/7/8/9/10.",
- version: "3.2.0",
+ version: "3.3.0",
domain: "doc.dynamicwhere.com",
repo: "https://github.com/Sajadh92/DynamicWhere.ex",
nuget: "https://www.nuget.org/packages/DynamicWhere.ex",
diff --git a/OfficialWebsite/package.json b/OfficialWebsite/package.json
index f17a1de..cac3161 100644
--- a/OfficialWebsite/package.json
+++ b/OfficialWebsite/package.json
@@ -1,6 +1,6 @@
{
"name": "dynamicwhere-docs",
- "version": "3.2.0",
+ "version": "3.3.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt
index 4149939..d09fe56 100644
--- a/OfficialWebsite/public/llms.txt
+++ b/OfficialWebsite/public/llms.txt
@@ -4,7 +4,7 @@
> opt-in field-level policy layer that decides what each caller may filter, sort, select, group,
> aggregate and see, and a reflection cache that needs no setup.
>
-> Version 3.2.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT
+> Version 3.3.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT
> Docs: https://doc.dynamicwhere.com · Source: https://github.com/Sajadh92/DynamicWhere.ex
This file is the whole library in one pass: every public type and member of the four packages, the
@@ -14,7 +14,7 @@ library. No other page is needed; where another page disagrees with this file, t
Where a name is not in this file, it does not exist — do not invent members.
```
-dotnet add package DynamicWhere.ex --version 3.2.0
+dotnet add package DynamicWhere.ex --version 3.3.0
dotnet add package DynamicWhere.ex.Policies.Redis # optional, same version as the core
dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore # optional, same version as the core
dotnet add package DynamicWhere.ex.Policies.AspNetCore # optional, same version as the core
@@ -203,6 +203,15 @@ Summary ConditionGroup ConditionGroup? null where, bef
Having ConditionGroup? null each Condition.Field is an AggregateBy.Alias, not a path
Orders List? null each Field is a GroupBy field or an Alias
Page PageBy? null pages the groups
+
+Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, shares no object
+ with the original: the condition tree with its
+ groups and conditions, the Selects list, each
+ OrderBy, the Page, and a Summary's GroupBy with
+ its AggregateBy list and its Having. A null
+ branch stays null. Use it to read one request
+ again with a different page or order without
+ editing what the caller handed in.
```
```
@@ -675,7 +684,7 @@ against 3.1 still binds.
- Validation rewrites the caller's objects in place: path strings get the declared casing (`"price"` → `"Price"`)
in `Condition.Field`, `OrderBy.Field`, `GroupBy.Fields`, `AggregateBy.Field` and `Selects`; null `Values`,
`Conditions`, `SubConditionGroups`, `ConditionSets`, `Fields` and `AggregateBy` become empty lists; the first
- `ConditionSet`'s `Intersection` becomes null. Clone a shape before reusing it if that matters.
+ `ConditionSet`'s `Intersection` becomes null. Call `Clone()` on the shape before reusing it if that matters (3.3.0).
### Select
@@ -1320,7 +1329,7 @@ Each of these compiles, passes validation, and returns something other than what
3. **Negated operators never match null.** `NotEqual`, `NotIn`, `NotContains`, `NotStartsWith`, `NotEndsWith` and
`NotBetween` all exclude rows whose member is null. Add an `IsNull` condition in an `Or` group to keep them.
4. **Validation rewrites the request objects you pass.** Paths are re-cased, null lists become empty, the first
- set's `Intersection` is cleared. Clone a shape before reusing it. (Guarded calls work on a clone.)
+ set's `Intersection` is cleared. Call `Clone()` on the shape before reusing it (3.3.0). (Guarded calls work on a clone.)
5. **Enum names in JSON need `JsonStringEnumConverter`.** Without it the body fails to bind. An omitted enum
property silently becomes member 0: `Text`, `Equal`, `And`, `Ascending`, `Count`.
6. **A `DateTime` member reads a zoned value as server local time.** A value carrying `Z` or an offset is converted
@@ -1446,7 +1455,19 @@ DwPolicy stat
- `Configure` refuses only two things:
- null `options` → `ArgumentNullException`;
- - a second call, including one made by `AddDwPolicies` → `InvalidOperationException`.
+ - a second call, including one made by `AddDwPolicies`, asking for a **different** posture → `InvalidOperationException`.
+- A second call asking for the posture already in force is a no-op (3.3.0). It returns, `DwPolicy.Options` keeps
+ pointing at the first call's instance, and the options passed to the second call are frozen. The check runs inside
+ the lock that configures, so a caller needs no `IsConfigured` check of its own — that check is a check-then-act two
+ hosts starting at once can both pass. This is what lets an integration suite start several
+ `WebApplicationFactory` hosts over one composition root.
+ - Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`,
+ `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value with `IsMinGroupSizeSet`, the exposed catalogue
+ (same types under the same names), and the provider **types**, in the order supplied, with
+ `AttributePolicyProvider` left out of both sides.
+ - Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds
+ its own and no two are ever the same reference, so they stay as the first call left them — a second host runs
+ with the first host's vault, container and rule stores.
- `Configure` freezes `options`, `options.Caps` and `options.Entities`. Any setter or `Expose` after that → `InvalidOperationException`.
- `Configure` does not validate the model.
- Invalid values were already refused by the setters.
@@ -1478,14 +1499,19 @@ DwPolicyConfiguration stat
Bind(this DwPolicyOptions options, IConfiguration section) -> DwPolicyOptions same instance
```
-`AddDwPolicies` does exactly this: `new DwPolicyOptions().Bind(section)` → `configure?.Invoke(options)` → `DwPolicy.Configure(options, providers)` → `services.AddSingleton(options)`.
+`AddDwPolicies` does exactly this: `new DwPolicyOptions().Bind(section)` → `configure?.Invoke(options)` → `DwPolicy.Configure(options, providers)` → `services.AddSingleton(DwPolicy.Options)`.
+
+Since 3.3.0 the singleton registered is the posture **in force**, not the instance this call built. On the first call
+they are the same object. On a later call asking for the same posture the first one's is registered, so whatever
+resolves `DwPolicyOptions` reads what the query path reads. A later call asking for a different posture still throws.
- There is one overload, and `section` is required (null → `ArgumentNullException`).
- `"DynamicWhere:Policies"` is a convention; any section works.
- For configuration from code only, call `DwPolicy.Configure`.
- Configuration binds first and `configure` runs second, so code wins over the file.
- It configures the static `DwPolicy` immediately, during service registration. Anything set in `configure` (`Services` included) must already exist at that point.
-- It registers only the frozen `DwPolicyOptions` singleton. No resolver, sink, vault, catalogue or provider is registered.
+- It registers only the frozen `DwPolicyOptions` singleton — the one in force. No resolver, sink, vault, catalogue or provider is registered.
+- Calling it a second time with the same posture is safe (3.3.0), which is what an integration suite starting several hosts needs.
- A `StorePolicyProvider` reads `StoreFailure`, `MaxSnapshotAge` and `RefreshInterval` from the options instance passed to `StorePolicyProvider.CreateAsync`.
- `AddDwPolicies` creates its options internally, so a provider cannot share them. With a store, bind by hand:
@@ -1811,6 +1837,9 @@ Rules:
- On a fresh handle it is the original, tracked source with no predicates.
- Calling a DynamicWhere extension on it for a `RequirePolicy` type → `PolicyRequired`.
- `LastTrace` is set on the handle the method was called on, not on the handle it returns.
+- `LastTrace` is assigned before the request is sanitized (3.3.0), so a refused request leaves its own trace
+ readable. It used to be assigned after, so a refusal left the previous request's trace, or null. A strict refusal
+ names no field, and the trace is where the real path and reason are.
- It holds the latest call that got past sanitizing, or a `QueryStringDenied` refusal.
- Refusals throw `PolicyException` (a `LogicException`) from the handle method.
- With `DwPolicyOptions.AuditRefusals` on, the terminal and composable methods also write the refusal to the context's audit buffer on its way out, once, without changing or catching it (section 22).
@@ -2282,6 +2311,26 @@ The resolver, fragment and provider types are listed in section 21.
## 17. Enforcement by tier and dry run
+Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused
+as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`. What decides:
+
+```
+Source of the rows What the set of producible members is read from Name.IsEmpty
+entity query EF Core model: columns (shadow included), owned,
+ complex, navigations, and derived types refused
+Select(...) before ApplyPolicy building the row that initializer's assignments, at every level refused
+ ... a member it copies whole: Name = role.Name the model, beneath the copied member refused
+rows in memory (EnumerableQuery) nothing; the getter runs runs
+beneath a column, converted or not nothing; the converter decides runs
+a framework member: Length, Year, Count, HasValue nothing; the provider translates it runs
+a source the library cannot read into nothing runs
+```
+
+- An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason.
+- `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded.
+- A member a `[DbFunction]` or an `IMethodCallTranslatorPlugin` computes is not in the model and is refused too.
+- The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`.
+
The count caps are checked first, before any name is resolved (3.1.0). Names are then resolved to canonical paths,
`MaxNavigationDepth` is checked, and the cost budget is checked before field policies — except under `Strict` outside
dry run, where it is checked after them (3.1.0). Nothing is clamped.
@@ -2715,6 +2764,10 @@ ValueTransform(MutateStage? mutate = null, GeneralizeStage? generalize = null, F
## 19. Group floor and transformed summaries
+**The floor is on by default.** `Caps.MinGroupSize` ships at 5, so a guarded summary drops every group with fewer
+than five rows, and nothing in the answer says a group was dropped. Right for anonymised reporting, surprising for
+an operational count. `Caps.MinGroupSize = 1` switches it off, deliberately. An unguarded summary is never floored.
+
```
effective floor = max(Caps.MinGroupSize, MinGroupSize of the chain of each AggregateBy.Field)
Caps.MinGroupSize unset -> DwCaps.DefaultMinGroupSize = 5 (IsMinGroupSizeSet = false); 1 = no global floor
@@ -4774,6 +4827,29 @@ MemoryCalculationInput
### History
```
+3.3.0 A second host may configure the same posture, a path the query cannot compute is refused rather than run,
+ and Clone is public on Filter, Segment and Summary.
+ - New. DwPolicy.Configure, and AddDwPolicies with it, take a second call asking for the posture already in
+ force and do nothing. A different posture still throws. The check is inside the lock that configures, so a
+ caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass.
+ Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge,
+ RefreshInterval, every cap with IsMinGroupSizeSet, the exposed catalogue, and the provider types in order.
+ Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with
+ the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than
+ the instance it built.
+ - Behaviour change. Under Strict outside a dry run, a path that exists on the type and names no value the
+ query can compute — LocalizedText.IsEmpty over two columns, an unmapped getter on the entity — is refused
+ as an unknown name is, in every clause, where the provider used to throw and the caller saw a five-hundred.
+ Refused only where the producible members are known: an entity's model, and the initializers of a
+ projection composed before ApplyPolicy, including a member it copies from the entity. Rows in memory, a
+ framework member such as Length or Year, anything beneath a column, Convenience and a dry run are unchanged.
+ - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own
+ trace readable instead of the previous request's.
+ - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list,
+ each order, the page, and a summary's GroupBy and Having.
+ - Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The
+ aggregation sections now lead with Caps.MinGroupSize shipping on at 5.
+
3.2.0 A projected row keeps its members, denials the gate could not see are enforced, a default order that
reaches projected rows, and a CancellationToken on every async terminal. The security fixes refuse or
withhold what 3.1.0 returned; the bullets marked "Behaviour change" also change what a correct query
@@ -5019,6 +5095,12 @@ MemoryCalculationInput
`[Column]`. Reached through a navigation (`Owner.New`) it is an ordinary member.
- A declared date format may not write text ISO 8601 or a year-first date already reads; `DwDates.Configure`
refuses one (section 4). ISO 8601 and year-first dates are read on every deployment and cannot be turned off.
+- A member no database can compute — a getter over columns, an unmapped getter on an entity, a member a
+ `[DbFunction]` or a translator plugin computes — cannot be filtered, ordered, selected, grouped or aggregated under
+ `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. Under
+ `Convenience` it still reaches the provider and still throws there.
+- A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store
+ instances (3.3.0, section 12). They are not compared and not replaced.
- Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`).
- `Filter` applies `Orders` and `Page` on T before the projection, so order fields need not be selected.
- `getQueryString` needs an EF Core provider.
diff --git a/README.md b/README.md
index 663e9fe..6de7961 100644
--- a/README.md
+++ b/README.md
@@ -47,13 +47,13 @@ Stop concatenating LINQ predicates by hand. Your front-end sends one JSON shape;
## Install
```bash
-dotnet add package DynamicWhere.ex --version 3.2.0
+dotnet add package DynamicWhere.ex --version 3.3.0
```
Or via Package Manager:
```powershell
-Install-Package DynamicWhere.ex -Version 3.2.0
+Install-Package DynamicWhere.ex -Version 3.3.0
```
Dependencies (restored automatically):
@@ -375,6 +375,16 @@ The complete reference — every enum, class, extension method, validation rule,
---
+## Version 3.3.0 highlights
+
+**Upgrade note — two behaviour changes. Read these before bumping.**
+
+- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — 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. 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. `AddDwPolicies` registers 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 as `LocalizedText.IsEmpty` reads 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. 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 before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it.
+- **Changed: `LastTrace` is 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 on `Filter`, `Segment` and `Summary`.** 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.
+- **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **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.**
From 757e4689ac932b3338a432ae1e882f15c868bce5 Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 12:43:46 +0300
Subject: [PATCH 03/54] test(policies): a composed clause reads the source too,
and every posture value is proved compared
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A clause composed on the guarded handle — Where, Order, Select, Page — now
passes the source's shape to the sanitizer, so it refuses a path the query
cannot compute exactly as a terminal does. Without it the same filter was
refused through ToList and ran to the provider's own failure through Where.
The posture comparison is now checked by reflection: every settable value on
DwPolicyOptions and DwCaps is changed in turn and the second configure must
refuse it. A value added later and forgotten in the comparison would let a
second host run with a posture it did not ask for, and this fails until somebody
decides which column it belongs in. TokenVault and Services are named as the two
that are not compared, and a stale name there fails too.
Also probed: a member a projection never assigns fails unguarded, which is why
refusing it guarded is right rather than an over-block.
Co-Authored-By: Claude Opus 5
---
.../Policies/ConfigureOnceTests.cs | 71 +++++++++++++++++++
.../Policies/UnexpressiblePathTests.cs | 38 ++++++++++
.../Policies/Source/PolicyQueryable.cs | 3 +-
3 files changed, 111 insertions(+), 1 deletion(-)
diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
index e3c2271..5056c8e 100644
--- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
+++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using DynamicWhere.ex.Policies.Config;
using DynamicWhere.ex.Policies.Enums;
using DynamicWhere.ex.Policies.Resolution;
@@ -279,6 +280,76 @@ public void A_second_registration_hands_the_container_the_posture_in_force()
Assert.Same(DwPolicy.Options, provider.GetRequiredService());
}
+ ///
+ /// Every settable value on the posture and on the caps decides whether a second call is the
+ /// same posture, and the two that do not are named here on purpose.
+ ///
+ ///
+ /// Written by reflection rather than by hand: a value added to DwPolicyOptions or
+ /// DwCaps later and forgotten in the comparison would let a second host run with a
+ /// posture it did not ask for, silently, which is the one failure this feature can cause. A
+ /// new property fails this test until somebody decides which column it belongs in.
+ ///
+ [Theory]
+ [InlineData(typeof(DwPolicyOptions))]
+ [InlineData(typeof(DwCaps))]
+ public void Every_value_on_the_posture_is_compared(Type declaring)
+ {
+ // The objects a host builds for itself. A second host builds its own, so comparing them
+ // by reference would refuse every second call; they stay as the first call left them.
+ HashSet notCompared = new() { nameof(DwPolicyOptions.TokenVault), nameof(DwPolicyOptions.Services) };
+
+ List settable = declaring
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(property => property.GetSetMethod() is not null)
+ .Select(property => property.Name)
+ .ToList();
+
+ Assert.NotEmpty(settable);
+
+ foreach (string name in settable)
+ {
+ if (notCompared.Remove(name))
+ {
+ continue;
+ }
+
+ DwPolicyOptions different = Copy();
+ object target = declaring == typeof(DwCaps) ? different.Caps : different;
+ PropertyInfo property = declaring.GetProperty(name)!;
+
+ property.SetValue(target, Other(property.GetValue(target), property.PropertyType));
+
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ Assert.Throws(() => DwPolicy.Configure(different));
+ Assert.Same(inForce, DwPolicy.Options);
+ }
+
+ // Both names belong to the posture, so nothing may be left over once it has been walked.
+ // A stale exclusion would otherwise sit here hiding a property nobody compares.
+ if (declaring == typeof(DwPolicyOptions))
+ {
+ Assert.Empty(notCompared);
+ }
+ }
+
+ /// A value of the same type that is not the one held.
+ private static object? Other(object? held, Type type) => type switch
+ {
+ _ when type == typeof(bool) => !(bool)held!,
+ _ when type == typeof(bool?) => held is true ? false : true,
+ _ when type == typeof(int) => (int)held! + 1,
+ _ when type == typeof(TimeSpan) => (TimeSpan)held! + TimeSpan.FromMinutes(1),
+ _ when type == typeof(string) => new string('x', DwPolicyOptions.MinimumHashSaltLength + 2),
+ _ when type == typeof(DwTier) => (DwTier)held! == DwTier.Strict ? DwTier.Convenience : DwTier.Strict,
+ _ when type == typeof(StoreFailureMode) => (StoreFailureMode)held! == StoreFailureMode.FailClosed
+ ? StoreFailureMode.LastKnownGood
+ : StoreFailureMode.FailClosed,
+ _ => throw new InvalidOperationException(
+ $"No second value is defined for {type.Name}. Add one, and decide whether the comparison covers it.")
+ };
+
private static void Refused(DwPolicyOptions different, params IDwPolicyProvider[] providers)
{
DwPolicyOptions inForce = DwPolicy.Options;
diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
index 342425b..93a258a 100644
--- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
+++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
@@ -250,6 +250,26 @@ public void A_projection_naming_it_is_refused()
Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode);
}
+ [Fact]
+ public void A_clause_composed_on_the_handle_is_refused_too()
+ {
+ // The composed pipeline reads the same source, so it answers as the terminal does.
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(Built(), DwTier.Strict).Where(new ConditionGroup
+ {
+ Conditions =
+ {
+ new Condition
+ {
+ Field = "Name.IsEmpty", DataType = DataType.Boolean,
+ Operator = Operator.Equal, Values = { "false" }
+ }
+ }
+ }));
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode);
+ }
+
[Fact]
public void A_summary_grouping_on_it_is_refused()
{
@@ -268,6 +288,24 @@ public void A_summary_grouping_on_it_is_refused()
// ---- what is left alone ---------------------------------------------------------------------------
+ [Fact]
+ public void A_member_the_projection_never_assigns_fails_unguarded_and_is_refused_guarded()
+ {
+ // Unguarded first: what the provider does with it decides whether refusing is right.
+ IQueryable rows = _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code });
+
+ Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.Ar == "مدير").ToList());
+
+ _out.WriteLine($"unguarded: {unguarded?.GetType().Name ?? "ran"}");
+
+ Assert.NotNull(unguarded);
+
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "مدير")));
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode);
+ }
+
[Fact]
public void Rows_in_memory_run_the_getter_as_they_always_did()
{
diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
index 1311032..fef2b5a 100644
--- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
+++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
@@ -935,7 +935,8 @@ private Filter SanitizeClause(Filter clause, bool applyDefaultOrder = false)
LastTrace = trace;
Filter sanitized = FilterSanitizer.Sanitize(
- clause, _resolver, _context, _options, trace, synthesizeProjection: false, applyDefaultOrder);
+ clause, _resolver, _context, _options, trace, synthesizeProjection: false, applyDefaultOrder,
+ rows: RowShape.Of(_source));
return sanitized;
}
From 073614c545bcdb98908fcd6d5db136785075ac0d Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 12:50:24 +0300
Subject: [PATCH 04/54] test: stop blocking the thread pool, and give the store
watch a budget a loaded run can meet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 3.2.0 publish run failed on one test — the store conformance watch, which
waits up to ten seconds for a store to report a change — while the same suite
had just passed twice on CI. The cause is not that store: four reads in the
wrapper suite blocked a thread-pool thread each for a whole query, which is
enough, on a two-core runner in Release, to leave the watch's reader unscheduled
until its budget runs out.
Those four reads are awaited now, through a ZwKit.CodeAsync that matches Code,
and the build is warning-free again (xUnit1031). The watch test waits on the
watch rather than on the clock, and its budget is sixty seconds: only a store
that never reports waits that long, and a budget tight enough to catch a real
hang is also tight enough to fail a healthy run under load.
Co-Authored-By: Claude Opus 5
---
.../Policies/PolicyStoreConformanceTests.cs | 10 +++++--
.../Policies/ReviewJoinEvalTests.cs | 26 +++++++++++++++++++
.../Policies/ReviewTrackingWrapperTests.cs | 8 +++---
3 files changed, 38 insertions(+), 6 deletions(-)
diff --git a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs
index c4c80ac..988b92e 100644
--- a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs
+++ b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs
@@ -171,7 +171,11 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll()
long before = await store.GetVersionAsync(default);
- using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(10));
+ // Generous on purpose. The watch runs on the thread pool, and a suite of two thousand tests
+ // can leave it waiting behind work that has nothing to do with this store; a budget tight
+ // enough to catch a real hang is also tight enough to fail a healthy run under load. Only a
+ // store that never reports a change waits this long.
+ using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(60));
IAsyncEnumerable? watch = store.WatchAsync(cancel.Token);
@@ -200,7 +204,9 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll()
{
await store.UpsertAsync(Rule(), cancel.Token);
- await Task.Delay(25, cancel.Token);
+ // Waits on the watch itself rather than on the clock, so the loop ends as soon as the
+ // store reports and does not go on writing while the reader waits to be scheduled.
+ await Task.WhenAny(observed, Task.Delay(25, cancel.Token));
}
Assert.True(await observed > before);
diff --git a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs
index 48ae8e1..f736716 100644
--- a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs
+++ b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs
@@ -209,12 +209,38 @@ public IQueryable Level1()
return _db.Customers.Where(c => c.Name != string.Empty).SelectMany(c => level2.Where(x => x.Id == c.Id));
}
+
/// Customers already in memory, handed out as a query.
public IQueryable InMemory(List held) => held.AsQueryable();
}
internal static class ZwKit
{
+ /// The asynchronous twin of , so a test awaits rather than blocks.
+ ///
+ /// Blocking on an asynchronous read holds a thread-pool thread for the whole query. Enough of
+ /// those at once and a test elsewhere in the run that waits on a background task never gets
+ /// scheduled, which is how a suite acquires a flaky failure that has nothing to do with the
+ /// code under test.
+ ///
+ internal static async Task CodeAsync(Func run)
+ {
+ try
+ {
+ await run();
+
+ return "ran";
+ }
+ catch (PolicyException refusal)
+ {
+ return refusal.ErrorCode.ToString();
+ }
+ catch (Exception other)
+ {
+ return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}";
+ }
+ }
+
private static readonly JsonSerializerOptions JsonOptions = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles };
internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class =>
diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs
index 301ac80..55ddf0c 100644
--- a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs
+++ b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs
@@ -239,10 +239,10 @@ public async Task Zw_W1_async_reads_through_a_wrapper_over_a_context_tracking_th
IQueryable source = new ZwAsyncQuery(_db.Shoppers);
object? data = null;
- string code = ZwKit.Code(() => data = ZwKit.Guard(source, tier).ToListAsync(new Filter()).GetAwaiter().GetResult().Data);
- string async = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter()).GetAwaiter().GetResult());
- string segment = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(Union()).GetAwaiter().GetResult());
- string summary = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(CountByName()).GetAwaiter().GetResult());
+ string code = await ZwKit.CodeAsync(async () => data = (await ZwKit.Guard(source, tier).ToListAsync(new Filter())).Data);
+ string async = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter()));
+ string segment = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(Union()));
+ string summary = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(CountByName()));
int after = _db.ChangeTracker.Entries().Count();
_out.WriteLine($"W1 {tier}: list={code} dynamic={async} segment={segment} summary={summary} tracked {before}->{after} sent={ZwKit.Json(data)}");
From 68bc021be70416ab3f5ae409864ba9332a61990e Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 12:53:21 +0300
Subject: [PATCH 05/54] fix(policies): a column only a subtype maps is not one
the queried type can compute
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The walk looked through an entity type's derived types for a member the queried
type does not map, and counted it as a column. EF Core does not: it translates a
member against the type the query is over, and a member mapped one level down
fails with "Translation of member 'Licence' on entity type 'ZyParty' failed.
This commonly occurs when the specified member is unmapped" — which is the
five-hundred this release exists to turn into a refusal. So the look through the
derived types made DW-17 miss the case it was written for.
The queried type's own model decides now. Query the derived type to filter on
such a member, which already worked and still does.
Co-Authored-By: Claude Opus 5
---
.../Policies/UnexpressiblePathTests.cs | 31 ++++++++-
DynamicWhere.ex/Policies/Source/RowShape.cs | 64 ++++---------------
2 files changed, 43 insertions(+), 52 deletions(-)
diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
index 93a258a..b3b5059 100644
--- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
+++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
@@ -47,11 +47,14 @@ public class ZyParty
public int Id { get; set; }
public string Kind { get; set; } = string.Empty;
+
+ /// Declared on the base, mapped only on the subtype, which is where the rows carry it.
+ public string? Licence { get; set; }
}
public class ZyMerchant : ZyParty
{
- public string? Licence { get; set; }
+ public string? Rating { get; set; }
}
/// The row a caller projects before the guard sees it, which is DCMP's shape.
@@ -84,6 +87,11 @@ protected override void OnModelCreating(ModelBuilder model)
model.Entity().HasDiscriminator("Discriminator")
.HasValue("party")
.HasValue("merchant");
+
+ // Ignored where it is declared and mapped where the rows have it, so the model maps the
+ // member on the derived type alone while the queried type still carries the CLR property.
+ model.Entity().Ignore(party => party.Licence);
+ model.Entity().Property(merchant => merchant.Licence);
}
}
@@ -106,7 +114,7 @@ public UnexpressiblePathTests(ITestOutputHelper output)
_db = new ZyContext(_connection);
_db.Database.EnsureCreated();
_db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } });
- _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1" });
+ _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" });
_db.SaveChanges();
_db.ChangeTracker.Clear();
}
@@ -184,6 +192,25 @@ public void A_column_a_subtype_maps_is_still_a_column()
Assert.Single(result.Data);
}
+ [Fact]
+ public void A_column_only_the_subtype_maps_is_refused_through_the_base_type()
+ {
+ // The queried type declares the member and the model maps it one level down. EF Core
+ // translates against the type the query is over, so this is the failure the refusal is
+ // for: "Translation of member 'Licence' on entity type 'ZyParty' failed."
+ Assert.NotNull(_db.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence"));
+ Assert.Null(_db.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence"));
+
+ Exception? unguarded = Record.Exception(() => _db.Parties.Where(party => party.Licence == "L-1").ToList());
+
+ Assert.IsType(unguarded);
+
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(_db.Parties, DwTier.Strict).ToList(Where("Licence", "L-1")));
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode);
+ }
+
[Fact]
public void A_framework_member_the_provider_translates_is_left_alone()
{
diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs
index e94319b..6f4fb9e 100644
--- a/DynamicWhere.ex/Policies/Source/RowShape.cs
+++ b/DynamicWhere.ex/Policies/Source/RowShape.cs
@@ -446,62 +446,26 @@ private static bool Named(object model, string name) =>
&& string.Equals(held, name, StringComparison.OrdinalIgnoreCase);
///
- /// The column a type or any type derived from it maps under a name, or null.
+ /// The column the queried type maps under a name, or null.
///
///
- /// Derived types are read because a hierarchy's rows come back as the queried type: a column
- /// declared by one subtype is still a column the provider can compute for the rows that have it.
+ /// The queried type's own model, not a derived type's. EF Core translates a member against the
+ /// type the query is over: a member the model maps one level down, on a subtype, fails with
+ /// "Translation of member '…' failed. This commonly occurs when the specified member is
+ /// unmapped", which is the failure this whole walk exists to turn into a refusal. Query the
+ /// derived type to filter on it.
///
private static IProperty? MappedProperty(IEntityType entity, string name) =>
- entity.GetProperties().FirstOrDefault(property => Same(property.Name, name))
- ?? entity.GetDerivedTypes()
- .SelectMany(derived => derived.GetProperties())
- .FirstOrDefault(property => Same(property.Name, name));
+ entity.GetProperties().FirstOrDefault(property => Same(property.Name, name));
- /// The navigation a type or any type derived from it maps under a name, or null.
- private static INavigationBase? MappedNavigation(IEntityType entity, string name)
- {
- INavigationBase? found =
- entity.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name))
- ?? (INavigationBase?)entity.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name));
-
- if (found is not null)
- {
- return found;
- }
-
- foreach (IEntityType derived in entity.GetDerivedTypes())
- {
- found = derived.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name))
- ?? (INavigationBase?)derived.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name));
-
- if (found is not null)
- {
- return found;
- }
- }
-
- return null;
- }
-
- /// The complex property a type or any type derived from it declares under a name, or null.
- private static object? ComplexMember(IEntityType entity, string name)
- {
- if (Items(entity, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } found)
- {
- return found;
- }
-
- foreach (IEntityType derived in entity.GetDerivedTypes())
- {
- if (Items(derived, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } beneath)
- {
- return beneath;
- }
- }
+ /// The navigation the queried type maps under a name, or null.
+ private static INavigationBase? MappedNavigation(IEntityType entity, string name) =>
+ entity.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name))
+ ?? (INavigationBase?)entity.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name));
- return null;
- }
+ /// The complex property the queried type declares under a name, or null.
+ private static object? ComplexMember(IEntityType entity, string name) =>
+ Items(entity, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name));
/// Compares two model names the way a caller's spelling is compared everywhere else.
private static bool Same(string left, string right) =>
From fb2658c32cf3215cb21b6ebc864c616be349bad4 Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 12:55:27 +0300
Subject: [PATCH 06/54] fix(policies): only EF Core's rows are held to what EF
Core can compute
A projection the library can read into was refused for a member the initializer
does not build, whatever provider ran it. That is EF Core's answer to give: a
provider of its own may evaluate the getter in memory, as rows in memory do, and
refusing there turns a query that worked into a refusal.
A projected shape now records whether it was read through EF Core's provider,
and answers "cannot say" otherwise. An entity shape is unaffected: it has a
model, which is EF Core's by definition.
Co-Authored-By: Claude Opus 5
---
.../Policies/UnexpressiblePathTests.cs | 52 +++++++++++++++++++
DynamicWhere.ex/Policies/Source/RowShape.cs | 22 ++++++--
2 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
index b3b5059..f1686f6 100644
--- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
+++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
@@ -1,3 +1,4 @@
+using System.Linq.Expressions;
using DynamicWhere.ex.Classes.Complex;
using DynamicWhere.ex.Classes.Core;
using DynamicWhere.ex.Classes.Result;
@@ -95,6 +96,37 @@ protected override void OnModelCreating(ModelBuilder model)
}
}
+ /// A provider of its own: not EF Core's, and not the one rows in memory carry.
+ public sealed class ZyOwnProvider : IQueryable, IQueryProvider
+ {
+ private readonly IQueryable _inner;
+
+ public ZyOwnProvider(IQueryable inner)
+ {
+ _inner = inner;
+ Expression = inner.Expression;
+ }
+
+ public Type ElementType => typeof(T);
+
+ public Expression Expression { get; }
+
+ public IQueryProvider Provider => this;
+
+ public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression);
+
+ public IQueryable CreateQuery(Expression expression) =>
+ _inner.Provider.CreateQuery(expression);
+
+ public object? Execute(Expression expression) => _inner.Provider.Execute(expression);
+
+ public TResult Execute(Expression expression) => _inner.Provider.Execute(expression);
+ }
+
///
/// A path beneath a member the policy has no fragment for, whose leaf no database can compute.
/// Until this, the package accepted the path and the provider threw, which is a five-hundred
@@ -333,6 +365,26 @@ public void A_member_the_projection_never_assigns_fails_unguarded_and_is_refused
Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode);
}
+ [Fact]
+ public void A_provider_that_is_not_EF_Core_s_decides_for_itself()
+ {
+ // Its rules are its own: it may evaluate the getter in memory, as rows in memory do, so
+ // nothing here is refused on EF Core's behalf.
+ ZyRole[] rows = { new() { Id = 1, Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } } };
+
+ IQueryable built = new ZyOwnProvider(rows.AsQueryable().Select(role => new ZyRoleRow
+ {
+ Id = role.Id,
+ Code = role.Code,
+ Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En }
+ }));
+
+ FilterResult result = Guard(built, DwTier.Strict)
+ .ToList(Where("Name.IsEmpty", "false", DataType.Boolean));
+
+ Assert.Single(result.Data);
+ }
+
[Fact]
public void Rows_in_memory_run_the_getter_as_they_always_did()
{
diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs
index 6f4fb9e..b23ff3d 100644
--- a/DynamicWhere.ex/Policies/Source/RowShape.cs
+++ b/DynamicWhere.ex/Policies/Source/RowShape.cs
@@ -71,6 +71,10 @@ internal sealed class RowShape
// A member a projection copies from the query's own entity, by the member's path from the row:
// Name = x.Name records ("Name", the entity, "Name"), so what is beneath it is read from the model.
private readonly Dictionary? _copied;
+
+ // True when the projection reads through EF Core. What a provider can compute is EF Core's
+ // answer to give; another provider's rules are its own, so its rows are left alone.
+ private readonly bool _translated;
private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _narrowable = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _kept = new(StringComparer.OrdinalIgnoreCase);
@@ -88,12 +92,14 @@ private RowShape(
Type? built,
IDictionary members,
Dictionary>? initialized = null,
- Dictionary? copied = null)
+ Dictionary? copied = null,
+ bool translated = false)
: this(kind)
{
_assigned = assigned;
_initialized = initialized;
_copied = copied;
+ _translated = translated;
_narrowable.UnionWith(narrowable);
Built = built;
@@ -324,6 +330,13 @@ internal bool Materializes(string path)
///
private bool? ExpressesInProjection(string[] segments)
{
+ if (!_translated)
+ {
+ // Another provider decides what it can compute, and its rules are not EF Core's. A
+ // provider that evaluates in memory runs the getter, as rows in memory do.
+ return null;
+ }
+
if (_assigned is not null && !_assigned.Contains(segments[0]))
{
// Nothing assigns it, so the projection carries no value for it at all.
@@ -742,7 +755,9 @@ private static RowShape Projected(IQueryable source) where T : class
{
// A constructor with arguments says nothing about which member each argument sets: every
// member counts as assigned, and none can be narrowed.
- return new RowShape(RowKind.Projected, null, Array.Empty(), body.Type, built);
+ return new RowShape(
+ RowKind.Projected, null, Array.Empty(), body.Type, built,
+ translated: source.Provider is IAsyncQueryProvider);
}
List narrowable = new();
@@ -782,7 +797,8 @@ private static RowShape Projected(IQueryable source) where T : class
}
}
- return new RowShape(RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied);
+ return new RowShape(
+ RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied, efCore);
}
///
From 45f1392a6f3a8866c4e8d526da73ec83eccc27da Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 12:59:10 +0300
Subject: [PATCH 07/54] fix(policies): a second call adding a source is
refused, the catalogue is compared by every name, and a refused segment keeps
its trace
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
What the security review of the branch found.
High: handing the posture in force back with a policy source beside it was read
as "the same posture" and did nothing. The instance carries the same values by
definition and says nothing about the sources, so the shortcut skipped the only
comparison that mattered: the resolver was never rebuilt, the source was never
consulted, and every rule in it — a denial included — quietly did not apply.
DwPolicy.Options is what AddDwPolicies registers, so the call is one a module
adding its own store would write. The shortcut compares the sources now.
Low: the catalogue was compared by what Entities reports, which is the last name
each type was exposed under, while every earlier name stays resolvable. Two
catalogues reporting the same pairs could answer differently to an
administrative request. DwEntityCatalog.SameAs compares both name maps.
Low: the segment terminal still assigned LastTrace after sanitizing, so a
refused segment left the previous request's trace on the handle — and a strict
refusal names no field, which makes the trace the only record of which field it
was.
Ruled out by the same review, with probes: the refusal is indistinguishable from
an unknown name and from a denial in every clause, caps and cost budget
included; Expresses can only add a refusal, never skip a check; the trace record
reaches no caller; forced predicates never pass through the name resolver; and
public Clone gives no TOCTOU, since sanitizing clones before it reads anything.
Co-Authored-By: Claude Opus 5
---
.../Policies/ConfigureOnceTests.cs | 54 +++++++++++++++++++
.../Policies/UnexpressiblePathTests.cs | 47 ++++++++++++++++
DynamicWhere.ex/Policies/Config/DwPolicy.cs | 33 +++++-------
.../Policies/Discovery/DwEntityCatalog.cs | 46 ++++++++++++++++
.../Policies/Source/PolicyQueryable.cs | 6 ++-
5 files changed, 164 insertions(+), 22 deletions(-)
diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
index 5056c8e..7d6f424 100644
--- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
+++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
@@ -280,6 +280,60 @@ public void A_second_registration_hands_the_container_the_posture_in_force()
Assert.Same(DwPolicy.Options, provider.GetRequiredService());
}
+ [Fact]
+ public void The_posture_in_force_handed_back_with_a_source_beside_it_is_refused()
+ {
+ // The instance carries the same values by definition and says nothing about the sources.
+ // Answering "same posture" here would drop the source: the resolver is never rebuilt, the
+ // source is never consulted, and every rule in it quietly does not apply.
+ PolicyResolver before = DwPolicy.Resolver;
+
+ InvalidOperationException refusal = Assert.Throws(
+ () => DwPolicy.Configure(DwPolicy.Options, new FakePolicyProvider()));
+
+ Assert.Contains("already configured", refusal.Message);
+ Assert.Same(before, DwPolicy.Resolver);
+ }
+
+ [Fact]
+ public void The_posture_in_force_handed_back_with_nothing_beside_it_is_the_same_posture()
+ {
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ DwPolicy.Configure(inForce);
+
+ Assert.Same(inForce, DwPolicy.Options);
+ }
+
+ [Fact]
+ public void A_catalogue_answering_to_one_more_name_for_the_same_type_is_refused()
+ {
+ // Entities reports the last name a type was exposed under; both stay resolvable. Two
+ // catalogues reporting the same pairs can still answer differently to an admin request.
+ DwPolicyOptions different = Copy();
+
+ if (DwPolicy.Options.Entities.IsEmpty)
+ {
+ different.Entities.Expose("only-here");
+
+ Refused(different);
+
+ return;
+ }
+
+ KeyValuePair first = DwPolicy.Options.Entities.Entities.First();
+
+ // Exposed again under an extra name, then back under the one in force, so Entities
+ // matches pair for pair and only the name map differs.
+ different.Entities.Expose(first.Key, $"{first.Value}-also");
+ different.Entities.Expose(first.Key, first.Value);
+
+ Assert.Equal(DwPolicy.Options.Entities.Entities.Count, different.Entities.Entities.Count);
+ Assert.Equal(first.Value, different.Entities.NameOf(first.Key));
+
+ Refused(different);
+ }
+
///
/// Every settable value on the posture and on the caps decides whether a second call is the
/// same posture, and the two that do not are named here on purpose.
diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
index f1686f6..c90b666 100644
--- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
+++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
@@ -329,6 +329,53 @@ public void A_clause_composed_on_the_handle_is_refused_too()
Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode);
}
+ [Fact]
+ public async Task A_segment_naming_it_is_refused_and_leaves_its_own_trace()
+ {
+ PolicyQueryable guarded = Guard(Built(), DwTier.Strict);
+
+ // A first request, so a trace left over from it would be visible if the refusal skipped
+ // the assignment.
+ await guarded.ToListAsync(new Segment
+ {
+ ConditionSets =
+ {
+ new ConditionSet
+ {
+ Sort = 1,
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "admin" } } }
+ }
+ }
+ }
+ });
+
+ PolicyTrace? first = guarded.LastTrace;
+
+ PolicyException refusal = await Assert.ThrowsAnyAsync(
+ () => guarded.ToListAsync(new Segment
+ {
+ ConditionSets =
+ {
+ new ConditionSet
+ {
+ Sort = 1,
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } }
+ }
+ }
+ }
+ }));
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode);
+ Assert.NotSame(first, guarded.LastTrace);
+ Assert.Contains(
+ guarded.LastTrace!.Decisions,
+ decision => decision.Reason is not null && decision.Reason.Contains("cannot compute"));
+ }
+
[Fact]
public void A_summary_grouping_on_it_is_refused()
{
diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs
index 58ca3ad..4260252 100644
--- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs
+++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs
@@ -146,7 +146,12 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked,
{
if (ReferenceEquals(inForce, asked))
{
- return true;
+ // The same instance carries the same values by definition, and says nothing about the
+ // sources. Handing the posture in force back with a store provider beside it is a call
+ // asking for that source, and answering "same posture" would drop it: the resolver is
+ // never rebuilt, the source is never consulted, and every rule in it — a denial
+ // included — quietly does not apply.
+ return SameProviders(providers);
}
if (inForce.Tier != asked.Tier
@@ -189,25 +194,13 @@ private static bool SameCaps(DwCaps inForce, DwCaps asked) =>
&& inForce.SchemaCycleLimit == asked.SchemaCycleLimit
&& inForce.MaxSchemaFields == asked.MaxSchemaFields;
- /// True when both catalogues expose the same types under the same names.
- private static bool SameCatalog(DwEntityCatalog inForce, DwEntityCatalog asked)
- {
- if (inForce.Entities.Count != asked.Entities.Count)
- {
- return false;
- }
-
- foreach (KeyValuePair exposed in inForce.Entities)
- {
- if (!asked.Entities.TryGetValue(exposed.Key, out string? name)
- || !string.Equals(exposed.Value, name, StringComparison.Ordinal))
- {
- return false;
- }
- }
-
- return true;
- }
+ /// True when both catalogues expose the same types and answer to the same names.
+ ///
+ /// Every name, not only the last one each type was exposed under. A type exposed twice keeps
+ /// both names resolvable while Entities reports one, so two catalogues can report the
+ /// same pairs and still answer differently to an administrative request.
+ ///
+ private static bool SameCatalog(DwEntityCatalog inForce, DwEntityCatalog asked) => inForce.SameAs(asked);
///
/// True when the second call supplies the same kinds of policy source, in the same order.
diff --git a/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs b/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs
index 1f68a02..1d88496 100644
--- a/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs
+++ b/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs
@@ -153,4 +153,50 @@ public Type[] ToArray()
/// Prevents any further change.
public void Freeze() => _frozen = true;
+
+ /// True when another catalogue exposes the same types and answers to the same names.
+ ///
+ /// Read when a second call asks whether it is configuring the posture already in force. Every
+ /// name matters, not only the last one each type was exposed under: a type exposed twice answers
+ /// to both, while reports one, so comparing that alone would let a second
+ /// host's administrative surface resolve a name this one never declared — or fail to resolve one
+ /// it did.
+ ///
+ internal bool SameAs(DwEntityCatalog other)
+ {
+ if (other is null
+ || _names.Count != other._names.Count
+ || _byName.Count != other._byName.Count
+ || _byFullName.Count != other._byFullName.Count)
+ {
+ return false;
+ }
+
+ foreach (KeyValuePair exposed in _names)
+ {
+ if (!other._names.TryGetValue(exposed.Key, out string? name)
+ || !string.Equals(exposed.Value, name, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ foreach (KeyValuePair named in _byName)
+ {
+ if (!other._byName.TryGetValue(named.Key, out Type? type) || type != named.Value)
+ {
+ return false;
+ }
+ }
+
+ foreach (KeyValuePair named in _byFullName)
+ {
+ if (!other._byFullName.TryGetValue(named.Key, out Type? type) || type != named.Value)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
index fef2b5a..2a314d9 100644
--- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
+++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs
@@ -414,13 +414,15 @@ public async Task> ToListAsync(Segment segment, CancellationTok
{
PolicyTrace trace = NewTrace();
+ // Before the sanitizing, as every other terminal does it, so a refused segment leaves
+ // its own trace readable rather than the request before it.
+ LastTrace = trace;
+
Segment sanitized = FilterSanitizer.Sanitize(
segment, _resolver, _context, _options, trace,
applyDefaultOrder: TakesDefaultOrder,
rows: RowShape.Of(_source));
- LastTrace = trace;
-
using (PolicyScope.Enter(_context, LastTrace))
{
SegmentResult result = await Guarded().ToListAsync(sanitized, cancellationToken);
From 652f3b15afb9f7a0627aea296d3ed4461dcc8def Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 13:07:43 +0300
Subject: [PATCH 08/54] fix(policies): a projection is not held to what the
database can compute, and a cap's own default is not a difference
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
What the over-blocking review of the branch found.
A projection is the last thing the provider builds, and EF Core evaluates that
one on the client when it cannot translate it. So Selects naming an unmapped
getter — on the entity, across a navigation, on an owned or JSON member —
returned its value on 3.2.0 and was refused here, in both tiers of the strict
path and in a Segment. Five shapes, on both EF legs. The refusal now applies to
the clauses the database has to compute: a filter, an order, a grouping key, an
aggregated field, and each of those inside a Segment.
The posture comparison read IsMinGroupSizeSet, which nothing in enforcement
reads. The documented appsettings sample writes "MinGroupSize": 5, so a host
binding it and a host on the defaults enforce the same floor and were refused —
the second host this feature exists to let start. The floor that applies is
compared now, not whether somebody wrote it down.
Co-Authored-By: Claude Opus 5
---
.../Policies/ConfigureOnceTests.cs | 20 ++++++++++
.../Policies/UnexpressiblePathTests.cs | 40 +++++++++++++++----
DynamicWhere.ex/Policies/Config/DwPolicy.cs | 12 +++++-
.../Policies/Source/FilterSanitizer.cs | 26 +++++++++---
4 files changed, 83 insertions(+), 15 deletions(-)
diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
index 7d6f424..63d3efa 100644
--- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
+++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs
@@ -127,6 +127,26 @@ public void A_different_cap_is_refused()
Refused(different);
}
+ [Fact]
+ public void Writing_the_group_floor_the_default_already_holds_is_the_same_posture()
+ {
+ // The floor that applies, not whether somebody wrote it down. The documented appsettings
+ // sample writes "MinGroupSize": 5, so a host binding it and a host on the defaults
+ // enforce the same floor — and refusing the second is refusing the case this is for.
+ DwPolicyOptions different = Copy();
+
+ different.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize;
+
+ Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, different.Caps.IsMinGroupSizeSet);
+ Assert.Equal(DwPolicy.Options.Caps.MinGroupSize, different.Caps.MinGroupSize);
+
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ DwPolicy.Configure(different);
+
+ Assert.Same(inForce, DwPolicy.Options);
+ }
+
[Fact]
public void A_different_group_floor_is_refused()
{
diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
index c90b666..1962a7d 100644
--- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
+++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs
@@ -298,15 +298,41 @@ public void An_order_on_it_is_refused()
}
[Fact]
- public void A_projection_naming_it_is_refused()
+ public void A_projection_naming_it_still_returns_it()
{
- PolicyException refusal = Assert.ThrowsAny(
- () => Guard(Built(), DwTier.Strict).ToList(new Filter
- {
- Selects = new List { "Id", "Name.IsEmpty" }
- }));
+ // A projection is the last thing the provider builds, and EF Core evaluates that one on
+ // the client when it cannot translate it. Unguarded this returns rows, so refusing it
+ // would take back a projection that has always worked.
+ List unguarded = Built().Select(row => new ZyRoleRow
+ {
+ Id = row.Id,
+ Name = new ZyLocalizedText { Ar = row.Name.Ar, En = row.Name.En }
+ }).ToList();
- Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode);
+ Assert.Single(unguarded);
+
+ FilterResult result = Guard(Built(), DwTier.Strict).ToList(new Filter
+ {
+ Selects = new List { "Id", "Name.IsEmpty" }
+ });
+
+ Assert.Single(result.Data);
+ }
+
+ [Fact]
+ public void An_unmapped_getter_on_the_entity_can_still_be_selected()
+ {
+ Assert.Single(_db.Roles.Select(role => new { role.Id, role.Display }).ToList());
+
+ FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(new Filter
+ {
+ Selects = new List { "Id", "Display" }
+ });
+
+ // The typed projection reads the members it was asked for, so the getter computes from
+ // those: Code was not selected, which is the core's own behaviour and not the policy's.
+ Assert.Single(result.Data);
+ Assert.Equal(1, result.Data[0].Id);
}
[Fact]
diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs
index 4260252..d5c4dae 100644
--- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs
+++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs
@@ -174,7 +174,16 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked,
return SameProviders(providers);
}
- /// True when two cap sets hold the same numbers, the group floor's opt-out included.
+ ///
+ /// True when two cap sets hold the same numbers.
+ ///
+ ///
+ /// The floor that applies, not whether somebody wrote it down. IsMinGroupSizeSet tells a
+ /// deliberate opt-out from a deployment that never heard of the control, and nothing in
+ /// enforcement reads it — a host binding the documented appsettings sample, which writes
+ /// "MinGroupSize": 5, enforces exactly what a host on the defaults does, and refusing the
+ /// second one would refuse the case this feature exists for.
+ ///
private static bool SameCaps(DwCaps inForce, DwCaps asked) =>
inForce.MaxPageSize == asked.MaxPageSize
&& inForce.DefaultPageSize == asked.DefaultPageSize
@@ -189,7 +198,6 @@ private static bool SameCaps(DwCaps inForce, DwCaps asked) =>
&& inForce.DefaultFieldCost == asked.DefaultFieldCost
&& inForce.MaxAuditEvents == asked.MaxAuditEvents
&& inForce.MinGroupSize == asked.MinGroupSize
- && inForce.IsMinGroupSizeSet == asked.IsMinGroupSizeSet
&& inForce.SchemaDepth == asked.SchemaDepth
&& inForce.SchemaCycleLimit == asked.SchemaCycleLimit
&& inForce.MaxSchemaFields == asked.MaxSchemaFields;
diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs
index ddc745a..2e25feb 100644
--- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs
+++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs
@@ -694,7 +694,7 @@ internal static Segment Sanitize(
{
for (int i = 0; i < working.Selects.Count; i++)
{
- working.Selects[i] = ResolveName(working.Selects[i], gate);
+ working.Selects[i] = ResolveName(working.Selects[i], gate, computed: false);
}
}
@@ -2371,17 +2371,22 @@ private static bool Widens(ForcedPredicate predicate, string path) where T :
/// The entity type being queried.
/// The name as the caller wrote it.
/// The per-query state, carrying the type's alias map.
+ ///
+ /// True when the clause needs the provider to compute the path — a filter, an order, a grouping
+ /// key, an aggregated field. False for a projection, which the provider evaluates on the client
+ /// when it cannot translate it, so a member no database can compute is still returned there.
+ ///
/// The canonical field path.
/// Thrown when the name could mean more than one field.
/// Thrown when the name names nothing, outside the strict tier.
- private static string ResolveName(string name, Gate gate) where T : class
+ private static string ResolveName(string name, Gate gate, bool computed = true) where T : class
{
// A type nobody has aliased takes the path it always did, with no extra reflection and no
// behavioural difference from before this existed.
if (gate.TypePolicy.Aliases.Count == 0)
{
return gate.HidesExistence
- ? Expressible(TryValidate(name), name, gate) ?? gate.Unknown(name)
+ ? Expressible(TryValidate(name), name, gate, computed) ?? gate.Unknown(name)
: name.Validate();
}
@@ -2438,7 +2443,7 @@ private static string ResolveName(string name, Gate gate) where T : class
string canonical = candidates[0];
- if (gate.HidesExistence && Expressible(canonical, spoken, gate) is null)
+ if (gate.HidesExistence && Expressible(canonical, spoken, gate, computed) is null)
{
return gate.Unknown(spoken);
}
@@ -2468,13 +2473,22 @@ private static string ResolveName(string name, Gate gate) where T : class
/// they may not use.
///
///
- private static string? Expressible(string? canonical, string spoken, Gate gate)
+ private static string? Expressible(string? canonical, string spoken, Gate gate, bool computed)
{
if (canonical is null)
{
return null;
}
+ if (!computed)
+ {
+ // A projection is the last thing the provider builds, and EF Core evaluates that one on
+ // the client when it cannot translate it. So a member no database can compute is still
+ // a member a caller can select, and refusing it here would take back a projection that
+ // has always worked.
+ return canonical;
+ }
+
if (gate.Rows.Expresses(canonical) == false)
{
gate.RecordUnexpressible(canonical, spoken);
@@ -2580,7 +2594,7 @@ private static void Canonicalize(Filter filter, Gate gate) where T : class
{
for (int i = 0; i < filter.Selects.Count; i++)
{
- filter.Selects[i] = ResolveName(filter.Selects[i], gate);
+ filter.Selects[i] = ResolveName(filter.Selects[i], gate, computed: false);
}
}
From ae60b4517ee8c0269fe5bef39ec4519293912548 Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 13:07:43 +0300
Subject: [PATCH 09/54] docs: what the review found in the text, and what the
fixes changed under it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From the docs-against-code review:
- A constructor with arguments does not leave a projected query in its own
order; only a projection with no initializer does. A Filter carrying Selects
is ordered like any other filter — it is a composed Select that leaves the
rest of the chain unordered.
- Count was listed as a framework member that runs. No path can name it: the
library's own validation refuses Lines.Count before any of this is asked.
- Three places still said a second Configure is refused, on pages that now
describe the opposite.
- LastTrace's sub-bullet still said the trace holds the last call that got past
sanitizing.
- AddDwPolicies does not "do nothing": it binds, changes no posture, and
registers the one in force. Its exception list said "and" for two alternative
causes.
- The [DbFunction] example cannot be reached by a property path. The rule behind
it is the model's, and that is what the text says now.
From the fixes in this branch: Selects is not refused, a column only a subtype
maps is refused through the base type, another provider's rows decide for
themselves, and the posture comparison reads the floor that applies rather than
whether it was written down.
Co-Authored-By: Claude Opus 5
---
DynamicWhere.ex/DOC.md | 22 ++++++---
DynamicWhere.ex/DynamicWhere.ex.csproj | 4 +-
.../Policies/Attributes/DwEntityAttribute.cs | 11 +++--
.../Policies/Config/DwPolicyConfiguration.cs | 2 +-
DynamicWhere.ex/Policies/Source/RowShape.cs | 2 +-
.../app/docs/breaking-changes/page.tsx | 33 +++++++++----
.../app/docs/policies/configuration/page.tsx | 19 ++++---
OfficialWebsite/app/docs/policies/page.tsx | 5 +-
.../app/docs/policies/security/page.tsx | 8 +--
OfficialWebsite/public/llms.txt | 49 +++++++++++++------
README.md | 4 +-
11 files changed, 103 insertions(+), 56 deletions(-)
diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md
index 892f3a1..b85c61e 100644
--- a/DynamicWhere.ex/DOC.md
+++ b/DynamicWhere.ex/DOC.md
@@ -1529,7 +1529,8 @@ caller request → ApplyPolicy(ctx) → sanitize → existing engine → transfo
```
```csharp
-// Once, at startup. Refused on a second call: the tier is read by every request thread.
+// At startup. A second call asking for the same posture does nothing; a different one is
+// refused, because the tier is read by every request thread. See "Configuring twice".
DwPolicy.Configure(new DwPolicyOptions
{
Tier = DwTier.Convenience,
@@ -1689,7 +1690,9 @@ public sealed class LocalizedText
}
```
-gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause.
+gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause the database has to compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`.
+
+**`Selects` is not one of them.** A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so `Selects = ["Id", "Name.IsEmpty"]` returns the computed value exactly as it did before. Refusing it would take back a projection that has always worked.
It is refused only where the whole set of members a container can produce is known:
@@ -1700,12 +1703,14 @@ It is refused only where the whole set of members a container can produce is kno
| …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused |
| Rows in memory | nothing — the getter runs | Runs, as it always did |
| Anything beneath a column, a converted one included | nothing — the converter decides | Runs, as it always did |
-| A framework member such as `Length`, `Year`, `Count`, `HasValue` | nothing — the provider translates it | Runs, as it always did |
+| A framework member such as `Length`, `Year` or `HasValue` | nothing — the provider translates it | Runs, as it always did |
| A source the library cannot read | nothing | Runs, as it always did |
+| A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused |
+| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Runs, as it always did |
An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null.
-A member a custom EF Core translator computes, through `[DbFunction]` or an `IMethodCallTranslatorPlugin`, is not in the model and is refused with the rest. Map it, or filter on the columns beneath it.
+The rule is the model's: a member it maps nowhere is one the database cannot compute. A member some provider extension computes without a mapping is refused with the rest, so map it, or filter on the columns beneath it.
### A navigation named in Selects
@@ -2073,9 +2078,10 @@ and nothing here can tell the difference.
#### Configuring twice
-*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure`, or a second
-`AddDwPolicies`, **asking for the posture already in force does nothing and returns**; one asking for
-a different posture still throws `InvalidOperationException`. The comparison happens inside the lock
+*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure` **asking for the posture
+already in force does nothing and returns**; one asking for a different posture still throws
+`InvalidOperationException`. A second `AddDwPolicies` binds and builds its options as ever, changes
+no posture, and registers the one in force. The comparison happens inside the lock
that does the configuring, so a caller needs no lock and no `IsConfigured` check of its own — which
matters because that check is a check-then-act two hosts starting at once can both pass.
@@ -2089,7 +2095,7 @@ What counts as the same posture:
|---|---|
| `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals` | `TokenVault` |
| `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval` | `Services` |
-| Every value on `Caps`, the group floor's opt-out included | The provider *instances* |
+| Every value on `Caps`, the floor that applies rather than whether it was written down | The provider *instances* |
| The exposed entity catalogue: the same types under the same names | |
| The provider *types*, in the order they were supplied | |
diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj
index 24bef93..78fbe1b 100644
--- a/DynamicWhere.ex/DynamicWhere.ex.csproj
+++ b/DynamicWhere.ex/DynamicWhere.ex.csproj
@@ -50,9 +50,9 @@
README.mdv3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, 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 including the group floor's opt-out, the exposed entity catalogue, and the kinds of policy source supplied, in order. 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.
+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, projection, group or aggregate naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. 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: an entity type and the types derived from it, an owned or complex type, and an initializer in a projection the caller composed 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 Count is left alone, a value beneath a converted column is left alone, 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. 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.
+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.
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.
diff --git a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs
index 2980e07..f21d959 100644
--- a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs
+++ b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs
@@ -39,10 +39,13 @@ public sealed class DwEntityAttribute : Attribute
/// initializer and assigns every field the default names a column — a member the model maps, read
/// directly, through reference navigations or through EF.Property — at every level of a
/// nested path. That is the shape a caller writes who projects a row type before guarding it. A
- /// constructor with arguments, a field the initializer leaves unassigned, and a field the projection
- /// computes each leave the query in its own order, because a default must never be the reason a
- /// query that ran unguarded fails. A Select composed on the guarded handle, or a
- /// Filter carrying Selects, stays unordered. An in-memory sequence sorted before
+ /// projection with no initializer at all, new TicketRow(t.Id, t.Code), a field the
+ /// initializer leaves unassigned, and a field the projection computes each leave the query in its
+ /// own order, because a default must never be the reason a query that ran unguarded fails. A
+ /// constructor with arguments and an initializer still takes the default, since the
+ /// initializer is what says which member holds which column. A Select composed on the
+ /// guarded handle leaves the rest of the chain unordered; a Filter carrying
+ /// Selects is ordered as any other filter is. An in-memory sequence sorted before
/// ApplyPolicy is not recognised as ordered, because it reaches the policy as a query with no
/// OrderBy in it, so send its order with the filter. End the default with a unique field, such
/// as the key, or rows sharing the leading values can still change places between pages.
diff --git a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs
index 7c8568a..6a07215 100644
--- a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs
+++ b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs
@@ -104,7 +104,7 @@ public static DwPolicyOptions Bind(this DwPolicyOptions options, IConfiguration
/// The same collection, for chaining.
/// Thrown when the collection or the section is null.
///
- /// Thrown when the policy layer is already configured with a different posture, and when
+ /// Thrown when the policy layer is already configured with a different posture, or when
/// the section names a key nothing answers to.
///
///
diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs
index b23ff3d..4f173ef 100644
--- a/DynamicWhere.ex/Policies/Source/RowShape.cs
+++ b/DynamicWhere.ex/Policies/Source/RowShape.cs
@@ -292,7 +292,7 @@ internal bool Materializes(string path)
/// produce: an entity type and its derived types, a complex or owned type, or an initializer in
/// the projection itself. Anywhere else the answer is null and the path is left alone — beneath a
/// column, where a converter decides; on a framework type, where Length, Year and
- /// Count are translated by the provider and are not members of any model; on rows in
+ /// HasValue are translated by the provider and are not members of any model; on rows in
/// memory, which run the getter; and on a source this library cannot read.
///
///
diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx
index bb3bb5b..a4403c7 100644
--- a/OfficialWebsite/app/docs/breaking-changes/page.tsx
+++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx
@@ -1473,9 +1473,18 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
Since 3.3.0 such a path is refused as an unknown name
is: the clause's own code, FieldPath{" "}
- "*", in a filter, an order, a projection, a group
- and an aggregate alike. It is refused only where the whole set of
- members a container can produce is known.
+ "*", in every clause the database has to compute
+ — a filter, an order, a grouping key, an aggregated field, and each of
+ those inside a Segment. It is refused only where the whole
+ set of members a container can produce is known.
+
+
+ Selects is not one of them. A projection
+ is the last thing the provider builds, and EF Core evaluates that one on
+ the client when it cannot translate it, so{" "}
+ Selects = ["Id", "Name.IsEmpty"]{" "}
+ returns the computed value exactly as it did before. Refusing it would
+ take back a projection that has always worked.
@@ -1487,8 +1496,10 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
…where the Select copies the member, Name = role.Name
the model, beneath the member it copies
Refused
Rows in memory
nothing — the getter runs
Runs, as before
Anything beneath a column, converted or not
nothing — the converter decides
Runs, as before
-
A framework member: Length, Year, Count
nothing — the provider translates it
Runs, as before
+
A framework member: Length, Year, HasValue
nothing — the provider translates it
Runs, as before
A source the library cannot read
nothing
Runs, as before
+
A column only a subtype maps, queried through the base
the queried type's model, which is what EF Core translates against
Refused
+
A projection a provider that is not EF Core's ran
nothing — its rules are its own
Runs, as before
@@ -1498,10 +1509,11 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
-
- A member EF Core computes through [DbFunction] or an{" "}
- IMethodCallTranslatorPlugin is not in the model, so the
- strict tier refuses it. Map it, or filter on the columns beneath it.
+
+ A member the model maps nowhere is one the database cannot compute, so
+ the strict tier refuses it wherever the database has to. A member some
+ provider extension computes without a mapping is refused with the rest:
+ map it, or filter on the columns beneath it.
31. LastTrace Is Set Before a Request Is Sanitized
@@ -1536,8 +1548,9 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}IncludeTraceInResult, AuditRefusals,{" "}
HashSalt, StoreFailure,{" "}
MaxSnapshotAge, RefreshInterval, every cap
- including the group floor's opt-out, the exposed entity catalogue,
- and the provider types in the order supplied. Not compared, and not
+ value, the exposed entity catalogue with every name it answers to, and
+ the provider types in the order supplied. Writing a cap's own
+ default down is not a difference. Not compared, and not
replaced: TokenVault, Services and the
provider instances — a second host builds its own, and no two are ever
the same reference.
diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx
index 7419252..587afd9 100644
--- a/OfficialWebsite/app/docs/policies/configuration/page.tsx
+++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx
@@ -28,11 +28,14 @@ export default function Page() {
MaxSnapshotAge = TimeSpan.FromMinutes(15),
RefreshInterval = TimeSpan.FromSeconds(30),
}, providers);`}
-
+
The posture is read by every request thread without synchronization. A
tier that can change while requests are in flight is one that can be
relaxed by a code path nobody expected to be security-relevant, so
- mutation after Configure throws.
+ mutation after Configure throws, and so does a second call
+ asking for a different posture. Since 3.3.0 a second call
+ asking for the one already in force is a no-op — see{" "}
+ Configuring twice.
AttributePolicyProvider is added whether or not you pass it.
@@ -907,10 +910,12 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
Configuring twice
The first call decides the posture. Since 3.3.0 a
- second DwPolicy.Configure, or a second{" "}
- AddDwPolicies, asking for the posture already in
- force does nothing and returns; one asking for a different
- posture still throws InvalidOperationException. The
+ second DwPolicy.Configureasking for the posture
+ already in force does nothing and returns; one asking for a
+ different posture still throws{" "}
+ InvalidOperationException. A second{" "}
+ AddDwPolicies binds and builds its options as ever, changes
+ no posture, and registers the one in force. The
comparison happens inside the lock that does the configuring, so a
caller needs no lock and no IsConfigured check of its own —
which matters, because that check is a check-then-act two hosts starting
@@ -935,7 +940,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
Services
-
Every value on Caps, the group floor's opt-out included
+
Every value on Caps — the floor that applies, not whether it was written down
The provider instances
diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx
index eb6b86e..a73e877 100644
--- a/OfficialWebsite/app/docs/policies/page.tsx
+++ b/OfficialWebsite/app/docs/policies/page.tsx
@@ -57,8 +57,9 @@ PolicyTrace what the policy did: always on LastTrace, and on
(off under the strict tier by default)`}
The whole thing in one screen
- {`// Once, at startup. A second call is refused: the tier is read by every
-// request thread, and a posture that can change mid-flight can be relaxed.
+ {`// At startup. A second call asking for the same posture does nothing; a
+// different one is refused, because the tier is read by every request thread
+// and a posture that can change mid-flight can be relaxed.
DwPolicy.Configure(new DwPolicyOptions
{
Tier = DwTier.Convenience,
diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx
index b43bce7..8b8871f 100644
--- a/OfficialWebsite/app/docs/policies/security/page.tsx
+++ b/OfficialWebsite/app/docs/policies/security/page.tsx
@@ -198,9 +198,11 @@ true order. Add [DwNoOrder] unless that is intended.`}
Such a path is now refused as an unknown name is: the clause's own
code, FieldPath"*", so it cannot be
- told from a misspelling or from a field the caller may not use. It is
- refused only where the whole set of members a container can produce is
- known — an entity's own EF Core model, and the initializers of a
+ told from a misspelling or from a field the caller may not use. It
+ applies to every clause the database has to compute, and not to{" "}
+ Selects, which EF Core evaluates on the client when it
+ cannot translate it. It is refused only where the whole set of members a
+ container can produce is known — an entity's own EF Core model, and the initializers of a
projection composed before ApplyPolicy, including a member
that projection copies from the entity. Rows in memory, a framework
member the provider translates such as Length or{" "}
diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt
index d09fe56..1ba39a2 100644
--- a/OfficialWebsite/public/llms.txt
+++ b/OfficialWebsite/public/llms.txt
@@ -1462,9 +1462,10 @@ DwPolicy stat
hosts starting at once can both pass. This is what lets an integration suite start several
`WebApplicationFactory` hosts over one composition root.
- Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`,
- `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value with `IsMinGroupSizeSet`, the exposed catalogue
- (same types under the same names), and the provider **types**, in the order supplied, with
- `AttributePolicyProvider` left out of both sides.
+ `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value, the exposed catalogue — the same types under the same
+ names, and every name each type answers to — and the provider **types**, in the order supplied, with
+ `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not compared: nothing in enforcement
+ reads it, so writing `MinGroupSize = 5` and leaving it unset are the same posture.
- Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds
its own and no two are ever the same reference, so they stay as the first call left them — a second host runs
with the first host's vault, container and rule stores.
@@ -1840,7 +1841,8 @@ Rules:
- `LastTrace` is assigned before the request is sanitized (3.3.0), so a refused request leaves its own trace
readable. It used to be assigned after, so a refusal left the previous request's trace, or null. A strict refusal
names no field, and the trace is where the real path and reason are.
- - It holds the latest call that got past sanitizing, or a `QueryStringDenied` refusal.
+ - It holds the latest call the handle made: one that got past sanitizing, one sanitizing refused, or a
+ `QueryStringDenied` refusal.
- Refusals throw `PolicyException` (a `LogicException`) from the handle method.
- With `DwPolicyOptions.AuditRefusals` on, the terminal and composable methods also write the refusal to the context's audit buffer on its way out, once, without changing or catching it (section 22).
@@ -2312,7 +2314,13 @@ The resolver, fragment and provider types are listed in section 21.
## 17. Enforcement by tier and dry run
Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused
-as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`. What decides:
+as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`, in every clause the database has to
+compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`.
+
+`Selects` is not one of them. A projection is the last thing the provider builds, and EF Core evaluates that one on
+the client when it cannot translate it, so `Selects` naming such a member returns its value exactly as before.
+
+What decides:
```
Source of the rows What the set of producible members is read from Name.IsEmpty
@@ -2322,13 +2330,16 @@ Select(...) before ApplyPolicy building the row that initializer's assignme
... a member it copies whole: Name = role.Name the model, beneath the copied member refused
rows in memory (EnumerableQuery) nothing; the getter runs runs
beneath a column, converted or not nothing; the converter decides runs
-a framework member: Length, Year, Count, HasValue nothing; the provider translates it runs
+a framework member: Length, Year, HasValue nothing; the provider translates it runs
a source the library cannot read into nothing runs
+a column only a subtype maps, through the base the queried type's model, which EF Core
+ translates against refused
+a projection another provider ran nothing; its rules are its own runs
```
- An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason.
- `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded.
-- A member a `[DbFunction]` or an `IMethodCallTranslatorPlugin` computes is not in the model and is refused too.
+- The rule is the model's: a member it maps nowhere is refused, whatever a provider extension could compute.
- The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`.
The count caps are checked first, before any name is resolved (3.1.0). Names are then resolved to canonical paths,
@@ -4833,16 +4844,19 @@ MemoryCalculationInput
force and do nothing. A different posture still throws. The check is inside the lock that configures, so a
caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass.
Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge,
- RefreshInterval, every cap with IsMinGroupSizeSet, the exposed catalogue, and the provider types in order.
+ RefreshInterval, every cap value, the exposed catalogue with every name it answers to, and the provider
+ types in order.
Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with
the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than
the instance it built.
- Behaviour change. Under Strict outside a dry run, a path that exists on the type and names no value the
query can compute — LocalizedText.IsEmpty over two columns, an unmapped getter on the entity — is refused
- as an unknown name is, in every clause, where the provider used to throw and the caller saw a five-hundred.
- Refused only where the producible members are known: an entity's model, and the initializers of a
- projection composed before ApplyPolicy, including a member it copies from the entity. Rows in memory, a
- framework member such as Length or Year, anything beneath a column, Convenience and a dry run are unchanged.
+ as an unknown name is, in every clause the database has to compute, where the provider used to throw and
+ the caller saw a five-hundred. Selects is not one of them: EF Core evaluates the last projection on the
+ client. Refused only where the producible members are known: the queried type's own model, and the
+ initializers of a projection EF Core ran before ApplyPolicy, including a member it copies from the entity.
+ Rows in memory, a framework member such as Length or Year, anything beneath a column, another provider's
+ rows, Convenience and a dry run are unchanged.
- Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own
trace readable instead of the previous request's.
- New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list,
@@ -5095,10 +5109,13 @@ MemoryCalculationInput
`[Column]`. Reached through a navigation (`Owner.New`) it is an ordinary member.
- A declared date format may not write text ISO 8601 or a year-first date already reads; `DwDates.Configure`
refuses one (section 4). ISO 8601 and year-first dates are read on every deployment and cannot be turned off.
-- A member no database can compute — a getter over columns, an unmapped getter on an entity, a member a
- `[DbFunction]` or a translator plugin computes — cannot be filtered, ordered, selected, grouped or aggregated under
- `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. Under
- `Convenience` it still reaches the provider and still throws there.
+- A member no database can compute — a getter over columns, or an unmapped getter on an entity — cannot be
+ filtered, ordered, grouped or aggregated under `Strict` on a source the library can read (3.3.0, section 17). Map
+ it, or name the columns beneath it. A projection may still name it, because EF Core evaluates the last projection
+ on the client. Under `Convenience` every clause still reaches the provider and still throws there.
+- A column the model maps only on a subtype cannot be filtered, ordered, grouped or aggregated through the base type
+ (3.3.0, section 17). EF Core translates a member against the type the query is over, so it fails there too: query
+ the derived type, `Set()`.
- A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store
instances (3.3.0, section 12). They are not compared and not replaced.
- Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`).
diff --git a/README.md b/README.md
index 6de7961..c7f3a9f 100644
--- a/README.md
+++ b/README.md
@@ -379,8 +379,8 @@ The complete reference — every enum, class, extension method, validation rule,
**Upgrade note — two behaviour changes. Read these before bumping.**
-- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — 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. 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. `AddDwPolicies` registers 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 as `LocalizedText.IsEmpty` reads 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. 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 before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it.
+- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — 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 cap's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor. 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. `AddDwPolicies` registers 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 as `LocalizedText.IsEmpty` reads 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. Not in `Selects`: 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 before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it.
- **Changed: `LastTrace` is 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 on `Filter`, `Segment` and `Summary`.** 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.
- **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **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.
From 371a8784289f40e8bddd2a26572c476fffba9f60 Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 13:12:38 +0300
Subject: [PATCH 10/54] test(policies): keep the review's probes, so what was
ruled out stays ruled out
The first round's over-blocking and posture probes, adopted as tests. They cover
what the fixes must not take back: a projection naming an unmapped getter on the
entity, across a navigation, on an owned member and on a JSON member; the
framework members the provider translates; and the posture comparison property
by property, the token vault, the service provider, provider instances and
provider order included.
One case asserted the opt-out being compared, which the fix removed on purpose;
it asserts the value that applies instead.
Co-Authored-By: Claude Opus 5
---
.../Policies/ReviewComputedPathTests.cs | 813 ++++++++++++++++++
.../Policies/ReviewPostureComparisonTests.cs | 325 +++++++
2 files changed, 1138 insertions(+)
create mode 100644 DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs
create mode 100644 DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs
diff --git a/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs
new file mode 100644
index 0000000..168db97
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs
@@ -0,0 +1,813 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Policies.Attributes;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ // ---------------------------------------------------------------------------------------------
+ // Probe model. EF Core 6 compatible on purpose, so the floor leg runs it too.
+ // ---------------------------------------------------------------------------------------------
+
+ public class ObMoney
+ {
+ public decimal Amount { get; set; }
+
+ public string Currency { get; set; } = "USD";
+
+ /// A getter over two owned columns: no database can answer it.
+ public bool IsZero => Amount == 0m;
+ }
+
+ public class ObCustomer
+ {
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public string? Nickname { get; set; }
+
+ public List Orders { get; set; } = new();
+
+ /// An unmapped getter one navigation away.
+ [NotMapped]
+ public string Handle => Name + "!";
+ }
+
+ public class ObOrder
+ {
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+
+ public DateTime PlacedAt { get; set; }
+
+ public DateTime? ShippedAt { get; set; }
+
+ public TimeSpan Window { get; set; }
+
+ public int Quantity { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public ObCustomer Customer { get; set; } = null!;
+
+ public ObMoney Total { get; set; } = new();
+
+ /// An unmapped getter over two mapped columns of the entity itself.
+ [NotMapped]
+ public string Slug => Code + "-" + Id;
+ }
+
+ /// A row a caller projects.
+ public class ObOrderRow
+ {
+ public ObOrderRow()
+ {
+ }
+
+ public ObOrderRow(int id, string code)
+ {
+ Id = id;
+ Code = code;
+ }
+
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+
+ public ObMoney Total { get; set; } = new();
+
+ public ObCustomer? Customer { get; set; }
+
+ public string Label { get; set; } = string.Empty;
+
+ public ObNest Nest { get; set; } = new();
+ }
+
+ public class ObNest
+ {
+ public ObNest? Inner { get; set; }
+
+ public string Leaf { get; set; } = string.Empty;
+
+ public ObMoney Money { get; set; } = new();
+ }
+
+ // ---- a hierarchy --------------------------------------------------------------------------
+
+ public class ObParty
+ {
+ public int Id { get; set; }
+
+ public string Kind { get; set; } = string.Empty;
+ }
+
+ public class ObMerchant : ObParty
+ {
+ public string? Licence { get; set; }
+ }
+
+ // ---- an alias -----------------------------------------------------------------------------
+
+ public class ObAliased
+ {
+ public int Id { get; set; }
+
+ [DwAlias("customer_name")]
+ public string Name { get; set; } = string.Empty;
+
+ [NotMapped]
+ [DwAlias("computed_tag")]
+ public string Tag => Name + "!";
+ }
+
+ public sealed class ObContext : DbContext
+ {
+ private readonly SqliteConnection _connection;
+
+ public ObContext(SqliteConnection connection) => _connection = connection;
+
+ public DbSet Orders => Set();
+
+ public DbSet Customers => Set();
+
+ public DbSet Parties => Set();
+
+ public DbSet Aliased => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection);
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity().OwnsOne(order => order.Total);
+ model.Entity().Ignore(order => order.Slug);
+ model.Entity().Property("Tenant");
+ model.Entity().HasDiscriminator("Discriminator")
+ .HasValue("party")
+ .HasValue("merchant");
+ model.Entity().Ignore(row => row.Tag);
+ model.Entity().Ignore(customer => customer.Handle);
+ }
+ }
+
+ ///
+ /// Probes for DW-17 over-blocking: a path the strict tier now refuses that an unguarded query
+ /// would in fact have run. Every case checks the unguarded query first.
+ ///
+ public sealed class ReviewComputedPathTests : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly ObContext _db;
+
+ public ReviewComputedPathTests(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new ObContext(_connection);
+ _db.Database.EnsureCreated();
+
+ ObCustomer customer = new() { Name = "Acme", Nickname = "A" };
+
+ _db.Customers.Add(customer);
+ _db.Orders.Add(new ObOrder
+ {
+ Code = "A-001",
+ PlacedAt = new DateTime(2024, 3, 9, 10, 30, 0),
+ ShippedAt = new DateTime(2024, 3, 10),
+ Window = TimeSpan.FromHours(3),
+ Quantity = 2,
+ Customer = customer,
+ Total = new ObMoney { Amount = 10m, Currency = "USD" }
+ });
+ _db.Parties.Add(new ObMerchant { Kind = "merchant", Licence = "L-1" });
+ _db.Aliased.Add(new ObAliased { Name = "Acme" });
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict)
+ where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ new DwPolicyOptions { Tier = tier },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ private static Filter Where(string field, string value, DataType type = DataType.Text) => new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ }
+ };
+
+ private static Filter Select(params string[] fields) => new() { Selects = fields.ToList() };
+
+ private static Filter Order(string field) => new()
+ {
+ Orders = new List { new() { Field = field, Direction = Direction.Ascending } }
+ };
+
+ /// Runs a guarded call and reports what came back: rows, a refusal, or a provider failure.
+ private string Run(IQueryable source, Filter filter, DwTier tier = DwTier.Strict)
+ where T : class
+ {
+ try
+ {
+ FilterResult result = Guard(source, tier).ToList(filter);
+
+ return $"OK({result.Data.Count})";
+ }
+ catch (PolicyException refusal)
+ {
+ return $"REFUSED({refusal.ErrorCode})";
+ }
+ catch (Exception failure)
+ {
+ return $"{failure.GetType().Name}: {Short(failure.Message)}";
+ }
+ }
+
+ private static string Short(string message)
+ {
+ string one = message.Replace(Environment.NewLine, " ");
+
+ return one.Length > 140 ? one[..140] : one;
+ }
+
+ /// Runs a raw LINQ query the way a caller without the guard would.
+ private string Unguarded(Func
Selects is not one of them. A projection
@@ -1492,16 +1492,23 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
An entity
the EF Core model: columns, shadow properties, owned and complex members, navigations
Refused
-
A row a Select built before ApplyPolicy
that initializer's own assignments, at every level
Refused
+
A row a Select built before ApplyPolicy
that initializer's own assignments, at every level, both branches of a conditional included
Refused
…where the Select copies the member, Name = role.Name
the model, beneath the member it copies
Refused
Rows in memory
nothing — the getter runs
Runs, as before
-
Anything beneath a column, converted or not
nothing — the converter decides
Runs, as before
+
Anything beneath a column, converted or not
nothing — the converter decides
Left alone, as before
A framework member: Length, Year, HasValue
nothing — the provider translates it
Runs, as before
-
A source the library cannot read
nothing
Runs, as before
+
A source the library cannot read
nothing
Left alone, as before
+
A provider in front of EF Core: an expression expander, a decompiler
nothing — it rewrites what EF Core cannot translate
Left alone, as before
A column only a subtype maps, queried through the base
the queried type's model, which is what EF Core translates against
Refused
-
A projection a provider that is not EF Core's ran
nothing — its rules are its own
Runs, as before
+
A projection a provider that is not EF Core's ran
nothing — its rules are its own
Left alone, as before
+
+ Left alone is not a promise that the path runs. The
+ policy does not refuse it, so it behaves exactly as it does unguarded:{" "}
+ Name.IsEmpty beneath a column mapped through a value
+ converter still fails inside the provider, as it always has.
+
An unmapped getter on the entity itself,{" "}
Display => $"{Code}:{Id}",
@@ -1511,10 +1518,30 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
A member the model maps nowhere is one the database cannot compute, so
- the strict tier refuses it wherever the database has to. A member some
- provider extension computes without a mapping is refused with the rest:
- map it, or filter on the columns beneath it.
+ the strict tier refuses it wherever the database has to. A member a
+ translator inside EF Core computes without a mapping — a{" "}
+ [DbFunction], a translator plugin — is refused with the
+ rest: map it, or filter on the columns beneath it.
+
+ A provider in front of EF Core is a different case, and is left
+ alone. LinqKit's AsExpandable() and
+ DelegateDecompiler's Decompile() exist to rewrite the
+ members EF Core cannot translate, so a member they compute is one the
+ query produces, over a projection and over an entity alike. A row the
+ library itself projected is read like any other: the core's typed{" "}
+ Select null-guards every nested node it builds, and both
+ branches of that guard are read, so composing Select and
+ then filtering refuses what the bare handle refuses.
+
+
+ The refusal raises no [DwAudit] event, for the reason an
+ unknown name raises none: no field was read, and the refusal names none.{" "}
+ AuditRefusals records it, and so does the trace. A
+ simulation has no source, so PolicySimulator and{" "}
+ /simulate cannot refuse such a path — they show the request
+ running where the strict query refuses it.
+
31. LastTrace Is Set Before a Request Is Sanitized
@@ -1548,9 +1575,14 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}IncludeTraceInResult
, AuditRefusals,{" "}
HashSalt, StoreFailure,{" "}
MaxSnapshotAge, RefreshInterval, every cap
- value, the exposed entity catalogue with every name it answers to, and
- the provider types in the order supplied. Writing a cap's own
- default down is not a difference. Not compared, and not
+ value, the exposed entity catalogue with every name it answers to and
+ the name each type is reported under, and the provider types in the
+ order supplied. Writing a value's own default down is not a
+ difference: a cap's default, and IncludeTraceInResult{" "}
+ written as the tier's own answer, are compared by the value that
+ applies. A type exposed under two names is reported under the last one,
+ so two catalogues resolving every name alike are still refused when the
+ order differs. Not compared, and not
replaced: TokenVault, Services and the
provider instances — a second host builds its own, and no two are ever
the same reference.
diff --git a/OfficialWebsite/app/docs/classes/filter/page.tsx b/OfficialWebsite/app/docs/classes/filter/page.tsx
index b27ffd8..c68d70a 100644
--- a/OfficialWebsite/app/docs/classes/filter/page.tsx
+++ b/OfficialWebsite/app/docs/classes/filter/page.tsx
@@ -124,7 +124,12 @@ FilterResult result = await dbContext.Customers.ToListAsync(filter);`}
Clone
Filter.Clone() is public since 3.3.0. It
- returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing is shared with the original.
+ returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing either request is given afterwards reaches the other.
+
+
+ Every node is new. The values a condition carries stay the caller's
+ own objects, in a new list: they are scalars decoded from JSON and
+ nothing in the pipeline writes to them.
{`Filter page2 = caller.Clone();
page2.Page!.PageNumber = 2; // the caller's own filter is untouched`}
diff --git a/OfficialWebsite/app/docs/classes/segment/page.tsx b/OfficialWebsite/app/docs/classes/segment/page.tsx
index f64f578..d5ec291 100644
--- a/OfficialWebsite/app/docs/classes/segment/page.tsx
+++ b/OfficialWebsite/app/docs/classes/segment/page.tsx
@@ -171,7 +171,12 @@ SegmentResult result = await dbContext.Customers.ToListAsync(segment);
Clone
Segment.Clone() is public since 3.3.0. It
- returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing is shared with the original.
+ returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing either request is given afterwards reaches the other.
+
+
+ Every node is new. The values a condition carries stay the caller's
+ own objects, in a new list: they are scalars decoded from JSON and
+ nothing in the pipeline writes to them.
{`Segment page2 = caller.Clone();
page2.Page!.PageNumber = 2; // the caller's own segment is untouched`}
diff --git a/OfficialWebsite/app/docs/classes/summary/page.tsx b/OfficialWebsite/app/docs/classes/summary/page.tsx
index ba0c94f..17742b0 100644
--- a/OfficialWebsite/app/docs/classes/summary/page.tsx
+++ b/OfficialWebsite/app/docs/classes/summary/page.tsx
@@ -171,7 +171,12 @@ SummaryResult result = await dbContext.Orders.ToListAsync(summary);`}
Clone
Summary.Clone() is public since 3.3.0. It
- returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing is shared with the original.
+ returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing either request is given afterwards reaches the other.
+
+
+ Every node is new. The values a condition carries stay the caller's
+ own objects, in a new list: they are scalars decoded from JSON and
+ nothing in the pipeline writes to them.
{`Summary page2 = caller.Clone();
page2.Page!.PageNumber = 2; // the caller's own summary is untouched`}
diff --git a/OfficialWebsite/app/docs/extensions/group/page.tsx b/OfficialWebsite/app/docs/extensions/group/page.tsx
index 2181089..833bdb1 100644
--- a/OfficialWebsite/app/docs/extensions/group/page.tsx
+++ b/OfficialWebsite/app/docs/extensions/group/page.tsx
@@ -21,6 +21,16 @@ export default function Page() {
group-by fields and aggregate aliases as properties.
+
+ DwCaps.MinGroupSize ships on, at 5, so a
+ guarded call removes every group with fewer than five rows — not
+ refused, and nothing in the answer says a group was dropped. That is
+ right for anonymised reporting and surprising for an operational count.
+ Set Caps.MinGroupSize = 1 to switch the floor off,
+ deliberately. An unguarded call is never floored. See{" "}
+ k-anonymity.
+
+
Signature
{`public static IQueryable Group(this IQueryable query, GroupBy groupBy)
where T : class`}
diff --git a/OfficialWebsite/app/docs/policies/admin/page.tsx b/OfficialWebsite/app/docs/policies/admin/page.tsx
index 34441f3..7382a48 100644
--- a/OfficialWebsite/app/docs/policies/admin/page.tsx
+++ b/OfficialWebsite/app/docs/policies/admin/page.tsx
@@ -180,7 +180,10 @@ export default function Page() {
loads; over rows in memory, values only. So the simulated clause can
list fewer members than the query returns, and can show a projection
an entity query does not need. PolicySimulator reads a
- type the same way. See{" "}
+ type the same way. For the same reason neither can refuse a path no
+ database can compute: that refusal is read from the model behind the
+ source, so a simulation shows such a request running where the strict
+ query refuses it. See{" "}
A request that sends no Selects.
diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx
index 587afd9..2295840 100644
--- a/OfficialWebsite/app/docs/policies/configuration/page.tsx
+++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx
@@ -932,7 +932,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
Compared
Not compared
-
Tier, DryRun, IncludeTraceInResult, AuditRefusals
+
Tier, DryRun, AuditRefusals, and IncludeTraceInResult by the value that applies
TokenVault
@@ -944,7 +944,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
The provider instances
-
The exposed entity catalogue: the same types under the same names
+
The exposed entity catalogue: the same types, every name each answers to, and the name each is reported under
@@ -953,6 +953,15 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so
+
+ IncludeTraceInResult is compared the way a cap is: it
+ defaults to the tier's own answer, and the tiers are equal by then,
+ so a host writing that answer out and a host leaving it null hand a
+ caller the same result. The catalogue is stricter — a type exposed under
+ two names is reported under the last one it was given, so two catalogues
+ that resolve every name alike still answer a schema request differently,
+ and the second posture is refused.
+
The three on the right are objects a host builds for itself, and a
second host builds its own, so comparing them by reference would make
diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx
index 8b8871f..cef73fe 100644
--- a/OfficialWebsite/app/docs/policies/security/page.tsx
+++ b/OfficialWebsite/app/docs/policies/security/page.tsx
@@ -18,9 +18,11 @@ export default function Page() {
Denying a field is easy. The hard part is the set of ways a caller can
learn a value without reading it. Six such channels follow, then
- two bypasses that are not channels, then the requests that, until
- 3.2.0, carried out a denied value the gate could not see; each has a
- test that reproduces the attack and goes red if the control is removed.
+ two bypasses that are not channels, then a path the query cannot compute
+ — not a channel either, but the one request the tier used to answer with
+ neither an answer nor a refusal — then the requests that, until 3.2.0,
+ carried out a denied value the gate could not see; each has a test that
+ reproduces the attack and goes red if the control is removed.
@@ -184,6 +186,21 @@ true order. Add [DwNoOrder] unless that is intended.`}
together describe how the rows are partitioned.
+
7 and 8. The two that are not channels
+
+
Attack
Control
+
+
+
An unguarded DynamicWhere call on a type that requires a policy
+
[DwEntity(RequirePolicy = true)] throws PolicyRequired rather than returning rows. Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted — the flag closes the hole in this API, not every route to the table.
+
+
+
An empty policy store
+
Attributes still enforce; an empty store never resolves to Allow
+
+
+
+
A path the query cannot compute
A member of a row's type is not always a value a database can
@@ -208,6 +225,17 @@ true order. Add [DwNoOrder] unless that is intended.`}
member the provider translates such as Length or{" "}
Year, anything beneath a column, the convenience tier and a
dry run are all unchanged, and fail exactly as the unguarded query does.
+ So is a query a provider in front of EF Core translates — LinqKit's{" "}
+ AsExpandable(), DelegateDecompiler's{" "}
+ Decompile() — since such a provider exists to rewrite what
+ EF Core cannot, and a member it computes is one the query produces.
+
+
+ The refusal raises no [DwAudit] event, for the reason an
+ unknown name raises none: no field was read, and the refusal names none.{" "}
+ AuditRefusals records it, and the trace carries the real
+ path. A simulation is handed no source, so it cannot refuse such a path
+ at all.
This closes no leak: the query failed, it did not answer. It removes a
@@ -216,21 +244,6 @@ true order. Add [DwNoOrder] unless that is intended.`}
refused. The trace records the reason.
-
7 and 8. The two that are not channels
-
-
Attack
Control
-
-
-
An unguarded DynamicWhere call on a type that requires a policy
-
[DwEntity(RequirePolicy = true)] throws PolicyRequired rather than returning rows. Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted — the flag closes the hole in this API, not every route to the table.
-
-
-
An empty policy store
-
Attributes still enforce; an empty store never resolves to Allow
-
-
-
-
Denials the gate could not see
A denied field often sits on a type the query reaches through a member:
diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt
index 1ba39a2..71f97ca 100644
--- a/OfficialWebsite/public/llms.txt
+++ b/OfficialWebsite/public/llms.txt
@@ -204,7 +204,8 @@ Summary ConditionGroup ConditionGroup? null where, bef
Orders List? null each Field is a GroupBy field or an Alias
Page PageBy? null pages the groups
-Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, shares no object
+Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, every node new,
+ a condition's values shared in a new list
with the original: the condition tree with its
groups and conditions, the Selects list, each
OrderBy, the Page, and a Summary's GroupBy with
@@ -1463,9 +1464,13 @@ DwPolicy stat
`WebApplicationFactory` hosts over one composition root.
- Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`,
`MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value, the exposed catalogue — the same types under the same
- names, and every name each type answers to — and the provider **types**, in the order supplied, with
- `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not compared: nothing in enforcement
- reads it, so writing `MinGroupSize = 5` and leaving it unset are the same posture.
+ names, every name each type answers to, and the name each type is reported under — and the provider **types**,
+ in the order supplied, with `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not
+ compared: nothing in enforcement reads it, so writing `MinGroupSize = 5` and leaving it unset are the same
+ posture. `IncludeTraceInResult` is compared by the value that applies, not by whether it was written down: it
+ defaults to the tier's own answer, so writing that answer out is the same posture. A type exposed under two
+ names is reported under the last one, so two catalogues resolving every name alike are still refused when the
+ order differs.
- Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds
its own and no two are ever the same reference, so they stay as the first call left them — a second host runs
with the first host's vault, container and rule stores.
@@ -2315,7 +2320,7 @@ The resolver, fragment and provider types are listed in section 21.
Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused
as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`, in every clause the database has to
-compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`.
+compute: a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`.
`Selects` is not one of them. A projection is the last thing the provider builds, and EF Core evaluates that one on
the client when it cannot translate it, so `Selects` naming such a member returns its value exactly as before.
@@ -2324,20 +2329,34 @@ What decides:
```
Source of the rows What the set of producible members is read from Name.IsEmpty
-entity query EF Core model: columns (shadow included), owned,
- complex, navigations, and derived types refused
-Select(...) before ApplyPolicy building the row that initializer's assignments, at every level refused
+entity query EF Core model of the queried type: columns
+ (shadow included), owned, complex, navigations refused
+Select(...) before ApplyPolicy building the row that initializer's assignments, at every level,
+ both branches of a conditional included refused
... a member it copies whole: Name = role.Name the model, beneath the copied member refused
rows in memory (EnumerableQuery) nothing; the getter runs runs
-beneath a column, converted or not nothing; the converter decides runs
+beneath a column, converted or not nothing; the converter decides left alone
a framework member: Length, Year, HasValue nothing; the provider translates it runs
-a source the library cannot read into nothing runs
+a source the library cannot read into nothing left alone
+a provider in front of EF Core nothing; it rewrites what EF Core cannot
+ translate left alone
a column only a subtype maps, through the base the queried type's model, which EF Core
translates against refused
-a projection another provider ran nothing; its rules are its own runs
+a projection another provider ran nothing; its rules are its own left alone
```
+- `left alone` is not a promise that the path runs: the policy does not refuse it, so it behaves
+ exactly as it does unguarded. `Name.IsEmpty` beneath a column mapped through a value converter
+ still fails inside the provider.
- An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason.
+- The rule is EF Core's own provider's. A provider that wraps EF Core — LinqKit's `AsExpandable()`,
+ DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its rows are left alone, over a
+ projection and over an entity alike.
+- A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node
+ it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare
+ handle refuses.
+- The refusal raises no `[DwAudit]` event, as an unknown name raises none: no field was read and the refusal names
+ none. `AuditRefusals` records it, and so does the trace.
- `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded.
- The rule is the model's: a member it maps nowhere is refused, whatever a provider extension could compute.
- The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`.
@@ -3704,7 +3723,7 @@ options.Entities.Expose("Employee");
var store = new InMemoryPolicyStore(options.Entities.Resolve);
var provider = await StorePolicyProvider.CreateAsync(store, options);
-DwPolicy.Configure(options, provider); // once; freezes options
+DwPolicy.Configure(options, provider); // freezes options; a second call must ask for this posture
builder.Services.AddSingleton(store); // admin API reads
builder.Services.AddSingleton(store); // admin API writes
@@ -4844,8 +4863,9 @@ MemoryCalculationInput
force and do nothing. A different posture still throws. The check is inside the lock that configures, so a
caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass.
Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge,
- RefreshInterval, every cap value, the exposed catalogue with every name it answers to, and the provider
- types in order.
+ RefreshInterval, every cap value, the exposed catalogue with every name it answers to and the name each
+ type is reported under, and the provider types in order. A cap's own default and the tier's own answer for
+ IncludeTraceInResult are compared by the value that applies, not by whether somebody wrote them down.
Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with
the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than
the instance it built.
@@ -4855,12 +4875,15 @@ MemoryCalculationInput
the caller saw a five-hundred. Selects is not one of them: EF Core evaluates the last projection on the
client. Refused only where the producible members are known: the queried type's own model, and the
initializers of a projection EF Core ran before ApplyPolicy, including a member it copies from the entity.
- Rows in memory, a framework member such as Length or Year, anything beneath a column, another provider's
- rows, Convenience and a dry run are unchanged.
+ Rows in memory, a framework member such as Length or Year, anything beneath a column, a query a provider
+ in front of EF Core translates — an expression expander, a decompiler — Convenience and a dry run are
+ unchanged. The refusal raises no [DwAudit] event, as an unknown name raises none; AuditRefusals and the
+ trace record it. A simulation has no source, so PolicySimulator and /simulate cannot refuse such a path.
- Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own
trace readable instead of the previous request's.
- New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list,
- each order, the page, and a summary's GroupBy and Having.
+ each order, the page, and a summary's GroupBy and Having. Every node is new; a condition's values are the
+ caller's own objects, in a new list.
- Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The
aggregation sections now lead with Caps.MinGroupSize shipping on at 5.
@@ -5118,6 +5141,11 @@ MemoryCalculationInput
the derived type, `Set()`.
- A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store
instances (3.3.0, section 12). They are not compared and not replaced.
+- A simulation has no source, so `PolicySimulator` and `/simulate` cannot refuse a path no database can compute;
+ they show such a request running where the strict query refuses it (3.3.0, section 17).
+- A condition's values are read once to validate their format and again to build the predicate. A value whose
+ `ToString()` answers differently each time is validated as one value and queried as another; pass values that do
+ not change. No policy decision reads a value's content — only how many there are.
- Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`).
- `Filter` applies `Orders` and `Page` on T before the projection, so order fields need not be selected.
- `getQueryString` needs an EF Core provider.
diff --git a/README.md b/README.md
index c7f3a9f..a9ec82d 100644
--- a/README.md
+++ b/README.md
@@ -210,7 +210,7 @@ Rows with an empty collection sort as `null` (or the type default for non-nullab
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?**
```csharp
-// Once, at startup.
+// At startup. A second call asking for this same posture does nothing.
DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, HashSalt = secret });
// Once per request.
@@ -379,10 +379,10 @@ The complete reference — every enum, class, extension method, validation rule,
**Upgrade note — two behaviour changes. Read these before bumping.**
-- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — 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 cap's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor. 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. `AddDwPolicies` registers 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 as `LocalizedText.IsEmpty` reads 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. Not in `Selects`: 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 before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it.
+- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — 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": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written 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. `AddDwPolicies` registers 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 as `LocalizedText.IsEmpty` reads 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. Not in `Selects`: 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 before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it.
- **Changed: `LastTrace` is 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 on `Filter`, `Segment` and `Summary`.** 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.
+- **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** 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.
- **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **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
From 1cbe928762718697cdebb00523834247f0f11e52 Mon Sep 17 00:00:00 2001
From: Sajjad Hussain
Date: Sun, 20 Sep 2026 13:59:17 +0300
Subject: [PATCH 14/54] test(policies): keep the second review's probes, so
what was ruled out stays ruled out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four files, 120 or so facts: the source table row by row, the posture
property by property, every clause that refuses and every one that does not,
the mapping shapes a refusal must not misread — JSON owned types, primitive
collections, TPH, TPT, table splitting, keyless entities — and the shapes
that decide whether a projected member can be spoken for.
Where a probe asserted a contract the review then settled the other way, it
asserts the settled one: a wrapping provider is answered for the same way
over either kind of row, Clone shares the caller's values, and the two
divergences that stay — a simulation cannot refuse an uncomputable path, and
a condition's values are read more than once — are pinned as the limits they
are rather than as bugs.
One probe drove DwPolicy's static provider list by reflection to reach a
comparison; it now reads the comparison itself, since the list is
process-wide and driving it made every suite configuring the posture in
parallel fail. The mapping probes stay off the floor leg: they map with
ToJson and a primitive collection, which EF Core 6 has neither of.
Co-Authored-By: Claude Opus 5
---
DynamicWhere.Tests/DynamicWhere.Tests.csproj | 4 +
.../Policies/DocsReview33Probe.cs | 1071 +++++++++++++++++
.../Policies/DocsReview33ProbeB.cs | 251 ++++
.../Policies/ReviewMappingProbes.cs | 336 ++++++
.../Policies/ReviewOverBlockProbes.cs | 875 ++++++++++++++
.../Policies/ReviewPostureProbes.cs | 336 ++++++
.../Policies/ReviewSecondRoundProbes.cs | 674 +++++++++++
7 files changed, 3547 insertions(+)
create mode 100644 DynamicWhere.Tests/Policies/DocsReview33Probe.cs
create mode 100644 DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs
create mode 100644 DynamicWhere.Tests/Policies/ReviewMappingProbes.cs
create mode 100644 DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs
create mode 100644 DynamicWhere.Tests/Policies/ReviewPostureProbes.cs
create mode 100644 DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs
diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj
index 48e78ca..29e17f9 100644
--- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj
+++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj
@@ -97,6 +97,10 @@
+
+
diff --git a/DynamicWhere.Tests/Policies/DocsReview33Probe.cs b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs
new file mode 100644
index 0000000..6c20f0c
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs
@@ -0,0 +1,1071 @@
+using System.Linq.Expressions;
+using System.Reflection;
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Policies.Attributes;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.Discovery;
+using DynamicWhere.ex.Policies.DTOs;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+// A docs-against-code probe for the 3.3.0 claims. Nothing here is a fix; every fact reads what the
+// code does and prints it, so the report can quote an outcome rather than an inference.
+
+namespace DynamicWhere.Tests.Policies
+{
+ // ---- a model with a converted column, a date, a nullable and an unmapped getter --------------
+
+ public class QpTag
+ {
+ public string Value { get; set; } = string.Empty;
+
+ /// A getter over the one column the converter stores.
+ public bool IsEmpty => string.IsNullOrWhiteSpace(Value);
+ }
+
+ public class QpTicket
+ {
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+
+ public DateTime CreatedAt { get; set; }
+
+ public int? Score { get; set; }
+
+ /// Stored through a value converter, so the model maps it as one column.
+ public QpTag Tag { get; set; } = new();
+
+ [DwDenied]
+ public string Secret { get; set; } = string.Empty;
+
+ /// Unmapped: a getter over two columns of the entity itself.
+ public string Display => $"{Code}:{Id}";
+ }
+
+ public sealed class QpContext : DbContext
+ {
+ private readonly SqliteConnection _connection;
+
+ public QpContext(SqliteConnection connection) => _connection = connection;
+
+ public DbSet Tickets => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection);
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity().Property(ticket => ticket.Tag)
+ .HasConversion(tag => tag.Value, value => new QpTag { Value = value });
+
+ model.Entity().Ignore(ticket => ticket.Display);
+ }
+ }
+
+ // ---- default-order shapes --------------------------------------------------------------------
+
+ [DwEntity(DefaultOrder = "Code desc")]
+ public class QpCtorRow
+ {
+ public QpCtorRow()
+ {
+ }
+
+ /// A constructor with arguments. Which member each one sets is not recorded.
+ public QpCtorRow(int id, string code)
+ {
+ Id = id;
+ Code = code;
+ }
+
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+ }
+
+ [DwEntity(DefaultOrder = "Code desc")]
+ public class QpPlainRow
+ {
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+ }
+
+ [DwEntity(DefaultOrder = "Code desc")]
+ public class QpTicketRow
+ {
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+ }
+
+ /// A catalogue-comparison stand-in, exposed nowhere else.
+ public sealed class QpOnlyHere
+ {
+ }
+
+ public sealed class QpAlsoOnlyHere
+ {
+ }
+
+ public sealed class QpProviderA : IDwPolicyProvider
+ {
+ public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) =>
+ Array.Empty();
+ }
+
+ public sealed class QpProviderB : IDwPolicyProvider
+ {
+ public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) =>
+ Array.Empty();
+ }
+
+ // =============================================================================================
+ // 1. The source table, row by row, and the Selects exemption.
+ // =============================================================================================
+
+ public sealed class QpSourceTableProbe : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly QpContext _db;
+
+ public QpSourceTableProbe(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new QpContext(_connection);
+ _db.Database.EnsureCreated();
+ _db.Tickets.Add(new QpTicket
+ {
+ Code = "alpha",
+ CreatedAt = new DateTime(2026, 3, 4),
+ Score = 7,
+ Tag = new QpTag { Value = "red" },
+ Secret = "s"
+ });
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source, DwTier tier) where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ new DwPolicyOptions { Tier = tier },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ private static Filter Where(string field, string value, DataType type = DataType.Text) => new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ }
+ };
+
+ private void Row(string label, IQueryable source, string path)
+ {
+ object shape = typeof(RowShape)
+ .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!
+ .MakeGenericMethod(source.ElementType)
+ .Invoke(null, new object[] { source })!;
+
+ bool? answer = (bool?)typeof(RowShape)
+ .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(shape, new object[] { path });
+
+ _out.WriteLine($"{label,-52} Expresses(\"{path}\") = {(answer is null ? "null (left alone)" : answer.ToString())}");
+ }
+
+ // ---- every row of the table, read straight off RowShape -----------------------------------
+
+ [Fact]
+ public void The_source_table_row_by_row()
+ {
+ Row("an entity", _db.Tickets, "Tag.IsEmpty");
+ Row("an entity, unmapped getter on itself", _db.Tickets, "Display");
+ Row("an entity, a framework member (Length)", _db.Tickets, "Code.Length");
+ Row("an entity, a framework member (Year)", _db.Tickets, "CreatedAt.Year");
+ Row("an entity, a framework member (HasValue)", _db.Tickets, "Score.HasValue");
+ Row("beneath a converted column", _db.Tickets, "Tag.Value");
+
+ Row("rows in memory", new[] { new QpTicket() }.AsQueryable(), "Tag.IsEmpty");
+ Row("a source the library cannot read",
+ new ZyOwnProvider(new[] { new QpTicket() }.AsQueryable()), "Tag.IsEmpty");
+ }
+
+ /// Beneath a column the converter decides, so the policy leaves it alone either way.
+ [Fact]
+ public void Beneath_a_column_is_left_alone_and_behaves_as_it_does_unguarded()
+ {
+ Exception? unguarded = Record.Exception(
+ () => _db.Tickets.Where(ticket => ticket.Tag.IsEmpty == false).ToList());
+
+ Exception? guarded = Record.Exception(
+ () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Tag.IsEmpty", "false", DataType.Boolean)));
+
+ _out.WriteLine($"converted column: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}");
+
+ Assert.IsNotType(guarded);
+ }
+
+ // ---- the framework members the text names, guarded and unguarded --------------------------
+
+ [Fact]
+ public void Length_runs_guarded_and_unguarded()
+ {
+ Assert.Single(_db.Tickets.Where(ticket => ticket.Code.Length == 5).ToList());
+
+ FilterResult guarded = Guard(_db.Tickets, DwTier.Strict)
+ .ToList(Where("Code.Length", "5", DataType.Number));
+
+ _out.WriteLine($"Length: unguarded=1 guarded={guarded.Data.Count}");
+
+ Assert.Single(guarded.Data);
+ }
+
+ [Fact]
+ public void Year_runs_guarded_and_unguarded()
+ {
+ Assert.Single(_db.Tickets.Where(ticket => ticket.CreatedAt.Year == 2026).ToList());
+
+ FilterResult guarded = Guard(_db.Tickets, DwTier.Strict)
+ .ToList(Where("CreatedAt.Year", "2026", DataType.Number));
+
+ _out.WriteLine($"Year: unguarded=1 guarded={guarded.Data.Count}");
+
+ Assert.Single(guarded.Data);
+ }
+
+ [Fact]
+ public void HasValue_runs_guarded_and_unguarded()
+ {
+ Assert.Single(_db.Tickets.Where(ticket => ticket.Score.HasValue).ToList());
+
+ Exception? guarded = Record.Exception(
+ () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Score.HasValue", "true", DataType.Boolean)));
+
+ _out.WriteLine($"HasValue: unguarded=1 guarded={guarded?.GetType().Name ?? "ran"} {guarded?.Message}");
+
+ Assert.IsNotType(guarded);
+ Assert.Null(guarded);
+ }
+
+ // ---- Selects, both tiers, filter and segment -----------------------------------------------
+
+ [Fact]
+ public void Selects_is_exempt_under_strict()
+ {
+ FilterResult result = Guard(_db.Tickets, DwTier.Strict)
+ .ToList(new Filter { Selects = new List { "Id", "Display" } });
+
+ _out.WriteLine($"strict Selects of an unmapped getter: {result.Data.Count} row(s)");
+
+ Assert.Single(result.Data);
+ }
+
+ [Fact]
+ public void Selects_is_exempt_under_convenience()
+ {
+ FilterResult result = Guard(_db.Tickets, DwTier.Convenience)
+ .ToList(new Filter { Selects = new List { "Id", "Display" } });
+
+ _out.WriteLine($"convenience Selects of an unmapped getter: {result.Data.Count} row(s)");
+
+ Assert.Single(result.Data);
+ }
+
+ [Fact]
+ public async Task Selects_is_exempt_inside_a_segment()
+ {
+ SegmentResult result = await Guard(_db.Tickets, DwTier.Strict).ToListAsync(new Segment
+ {
+ Selects = new List { "Id", "Display" },
+ ConditionSets =
+ {
+ new ConditionSet
+ {
+ Sort = 1,
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha" } } }
+ }
+ }
+ }
+ });
+
+ _out.WriteLine($"segment Selects of an unmapped getter: {result.Data.Count} row(s)");
+
+ Assert.Single(result.Data);
+ }
+
+ [Fact]
+ public void A_denied_field_named_in_Selects_is_still_refused()
+ {
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "Secret" } }));
+
+ _out.WriteLine($"denied field in Selects: {refusal.ErrorCode} FieldPath={refusal.FieldPath}");
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode);
+ }
+
+ [Fact]
+ public void An_unknown_name_in_Selects_is_still_refused()
+ {
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "NoSuchColumn" } }));
+
+ _out.WriteLine($"unknown name in Selects: {refusal.ErrorCode}");
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode);
+ }
+
+ // ---- which clauses refuse -------------------------------------------------------------------
+
+ [Fact]
+ public void An_aggregated_field_is_refused()
+ {
+ PolicyException refusal = Assert.ThrowsAny(
+ () => Guard(_db.Tickets, DwTier.Strict).ToList(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Code" },
+ AggregateBy = new List
+ {
+ new() { Alias = "N", Aggregator = Aggregator.Count },
+ new() { Alias = "D", Field = "Display", Aggregator = Aggregator.Maximum }
+ }
+ }
+ }));
+
+ _out.WriteLine($"aggregated field: {refusal.ErrorCode}");
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForAggregate, refusal.ErrorCode);
+ }
+
+ [Fact]
+ public void The_composed_clauses_each_report_their_own_answer()
+ {
+ PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict);
+
+ Exception? where = Record.Exception(() => guarded.Where(new Condition
+ {
+ Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" }
+ }));
+
+ Exception? order = Record.Exception(
+ () => guarded.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending }));
+
+ Exception? select = Record.Exception(() => guarded.Select(new List { "Id", "Display" }));
+
+ Exception? page = Record.Exception(() => guarded.Page(new PageBy { PageNumber = 1, PageSize = 10 }));
+
+ _out.WriteLine($"composed .Where -> {Name(where)}");
+ _out.WriteLine($"composed .Order -> {Name(order)}");
+ _out.WriteLine($"composed .Select -> {Name(select)}");
+ _out.WriteLine($"composed .Page -> {Name(page)}");
+
+ Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, ((PolicyException)where!).ErrorCode);
+ Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)order!).ErrorCode);
+ Assert.Null(select);
+ Assert.Null(page);
+
+ static string Name(Exception? error) =>
+ error is PolicyException policy
+ ? $"{policy.GetType().Name} {policy.ErrorCode}"
+ : error?.GetType().Name ?? "no refusal";
+ }
+
+ // ---- LastTrace on every terminal ------------------------------------------------------------
+
+ [Fact]
+ public async Task LastTrace_is_readable_after_a_refusal_on_every_terminal()
+ {
+ Filter filter = Where("Display", "alpha:1");
+
+ Report("ToList(Filter)", g => g.ToList(filter));
+ await ReportAsync("ToListAsync(Filter)", g => g.ToListAsync(Where("Display", "alpha:1")));
+ Report("ToListDynamic(Filter)", g => g.ToListDynamic(Where("Display", "alpha:1")));
+ await ReportAsync("ToListAsyncDynamic(Filter)", g => g.ToListAsyncDynamic(Where("Display", "alpha:1")));
+
+ Report("ToList(Summary)", g => g.ToList(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Display" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ }
+ }));
+
+ await ReportAsync("ToListAsync(Summary)", g => g.ToListAsync(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Display" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ }
+ }));
+
+ await ReportAsync("ToListAsync(Segment)", g => g.ToListAsync(new Segment
+ {
+ ConditionSets =
+ {
+ new ConditionSet
+ {
+ Sort = 1,
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } } }
+ }
+ }
+ }
+ }));
+
+ Report("composed .Where", g => g.Where(new Condition
+ {
+ Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" }
+ }));
+
+ Report("composed .Order", g => g.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending }));
+
+ Report("composed .Group", g => g.Group(new GroupBy
+ {
+ Fields = new List { "Display" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ }));
+
+ void Report(string label, Action> call)
+ {
+ PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict);
+
+ Assert.ThrowsAny(() => call(guarded));
+
+ Describe(label, guarded.LastTrace);
+ }
+
+ async Task ReportAsync(string label, Func, Task> call)
+ {
+ PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict);
+
+ await Assert.ThrowsAnyAsync(() => call(guarded));
+
+ Describe(label, guarded.LastTrace);
+ }
+
+ void Describe(string label, PolicyTrace? trace)
+ {
+ string reason = trace?.Decisions
+ .Where(decision => decision.Reason is not null && decision.Reason.Contains("cannot compute"))
+ .Select(decision => decision.FieldPath)
+ .FirstOrDefault() ?? "(no 'cannot compute' decision)";
+
+ _out.WriteLine($"{label,-28} LastTrace={(trace is null ? "null" : "set")} reason on: {reason}");
+
+ Assert.NotNull(trace);
+ }
+ }
+ }
+
+ // =============================================================================================
+ // 2. DefaultOrder: the four shapes the corrected remarks name.
+ // =============================================================================================
+
+ public sealed class QpDefaultOrderProbe : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly QpContext _db;
+
+ public QpDefaultOrderProbe(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new QpContext(_connection);
+ _db.Database.EnsureCreated();
+
+ // Inserted B, C, A: none of the expected orders is the insertion order.
+ foreach (string code in new[] { "B", "C", "A" })
+ {
+ _db.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() });
+ }
+
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source) where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ new DwPolicyOptions { Tier = DwTier.Strict },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ [Fact]
+ public void A_constructor_with_arguments_and_an_initializer_still_takes_the_default()
+ {
+ IQueryable rows = _db.Tickets
+ .Select(ticket => new QpCtorRow(ticket.Id, ticket.Code) { Code = ticket.Code });
+
+ string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code));
+
+ _out.WriteLine($"ctor with args + initializer: {order}");
+
+ Assert.Equal("C,B,A", order);
+ }
+
+ [Fact]
+ public void A_projection_with_no_initializer_at_all_stays_in_its_own_order()
+ {
+ IQueryable rows = _db.Tickets.Select(ticket => new QpCtorRow(ticket.Id, ticket.Code));
+
+ string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code));
+
+ _out.WriteLine($"ctor with args, no initializer: {order}");
+
+ Assert.NotEqual("C,B,A", order);
+ }
+
+ [Fact]
+ public void A_filter_carrying_Selects_is_ordered_as_any_other_filter_is()
+ {
+ IQueryable rows = _db.Tickets
+ .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code });
+
+ string order = string.Join(
+ ",",
+ Guard(rows).ToList(new Filter { Selects = new List { "Id", "Code" } })
+ .Data.Select(row => row.Code));
+
+ _out.WriteLine($"filter carrying Selects: {order}");
+
+ Assert.Equal("C,B,A", order);
+ }
+
+ [Fact]
+ public void A_Select_composed_on_the_guarded_handle_leaves_the_chain_unordered()
+ {
+ IQueryable rows = _db.Tickets
+ .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code });
+
+ string order = string.Join(
+ ",",
+ Guard(rows).Select(new List { "Id", "Code" })
+ .ToList(new Filter()).Data.Select(row => row.Code));
+
+ _out.WriteLine($"composed .Select then ToList(new Filter()): {order}");
+
+ Assert.NotEqual("C,B,A", order);
+ }
+ }
+
+ // =============================================================================================
+ // 3. Posture comparison, property by property.
+ // =============================================================================================
+
+ public sealed class QpPostureProbe
+ {
+ private readonly ITestOutputHelper _out;
+
+ public QpPostureProbe(ITestOutputHelper output)
+ {
+ _out = output;
+ PolicyBootstrap.Ensure();
+ }
+
+ private static readonly MethodInfo SamePosture = typeof(DwPolicy)
+ .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ private static readonly MethodInfo SameProviders = typeof(DwPolicy)
+ .GetMethod("SameProviders", BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ private static readonly FieldInfo ProviderTypes = typeof(DwPolicy)
+ .GetField("_providerTypes", BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ private static bool Same(DwPolicyOptions asked, params IDwPolicyProvider[] providers) =>
+ (bool)SamePosture.Invoke(null, new object?[] { DwPolicy.Options, asked, providers })!;
+
+ /// A posture holding exactly what is in force, value by value.
+ private static DwPolicyOptions Copy()
+ {
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ DwPolicyOptions copy = new()
+ {
+ Tier = inForce.Tier,
+ DryRun = inForce.DryRun,
+ IncludeTraceInResult = inForce.IncludeTraceInResult,
+ AuditRefusals = inForce.AuditRefusals,
+ StoreFailure = inForce.StoreFailure,
+ MaxSnapshotAge = inForce.MaxSnapshotAge,
+ RefreshInterval = inForce.RefreshInterval
+ };
+
+ if (!string.IsNullOrEmpty(inForce.HashSalt))
+ {
+ copy.HashSalt = inForce.HashSalt;
+ }
+
+ foreach (PropertyInfo cap in Caps())
+ {
+ if (cap.Name == "MinGroupSize" && !inForce.Caps.IsMinGroupSizeSet)
+ {
+ continue;
+ }
+
+ cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps));
+ }
+
+ foreach (KeyValuePair exposed in inForce.Entities.Entities)
+ {
+ copy.Entities.Expose(exposed.Key, exposed.Value);
+ }
+
+ return copy;
+ }
+
+ private static PropertyInfo[] Caps() => typeof(DwCaps)
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .Where(property => property.CanWrite && property.PropertyType == typeof(int))
+ .OrderBy(property => property.Name)
+ .ToArray();
+
+ // ---- the options, one property at a time ----------------------------------------------------
+
+ [Fact]
+ public void An_identical_posture_is_the_same_posture()
+ {
+ Assert.True(Same(Copy()));
+ }
+
+ [Fact]
+ public void Every_option_the_text_lists_as_compared_really_is()
+ {
+ Check("Tier", o => o.Tier = o.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict);
+ Check("DryRun", o => o.DryRun = !o.DryRun);
+ // Compared by the value that applies: the flag defaults to the tier's own answer, so what
+ // has to be refused is a value answering differently, not a value written down.
+ Check("IncludeTraceInResult", o => o.IncludeTraceInResult =
+ !(o.IncludeTraceInResult ?? o.Tier == DwTier.Convenience));
+ Check("AuditRefusals", o => o.AuditRefusals = !o.AuditRefusals);
+ Check("HashSalt", o => o.HashSalt = "a-salt-long-enough-for-the-minimum");
+ Check("StoreFailure", o => o.StoreFailure =
+ o.StoreFailure == StoreFailureMode.FailClosed ? StoreFailureMode.LastKnownGood : StoreFailureMode.FailClosed);
+ Check("MaxSnapshotAge", o => o.MaxSnapshotAge = o.MaxSnapshotAge + TimeSpan.FromMinutes(1));
+ Check("RefreshInterval", o => o.RefreshInterval = o.RefreshInterval + TimeSpan.FromSeconds(1));
+
+ void Check(string label, Action change)
+ {
+ DwPolicyOptions asked = Copy();
+
+ change(asked);
+
+ bool same = Same(asked);
+
+ _out.WriteLine($"{label,-22} differs -> SamePosture = {same}");
+
+ Assert.False(same);
+ }
+ }
+
+ [Fact]
+ public void The_two_objects_the_text_lists_as_not_compared_really_are_not()
+ {
+ DwPolicyOptions vault = Copy();
+ vault.TokenVault = new QpVault();
+
+ DwPolicyOptions services = Copy();
+ services.Services = new QpServices();
+
+ _out.WriteLine($"TokenVault differs -> SamePosture = {Same(vault)}");
+ _out.WriteLine($"Services differs -> SamePosture = {Same(services)}");
+
+ Assert.True(Same(vault));
+ Assert.True(Same(services));
+ }
+
+ // ---- every cap, one at a time ---------------------------------------------------------------
+
+ [Fact]
+ public void Every_cap_value_is_compared()
+ {
+ PropertyInfo[] caps = Caps();
+
+ _out.WriteLine($"settable int caps on DwCaps: {caps.Length} -> {string.Join(", ", caps.Select(c => c.Name))}");
+
+ foreach (PropertyInfo cap in caps)
+ {
+ DwPolicyOptions asked = Copy();
+
+ int inForce = (int)cap.GetValue(DwPolicy.Options.Caps)!;
+
+ // Every cap setter refuses below one, so move up rather than down.
+ cap.SetValue(asked.Caps, inForce + 1);
+
+ bool same = Same(asked);
+
+ _out.WriteLine($" {cap.Name,-22} {inForce} -> {inForce + 1}: SamePosture = {same}");
+
+ Assert.False(same);
+ }
+ }
+
+ [Fact]
+ public void IsMinGroupSizeSet_is_not_compared()
+ {
+ DwPolicyOptions asked = Copy();
+
+ asked.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize;
+
+ _out.WriteLine(
+ $"in force: MinGroupSize={DwPolicy.Options.Caps.MinGroupSize} set={DwPolicy.Options.Caps.IsMinGroupSizeSet}; "
+ + $"asked: MinGroupSize={asked.Caps.MinGroupSize} set={asked.Caps.IsMinGroupSizeSet}; "
+ + $"SamePosture = {Same(asked)}");
+
+ Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, asked.Caps.IsMinGroupSizeSet);
+ Assert.True(Same(asked));
+ }
+
+ [Fact]
+ public void The_default_MinGroupSize_is_five_and_unset_reads_as_five()
+ {
+ DwCaps caps = new();
+
+ _out.WriteLine($"fresh DwCaps: MinGroupSize={caps.MinGroupSize} IsMinGroupSizeSet={caps.IsMinGroupSizeSet} DefaultMinGroupSize={DwCaps.DefaultMinGroupSize}");
+
+ Assert.Equal(5, caps.MinGroupSize);
+ Assert.False(caps.IsMinGroupSizeSet);
+ Assert.Equal(5, DwCaps.DefaultMinGroupSize);
+ }
+
+ // ---- the catalogue, every name ---------------------------------------------------------------
+
+ [Fact]
+ public void The_catalogue_is_compared_by_every_name_it_answers_to()
+ {
+ MethodInfo sameAs = typeof(DwEntityCatalog)
+ .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ bool Ask(DwEntityCatalog left, DwEntityCatalog right) =>
+ (bool)sameAs.Invoke(left, new object[] { right })!;
+
+ DwEntityCatalog one = new();
+ one.Expose("only");
+
+ DwEntityCatalog same = new();
+ same.Expose("only");
+
+ DwEntityCatalog extraName = new();
+ extraName.Expose("also");
+ extraName.Expose("only");
+
+ DwEntityCatalog otherName = new();
+ otherName.Expose("different");
+
+ DwEntityCatalog extraType = new();
+ extraType.Expose("only");
+ extraType.Expose("second");
+
+ _out.WriteLine($"identical -> {Ask(one, same)}");
+ _out.WriteLine($"Entities reports the same pair, one extra resolvable name -> {Ask(one, extraName)}");
+ _out.WriteLine($" (its Entities count: {one.Entities.Count} vs {extraName.Entities.Count}; "
+ + $"NameOf: {one.NameOf(typeof(QpOnlyHere))} vs {extraName.NameOf(typeof(QpOnlyHere))})");
+ _out.WriteLine($"a different public name -> {Ask(one, otherName)}");
+ _out.WriteLine($"one more type -> {Ask(one, extraType)}");
+
+ Assert.True(Ask(one, same));
+ Assert.Equal(one.Entities.Count, extraName.Entities.Count);
+ Assert.Equal(one.NameOf(typeof(QpOnlyHere)), extraName.NameOf(typeof(QpOnlyHere)));
+ Assert.False(Ask(one, extraName));
+ Assert.False(Ask(one, otherName));
+ Assert.False(Ask(one, extraType));
+ }
+
+ // ---- the providers ----------------------------------------------------------------------------
+
+ [Fact]
+ public void The_provider_rules_hold_type_by_type()
+ {
+ Type[] saved = (Type[])ProviderTypes.GetValue(null)!;
+
+ try
+ {
+ ProviderTypes.SetValue(null, new[] { typeof(QpProviderA), typeof(QpProviderB) });
+
+ bool Ask(params IDwPolicyProvider[] asked) =>
+ (bool)SameProviders.Invoke(null, new object?[] { asked })!;
+
+ bool sameKinds = Ask(new QpProviderA(), new QpProviderB());
+ bool reordered = Ask(new QpProviderB(), new QpProviderA());
+ bool dropped = Ask(new QpProviderA());
+ bool added = Ask(new QpProviderA(), new QpProviderB(), new QpProviderA());
+ bool attributeIgnored = Ask(new AttributePolicyProvider(), new QpProviderA(), new QpProviderB());
+ bool none = Ask();
+
+ _out.WriteLine($"in force = [QpProviderA, QpProviderB]");
+ _out.WriteLine($" same kinds, new instances -> {sameKinds}");
+ _out.WriteLine($" reordered -> {reordered}");
+ _out.WriteLine($" one dropped -> {dropped}");
+ _out.WriteLine($" one added -> {added}");
+ _out.WriteLine($" AttributePolicyProvider prefixed -> {attributeIgnored}");
+ _out.WriteLine($" none supplied -> {none}");
+
+ Assert.True(sameKinds);
+ Assert.False(reordered);
+ Assert.False(dropped);
+ Assert.False(added);
+ Assert.True(attributeIgnored);
+ Assert.False(none);
+ }
+ finally
+ {
+ ProviderTypes.SetValue(null, saved);
+ }
+ }
+
+ [Fact]
+ public void Handing_the_posture_in_force_back_with_a_source_beside_it_is_refused()
+ {
+ InvalidOperationException refusal = Assert.Throws(
+ () => DwPolicy.Configure(DwPolicy.Options, new QpProviderA()));
+
+ _out.WriteLine($"Configure(DwPolicy.Options, provider) -> {refusal.GetType().Name}");
+
+ Assert.Same(DwPolicy.Options, DwPolicy.Options);
+ }
+
+ [Fact]
+ public void Handing_the_posture_in_force_back_alone_is_a_no_op()
+ {
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ DwPolicy.Configure(DwPolicy.Options);
+
+ _out.WriteLine($"Configure(DwPolicy.Options) -> same instance still in force: {ReferenceEquals(inForce, DwPolicy.Options)}");
+
+ Assert.Same(inForce, DwPolicy.Options);
+ }
+
+ private sealed class QpVault : DynamicWhere.ex.Policies.Tokens.IDwTokenVault
+ {
+ public string GetOrCreate(string scope, string value) => value;
+ }
+
+ private sealed class QpServices : IServiceProvider
+ {
+ public object? GetService(Type serviceType) => null;
+ }
+ }
+
+ // =============================================================================================
+ // 4. Clone, branch by branch.
+ // =============================================================================================
+
+ public sealed class QpCloneProbe
+ {
+ private readonly ITestOutputHelper _out;
+
+ public QpCloneProbe(ITestOutputHelper output) => _out = output;
+
+ [Fact]
+ public void Clone_is_public_on_all_three()
+ {
+ foreach (Type type in new[] { typeof(Filter), typeof(Segment), typeof(Summary) })
+ {
+ MethodInfo? clone = type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public);
+
+ _out.WriteLine($"{type.Name}.Clone: {(clone is null ? "not public" : $"public, returns {clone.ReturnType.Name}")}");
+
+ Assert.NotNull(clone);
+ Assert.Equal(type, clone!.ReturnType);
+ }
+ }
+
+ [Fact]
+ public void A_filter_clone_shares_no_branch()
+ {
+ Filter filter = new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } },
+ SubConditionGroups = { new ConditionGroup { Sort = 1, Conditions = { new Condition { Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { "1" } } } } }
+ },
+ Selects = new List { "Id" },
+ Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } },
+ Page = new PageBy { PageNumber = 1, PageSize = 10 }
+ };
+
+ Filter copy = filter.Clone();
+
+ Assert.NotSame(filter.ConditionGroup, copy.ConditionGroup);
+ Assert.NotSame(filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions);
+ Assert.NotSame(filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]);
+ Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]);
+ Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0].Conditions[0], copy.ConditionGroup.SubConditionGroups[0].Conditions[0]);
+ Assert.NotSame(filter.Selects, copy.Selects);
+ Assert.NotSame(filter.Orders, copy.Orders);
+ Assert.NotSame(filter.Orders![0], copy.Orders![0]);
+ Assert.NotSame(filter.Page, copy.Page);
+
+ copy.Page!.PageNumber = 2;
+ copy.ConditionGroup.Conditions[0].Field = "Other";
+ copy.Selects!.Add("Code");
+
+ _out.WriteLine($"filter: original page={filter.Page!.PageNumber} field={filter.ConditionGroup.Conditions[0].Field} selects={filter.Selects!.Count}");
+
+ Assert.Equal(1, filter.Page.PageNumber);
+ Assert.Equal("Code", filter.ConditionGroup.Conditions[0].Field);
+ Assert.Single(filter.Selects);
+ }
+
+ [Fact]
+ public void A_null_branch_stays_null()
+ {
+ Filter filter = new();
+ Filter copy = filter.Clone();
+
+ _out.WriteLine($"filter nulls kept: group={copy.ConditionGroup is null} selects={copy.Selects is null} orders={copy.Orders is null} page={copy.Page is null}");
+
+ Assert.Null(copy.ConditionGroup);
+ Assert.Null(copy.Selects);
+ Assert.Null(copy.Orders);
+ Assert.Null(copy.Page);
+
+ Summary summary = new();
+ Summary summaryCopy = summary.Clone();
+
+ _out.WriteLine($"summary nulls kept: group={summaryCopy.ConditionGroup is null} groupBy={summaryCopy.GroupBy is null} having={summaryCopy.Having is null} orders={summaryCopy.Orders is null} page={summaryCopy.Page is null}");
+
+ Assert.Null(summaryCopy.ConditionGroup);
+ Assert.Null(summaryCopy.GroupBy);
+ Assert.Null(summaryCopy.Having);
+ Assert.Null(summaryCopy.Orders);
+ Assert.Null(summaryCopy.Page);
+
+ Segment segment = new() { Orders = null };
+ Segment segmentCopy = segment.Clone();
+
+ _out.WriteLine($"segment nulls kept: selects={segmentCopy.Selects is null} orders={segmentCopy.Orders is null} page={segmentCopy.Page is null}");
+
+ Assert.Null(segmentCopy.Selects);
+ Assert.Null(segmentCopy.Orders);
+ Assert.Null(segmentCopy.Page);
+ }
+
+ [Fact]
+ public void A_segment_clone_gives_each_set_its_own_condition_group()
+ {
+ ConditionGroup shared = new()
+ {
+ Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } }
+ };
+
+ Segment segment = new()
+ {
+ ConditionSets =
+ {
+ new ConditionSet { Sort = 1, ConditionGroup = shared },
+ new ConditionSet { Sort = 2, Intersection = Intersection.Union, ConditionGroup = shared }
+ },
+ Selects = new List { "Id" },
+ Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } },
+ Page = new PageBy { PageNumber = 1, PageSize = 10 }
+ };
+
+ Segment copy = segment.Clone();
+
+ _out.WriteLine(
+ "segment: sets share one group before the clone: "
+ + $"{ReferenceEquals(segment.ConditionSets[0].ConditionGroup, segment.ConditionSets[1].ConditionGroup)}; after: "
+ + $"{ReferenceEquals(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup)}");
+
+ Assert.NotSame(segment.ConditionSets, copy.ConditionSets);
+ Assert.NotSame(segment.ConditionSets[0], copy.ConditionSets[0]);
+ Assert.NotSame(segment.ConditionSets[0].ConditionGroup, copy.ConditionSets[0].ConditionGroup);
+ Assert.NotSame(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup);
+ Assert.NotSame(segment.Selects, copy.Selects);
+ Assert.NotSame(segment.Orders, copy.Orders);
+ Assert.NotSame(segment.Orders![0], copy.Orders![0]);
+ Assert.NotSame(segment.Page, copy.Page);
+ }
+
+ [Fact]
+ public void A_summary_clone_reaches_the_group_by_the_aggregates_and_the_having()
+ {
+ Summary summary = new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } }
+ },
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Code" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ },
+ Having = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "N", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } }
+ },
+ Orders = new List { new() { Field = "N", Direction = Direction.Descending } },
+ Page = new PageBy { PageNumber = 1, PageSize = 10 }
+ };
+
+ Summary copy = summary.Clone();
+
+ Assert.NotSame(summary.ConditionGroup, copy.ConditionGroup);
+ Assert.NotSame(summary.GroupBy, copy.GroupBy);
+ Assert.NotSame(summary.GroupBy!.Fields, copy.GroupBy!.Fields);
+ Assert.NotSame(summary.GroupBy.AggregateBy, copy.GroupBy.AggregateBy);
+ Assert.NotSame(summary.GroupBy.AggregateBy[0], copy.GroupBy.AggregateBy[0]);
+ Assert.NotSame(summary.Having, copy.Having);
+ Assert.NotSame(summary.Having!.Conditions[0], copy.Having!.Conditions[0]);
+ Assert.NotSame(summary.Orders, copy.Orders);
+ Assert.NotSame(summary.Orders![0], copy.Orders![0]);
+ Assert.NotSame(summary.Page, copy.Page);
+
+ copy.GroupBy.AggregateBy[0].Alias = "Changed";
+ copy.Having.Conditions[0].Field = "Changed";
+
+ _out.WriteLine($"summary: original alias={summary.GroupBy.AggregateBy[0].Alias} having field={summary.Having.Conditions[0].Field}");
+
+ Assert.Equal("N", summary.GroupBy.AggregateBy[0].Alias);
+ Assert.Equal("N", summary.Having.Conditions[0].Field);
+ }
+
+ [Fact]
+ public void A_segment_whose_ConditionSets_is_null_is_what_Clone_gives_back()
+ {
+ Segment segment = new() { ConditionSets = null! };
+
+ Segment copy = segment.Clone();
+
+ _out.WriteLine($"Segment.Clone with null ConditionSets -> {(copy.ConditionSets is null ? "null" : "empty list")}");
+
+ Assert.Null(copy.ConditionSets);
+ }
+ }
+}
diff --git a/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs
new file mode 100644
index 0000000..a3bb9d8
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs
@@ -0,0 +1,251 @@
+using System.Reflection;
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.DTOs;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ // =============================================================================================
+ // What the entity row of the source table is actually read from, plus the group floor.
+ // =============================================================================================
+
+ public sealed class QpDerivedAndFloorProbe : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _zyConnection;
+ private readonly ZyContext _zy;
+ private readonly SqliteConnection _qpConnection;
+ private readonly QpContext _qp;
+
+ public QpDerivedAndFloorProbe(ITestOutputHelper output)
+ {
+ _out = output;
+
+ _zyConnection = new SqliteConnection("DataSource=:memory:");
+ _zyConnection.Open();
+ _zy = new ZyContext(_zyConnection);
+ _zy.Database.EnsureCreated();
+ _zy.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin" } });
+ _zy.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" });
+ _zy.SaveChanges();
+ _zy.ChangeTracker.Clear();
+
+ _qpConnection = new SqliteConnection("DataSource=:memory:");
+ _qpConnection.Open();
+ _qp = new QpContext(_qpConnection);
+ _qp.Database.EnsureCreated();
+
+ foreach (string code in new[] { "a", "b", "c" })
+ {
+ _qp.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() });
+ }
+
+ _qp.SaveChanges();
+ _qp.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _zy.Dispose();
+ _zyConnection.Dispose();
+ _qp.Dispose();
+ _qpConnection.Dispose();
+ }
+
+ private static bool? Ask(IQueryable source, string path)
+ {
+ object shape = typeof(RowShape)
+ .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!
+ .MakeGenericMethod(source.ElementType)
+ .Invoke(null, new object[] { source })!;
+
+ return (bool?)typeof(RowShape)
+ .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(shape, new object[] { path });
+ }
+
+ private static string Show(bool? answer) => answer is null ? "null (left alone)" : answer.ToString()!;
+
+ private static PolicyQueryable Guard(IQueryable source, DwPolicyOptions options) where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ options,
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ ///
+ /// The one claim two tables disagree on: whether a derived type's columns count as members
+ /// the queried type can produce.
+ ///
+ [Fact]
+ public void The_entity_row_reads_the_queried_types_own_model_not_a_derived_types()
+ {
+ bool derivedMaps = _zy.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence") is not null;
+ bool baseMaps = _zy.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence") is not null;
+
+ bool? throughBase = Ask(_zy.Parties, "Licence");
+ bool? throughDerived = Ask(_zy.Set(), "Licence");
+
+ _out.WriteLine($"model maps Licence: on ZyMerchant = {derivedMaps}, on ZyParty = {baseMaps}");
+ _out.WriteLine($"Expresses(\"Licence\") on DbSet = {Show(throughBase)}");
+ _out.WriteLine($"Expresses(\"Licence\") on Set() = {Show(throughDerived)}");
+
+ Assert.True(derivedMaps);
+ Assert.False(baseMaps);
+ Assert.False(throughBase);
+ Assert.True(throughDerived);
+ }
+
+ /// The projected rows of the table, read straight off the shape.
+ [Fact]
+ public void The_projected_rows_of_the_table()
+ {
+ IQueryable built = _zy.Roles.Select(role => new ZyRoleRow
+ {
+ Id = role.Id,
+ Code = role.Code,
+ Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En }
+ });
+
+ IQueryable copied =
+ _zy.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name });
+
+ IQueryable another = new ZyOwnProvider(
+ new[] { new ZyRole { Id = 1, Code = "admin", Name = new ZyLocalizedText() } }
+ .AsQueryable()
+ .Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = new ZyLocalizedText { En = role.Name.En } }));
+
+ _out.WriteLine($"a Select that builds the row Expresses(\"Name.IsEmpty\") = {Show(Ask(built, "Name.IsEmpty"))}");
+ _out.WriteLine($" ... and what it does assign Expresses(\"Name.En\") = {Show(Ask(built, "Name.En"))}");
+ _out.WriteLine($"a Select that copies the member Expresses(\"Name.IsEmpty\") = {Show(Ask(copied, "Name.IsEmpty"))}");
+ _out.WriteLine($"a projection another provider ran Expresses(\"Name.IsEmpty\") = {Show(Ask(another, "Name.IsEmpty"))}");
+ _out.WriteLine($"an entity, an owned type's getter Expresses(\"Name.IsEmpty\") = {Show(Ask(_zy.Roles, "Name.IsEmpty"))}");
+
+ Assert.False(Ask(built, "Name.IsEmpty"));
+ Assert.True(Ask(built, "Name.En"));
+ Assert.False(Ask(copied, "Name.IsEmpty"));
+ Assert.Null(Ask(another, "Name.IsEmpty"));
+ Assert.False(Ask(_zy.Roles, "Name.IsEmpty"));
+ }
+
+ /// The exact wording the docs quote for the trace.
+ [Fact]
+ public void The_trace_reason_is_worded_as_the_docs_quote_it()
+ {
+ PolicyQueryable guarded = Guard(_zy.Roles, new DwPolicyOptions { Tier = DwTier.Strict });
+
+ Assert.ThrowsAny(() => guarded.ToList(new Filter
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } }
+ }
+ }));
+
+ string reason = guarded.LastTrace!.Decisions
+ .Select(decision => decision.Reason)
+ .First(text => text is not null && text.Contains("cannot compute"))!;
+
+ _out.WriteLine($"trace reason: {reason}");
+
+ Assert.Equal(
+ "the member exists on the type and the query cannot compute it, so it is refused as an unknown name is",
+ reason);
+ }
+
+ /// LastTrace lands on the handle the method was called on, not the one it returns.
+ [Fact]
+ public void LastTrace_lands_on_the_handle_the_method_was_called_on()
+ {
+ PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Strict });
+
+ PolicyQueryable returned = guarded.Where(new Condition
+ {
+ Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" }
+ });
+
+ _out.WriteLine(
+ $"called-on handle LastTrace = {(guarded.LastTrace is null ? "null" : "set")}; "
+ + $"returned handle LastTrace = {(returned.LastTrace is null ? "null" : "set")}");
+
+ Assert.NotNull(guarded.LastTrace);
+ Assert.Null(returned.LastTrace);
+ }
+
+ /// The floor the three pages now lead with: on at 5, silent, and never unguarded.
+ [Fact]
+ public void The_group_floor_is_on_at_five_and_silent()
+ {
+ static Summary Ask() => new()
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Code" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ }
+ };
+
+ SummaryResult unguarded = _qp.Tickets.ToList(Ask());
+
+ SummaryResult floored = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }).ToList(Ask());
+
+ SummaryResult unfloored = Guard(
+ _qp.Tickets,
+ new DwPolicyOptions { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }).ToList(Ask());
+
+ _out.WriteLine(
+ $"3 groups of 1 row each -> unguarded={unguarded.Data.Count} "
+ + $"guarded(default)={floored.Data.Count} guarded(MinGroupSize=1)={unfloored.Data.Count}");
+ _out.WriteLine($"guarded(default): TotalCount={floored.TotalCount}, trace on result={(floored.Policy is null ? "null" : "set")}");
+
+ Assert.Equal(3, unguarded.Data.Count);
+ Assert.Empty(floored.Data);
+ Assert.Equal(3, unfloored.Data.Count);
+ }
+
+ ///
+ /// Whether the composable pair is floored too, which decides whether a page describing them
+ /// without the floor is a gap.
+ ///
+ [Fact]
+ public void The_composable_Group_and_Summary_are_floored_the_same_way()
+ {
+ static GroupBy Grouping() => new()
+ {
+ Fields = new List { "Code" },
+ AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } }
+ };
+
+ static Summary Asked() => new() { GroupBy = Grouping() };
+
+ int unguardedGroup = _qp.Tickets.Group(Grouping()).Cast().Count();
+
+ PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience });
+ int guardedGroup = guarded.Group(Grouping()).Cast().Count();
+
+ int unguardedSummary = _qp.Tickets.Summary(Asked()).Cast().Count();
+
+ PolicyQueryable guardedTwo = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience });
+ int guardedSummary = guardedTwo.Summary(Asked()).Cast().Count();
+
+ _out.WriteLine($"composable .Group -> unguarded={unguardedGroup} guarded(default floor)={guardedGroup}");
+ _out.WriteLine($"composable .Summary -> unguarded={unguardedSummary} guarded(default floor)={guardedSummary}");
+
+ Assert.Equal(3, unguardedGroup);
+ Assert.Equal(3, unguardedSummary);
+ Assert.Equal(0, guardedGroup);
+ Assert.Equal(0, guardedSummary);
+ }
+ }
+}
diff --git a/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs
new file mode 100644
index 0000000..95ef98f
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs
@@ -0,0 +1,336 @@
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ // ---- mapping shapes the strict check now walks -------------------------------------------------------
+
+ public class MpDoc
+ {
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+
+ /// Owned, stored as JSON on EF Core 8.
+ public MpMeta Meta { get; set; } = new();
+
+ /// An owned collection.
+ public List Lines { get; set; } = new();
+
+ /// A primitive collection on EF Core 8.
+ public List Tags { get; set; } = new();
+ }
+
+ public class MpMeta
+ {
+ public string Author { get; set; } = string.Empty;
+
+ public int Revision { get; set; }
+
+ /// A getter, not a column.
+ public bool IsDraft => Revision == 0;
+ }
+
+ public class MpLine
+ {
+ public int Id { get; set; }
+
+ public string Sku { get; set; } = string.Empty;
+
+ public int Qty { get; set; }
+ }
+
+ /// TPT: each type has a table of its own.
+ public class MpAnimal
+ {
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+ }
+
+ public class MpDog : MpAnimal
+ {
+ public string? Breed { get; set; }
+ }
+
+ /// Table splitting: two types share one table.
+ public class MpAccount
+ {
+ public int Id { get; set; }
+
+ public string Number { get; set; } = string.Empty;
+
+ public MpAccountDetail Detail { get; set; } = null!;
+ }
+
+ public class MpAccountDetail
+ {
+ public int Id { get; set; }
+
+ public string Iban { get; set; } = string.Empty;
+
+ public MpAccount Account { get; set; } = null!;
+ }
+
+ /// Keyless, read through a raw query.
+ public class MpTally
+ {
+ public string Code { get; set; } = string.Empty;
+
+ public int Total { get; set; }
+ }
+
+ public sealed class MpContext : DbContext
+ {
+ private readonly SqliteConnection _connection;
+
+ public MpContext(SqliteConnection connection) => _connection = connection;
+
+ public DbSet Docs => Set();
+
+ public DbSet Animals => Set();
+
+ public DbSet Dogs => Set();
+
+ public DbSet Accounts => Set();
+
+ public DbSet Details => Set();
+
+ public DbSet Tallies => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection);
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity().OwnsOne(doc => doc.Meta, meta => meta.ToJson());
+ model.Entity().OwnsMany(doc => doc.Lines, lines => lines.HasKey(line => line.Id));
+
+ model.Entity().ToTable("Animals");
+ model.Entity().ToTable("Dogs");
+
+ model.Entity().ToTable("Accounts");
+ model.Entity().ToTable("Accounts");
+ model.Entity().HasOne(detail => detail.Account)
+ .WithOne(account => account.Detail)
+ .HasForeignKey(detail => detail.Id);
+
+ model.Entity().HasNoKey().ToView(null);
+ }
+ }
+
+ ///
+ /// The mapping shapes RowShape.Expresses now walks, each run guarded and unguarded.
+ ///
+ public sealed class ReviewMappingProbes : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly MpContext _db;
+
+ public ReviewMappingProbes(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new MpContext(_connection);
+ _db.Database.EnsureCreated();
+
+ _db.Docs.Add(new MpDoc
+ {
+ Code = "D-1",
+ Meta = new MpMeta { Author = "sajjad", Revision = 2 },
+ Lines = { new MpLine { Id = 1, Sku = "S-1", Qty = 3 } },
+ Tags = { "red", "blue" }
+ });
+
+ _db.Dogs.Add(new MpDog { Name = "Rex", Breed = "husky" });
+ _db.Accounts.Add(new MpAccount { Number = "N-1", Detail = new MpAccountDetail { Iban = "IQ-1" } });
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source) where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ new DwPolicyOptions { Tier = DwTier.Strict },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ private static Filter Where(string field, string value, DataType type = DataType.Text) => new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ }
+ };
+
+ private string Probe(IQueryable source, Filter filter) where T : class
+ {
+ try
+ {
+ FilterResult result = Guard(source).ToList(filter);
+
+ return $"ran, {result.Data.Count} row(s)";
+ }
+ catch (PolicyException refusal)
+ {
+ return $"REFUSED {refusal.ErrorCode}";
+ }
+ catch (Exception other)
+ {
+ return $"threw {other.GetType().Name}";
+ }
+ }
+
+ private string Unguarded(Func> run)
+ {
+ try
+ {
+ return $"ran, {run().Count()} row(s)";
+ }
+ catch (Exception other)
+ {
+ return $"threw {other.GetType().Name}";
+ }
+ }
+
+ [Fact]
+ public void A_member_of_a_JSON_owned_type_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author == "sajjad"));
+ string guarded = Probe(_db.Docs, Where("Meta.Author", "sajjad"));
+
+ _out.WriteLine($"JSON member unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_getter_on_a_JSON_owned_type_matches_the_unguarded_failure()
+ {
+ string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.IsDraft));
+ string guarded = Probe(_db.Docs, Where("Meta.IsDraft", "false", DataType.Boolean));
+
+ _out.WriteLine($"JSON getter unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void A_member_of_an_owned_collection_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Lines.Any(line => line.Sku == "S-1")));
+ string guarded = Probe(_db.Docs, Where("Lines.Sku", "S-1"));
+
+ _out.WriteLine($"owned collection unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_primitive_collection_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Tags.Contains("red")));
+ string guarded = Probe(_db.Docs, new Filter
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions =
+ {
+ new Condition
+ {
+ Field = "Tags", DataType = DataType.Text,
+ Operator = Operator.Contains, Values = { "red" }
+ }
+ }
+ }
+ });
+
+ _out.WriteLine($"primitive collection unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void A_TPT_subtype_column_still_filters_through_its_own_set()
+ {
+ string unguarded = Unguarded(() => _db.Dogs.Where(dog => dog.Breed == "husky"));
+ string guarded = Probe(_db.Dogs, Where("Breed", "husky"));
+
+ _out.WriteLine($"TPT own set unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_TPT_subtype_column_through_OfType_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Animals.OfType().Where(dog => dog.Breed == "husky"));
+ string guarded = Probe(_db.Animals.OfType(), Where("Breed", "husky"));
+
+ _out.WriteLine($"TPT OfType unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_table_split_sibling_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Accounts.Where(account => account.Detail.Iban == "IQ-1"));
+ string guarded = Probe(_db.Accounts, Where("Detail.Iban", "IQ-1"));
+
+ _out.WriteLine($"table splitting unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_keyless_type_read_through_FromSql_still_filters()
+ {
+ IQueryable rows = _db.Tallies.FromSqlRaw("SELECT \"Code\" AS \"Code\", 1 AS \"Total\" FROM \"Docs\"");
+
+ string unguarded = Unguarded(() => rows.Where(tally => tally.Code == "D-1"));
+ string guarded = Probe(rows, Where("Code", "D-1"));
+
+ _out.WriteLine($"FromSql keyless unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_split_query_still_filters()
+ {
+ IQueryable rows = _db.Docs.Include(doc => doc.Lines).AsSplitQuery();
+
+ string unguarded = Unguarded(() => rows.Where(doc => doc.Code == "D-1"));
+ string guarded = Probe(rows, Where("Code", "D-1"));
+
+ _out.WriteLine($"split query unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_string_length_over_a_JSON_member_still_filters()
+ {
+ string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author.Length == 6));
+ string guarded = Probe(_db.Docs, Where("Meta.Author.Length", "6", DataType.Number));
+
+ _out.WriteLine($"JSON member length unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+ }
+}
diff --git a/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs
new file mode 100644
index 0000000..2dd78ac
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs
@@ -0,0 +1,875 @@
+using System.Linq.Expressions;
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Policies.Attributes;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.DTOs;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ // ---- model -------------------------------------------------------------------------------------------
+
+ public class RbText
+ {
+ public string Ar { get; set; } = string.Empty;
+
+ public string En { get; set; } = string.Empty;
+
+ /// A getter over two columns: no database can answer it.
+ public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En);
+ }
+
+ public class RbCustomer
+ {
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public RbText Title { get; set; } = new();
+
+ public List Orders { get; set; } = new();
+
+ /// Unmapped getter over mapped columns.
+ public string Display => $"{Name}#{Id}";
+ }
+
+ public class RbOrder
+ {
+ public int Id { get; set; }
+
+ /// An alias whose target is a member the model does not map.
+ [DwAlias("ticket")]
+ public string AliasedLabel => $"{Code}!";
+
+ /// An alias over a mapped column, for the control case.
+ [DwAlias("ref")]
+ public string Reference { get; set; } = string.Empty;
+
+ /// Denied outright, so a Selects naming it must still be refused.
+ [DwDenied]
+ public string Secret { get; set; } = string.Empty;
+
+ public int CustomerId { get; set; }
+
+ public RbCustomer Customer { get; set; } = null!;
+
+ public string Code { get; set; } = string.Empty;
+
+ public DateTime PlacedAt { get; set; }
+
+ public DateTime? ShippedAt { get; set; }
+
+ public decimal Amount { get; set; }
+
+ /// Mapped with a computed column.
+ public decimal Doubled { get; set; }
+
+ /// Unmapped getter, ignored in the model.
+ public string Label => $"{Code}/{Id}";
+ }
+
+ /// TPH.
+ public class RbParty
+ {
+ public int Id { get; set; }
+
+ public string Kind { get; set; } = string.Empty;
+ }
+
+ public class RbVendor : RbParty
+ {
+ public string? Vat { get; set; }
+ }
+
+ // ---- rows a caller projects --------------------------------------------------------------------------
+
+ public class RbRow
+ {
+ public RbRow()
+ {
+ }
+
+ public RbRow(int id) => Id = id;
+
+ public int Id { get; set; }
+
+ public string Code { get; set; } = string.Empty;
+
+ public RbText Title { get; set; } = new();
+
+ public string Tag { get; set; } = string.Empty;
+
+ public RbCustomer? Customer { get; set; }
+ }
+
+ public sealed class RbContext : DbContext
+ {
+ private readonly SqliteConnection _connection;
+
+ public RbContext(SqliteConnection connection) => _connection = connection;
+
+ public DbSet Customers => Set();
+
+ public DbSet Orders => Set();
+
+ public DbSet Parties => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection);
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity().OwnsOne(customer => customer.Title);
+ model.Entity().Ignore(customer => customer.Display);
+
+ model.Entity().Ignore(order => order.Label);
+ model.Entity().Ignore(order => order.AliasedLabel);
+ model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2");
+ model.Entity().Property("Tenant");
+
+ model.Entity().HasDiscriminator("Discriminator")
+ .HasValue("party")
+ .HasValue("vendor");
+ }
+ }
+
+ ///
+ /// A provider layered over EF Core, the way LinqKit's AsExpandable and DelegateDecompiler's
+ /// Decompile are: the expression still carries EF Core's query root, and the provider
+ /// rewrites what EF Core alone could not translate before handing it on.
+ ///
+ public sealed class RbDecompilingProvider : IQueryable, IQueryProvider
+ {
+ private readonly IQueryProvider _inner;
+
+ public RbDecompilingProvider(IQueryable inner)
+ : this(inner.Provider, inner.Expression)
+ {
+ }
+
+ public RbDecompilingProvider(IQueryProvider inner, Expression expression)
+ {
+ _inner = inner;
+ Expression = expression;
+ }
+
+ public Type ElementType => typeof(T);
+
+ public Expression Expression { get; }
+
+ public IQueryProvider Provider => this;
+
+ public IEnumerator GetEnumerator() =>
+ _inner.CreateQuery(Rewritten(Expression)).GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+
+ // Composition stays wrapped, as LinqKit's ExpandableQuery does: the expansion happens once,
+ // at execution, over the whole expression the caller built.
+ public IQueryable CreateQuery(Expression expression) =>
+ (IQueryable)Activator.CreateInstance(
+ typeof(RbDecompilingProvider<>).MakeGenericType(
+ expression.Type.GetGenericArguments().Length == 1
+ ? expression.Type.GetGenericArguments()[0]
+ : typeof(object)),
+ new object[] { _inner, expression },
+ null)!;
+
+ public IQueryable CreateQuery(Expression expression) =>
+ new RbDecompilingProvider(_inner, expression);
+
+ public object? Execute(Expression expression) => _inner.Execute(Rewritten(expression));
+
+ public TResult Execute(Expression expression) => _inner.Execute(Rewritten(expression));
+
+ /// Expands the getters EF Core cannot translate into the columns behind them.
+ private static Expression Rewritten(Expression expression) => new RbDecompiler().Visit(expression);
+ }
+
+ /// Replaces Label and Display with the expression their getter computes.
+ public sealed class RbDecompiler : ExpressionVisitor
+ {
+ protected override Expression VisitMember(MemberExpression node)
+ {
+ if (node.Member.Name == "Label" && node.Member.DeclaringType == typeof(RbOrder))
+ {
+ Expression order = Visit(node.Expression)!;
+
+ return Expression.Call(
+ typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!,
+ Expression.Property(order, nameof(RbOrder.Code)),
+ Expression.Constant("/"));
+ }
+
+ if (node.Member.Name == "Display" && node.Member.DeclaringType == typeof(RbCustomer))
+ {
+ Expression customer = Visit(node.Expression)!;
+
+ return Expression.Property(customer, nameof(RbCustomer.Name));
+ }
+
+ return base.VisitMember(node);
+ }
+ }
+
+ public sealed class ReviewOverBlockProbes : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly RbContext _db;
+
+ public ReviewOverBlockProbes(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new RbContext(_connection);
+ _db.Database.EnsureCreated();
+
+ RbCustomer customer = new() { Name = "Acme", Title = new RbText { Ar = "AR", En = "EN" } };
+
+ RbOrder order = new()
+ {
+ Customer = customer,
+ Code = "AB123",
+ PlacedAt = new DateTime(2024, 3, 9, 14, 30, 0),
+ ShippedAt = new DateTime(2024, 3, 11, 9, 0, 0),
+ Amount = 10m,
+ Reference = "R-1",
+ Secret = "s"
+ };
+
+ _db.Customers.Add(customer);
+ _db.Orders.Add(order);
+ _db.Entry(order).Property("Tenant").CurrentValue = "t1";
+ _db.Parties.Add(new RbVendor { Kind = "vendor", Vat = "V-1" });
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict)
+ where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ new DwPolicyOptions { Tier = tier },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ private static Filter Where(string field, string value, DataType type = DataType.Text) => new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ }
+ };
+
+ private static ConditionGroup Group(string field, string value, DataType type = DataType.Text) => new()
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ };
+
+ /// Runs a guarded filter and says what happened, for the probe log.
+ private string Probe(IQueryable source, Filter filter) where T : class
+ {
+ try
+ {
+ FilterResult result = Guard(source).ToList(filter);
+
+ return $"ran, {result.Data.Count} row(s)";
+ }
+ catch (PolicyException refusal)
+ {
+ return $"REFUSED {refusal.ErrorCode}";
+ }
+ catch (Exception other)
+ {
+ return $"threw {other.GetType().Name}";
+ }
+ }
+
+ private string Unguarded(Func> run)
+ {
+ try
+ {
+ return $"ran, {run().Count()} row(s)";
+ }
+ catch (Exception other)
+ {
+ return $"threw {other.GetType().Name}";
+ }
+ }
+
+ // ---- framework members the provider translates, on every clause ----------------------------------
+
+ [Theory]
+ [InlineData("Code.Length", "5", DataType.Number)]
+ [InlineData("PlacedAt.Year", "2024", DataType.Number)]
+ [InlineData("PlacedAt.Month", "3", DataType.Number)]
+ [InlineData("PlacedAt.Date", "2024-03-09", DataType.Date)]
+ [InlineData("ShippedAt.Value.Year", "2024", DataType.Number)]
+ [InlineData("Doubled", "20", DataType.Number)]
+ [InlineData("Customer.Name", "Acme", DataType.Text)]
+ [InlineData("Customer.Title.En", "EN", DataType.Text)]
+ public void A_translatable_member_still_filters(string field, string value, DataType type)
+ {
+ string guarded = Probe(_db.Orders, Where(field, value, type));
+
+ _out.WriteLine($"where {field}: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_shadow_property_is_refused_on_both_versions()
+ {
+ // Ruled out, not a finding. A shadow property has no CLR member, so the path never
+ // resolved: 3.2.0 refuses it with the same code, before Expresses existed.
+ string guarded = Probe(_db.Orders, Where("Tenant", "t1"));
+
+ _out.WriteLine($"shadow property: {guarded}");
+
+ Assert.StartsWith("REFUSED", guarded);
+ }
+
+ [Theory]
+ [InlineData("Code.Length")]
+ [InlineData("PlacedAt.Year")]
+ [InlineData("Customer.Name")]
+ public void A_translatable_member_still_orders(string field)
+ {
+ PolicyQueryable guarded = Guard(_db.Orders);
+
+ Exception? failed = Record.Exception(() => guarded.Order(new OrderBy { Field = field, Direction = Direction.Ascending }));
+
+ _out.WriteLine($"order {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}");
+
+ Assert.Null(failed);
+ }
+
+ [Theory]
+ [InlineData("Code.Length")]
+ [InlineData("PlacedAt.Year")]
+ public void A_translatable_member_still_groups(string field)
+ {
+ Exception? failed = Record.Exception(() => Guard(_db.Orders).Group(new GroupBy
+ {
+ Fields = new List { field },
+ AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } }
+ }));
+
+ _out.WriteLine($"group {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}");
+
+ Assert.Null(failed);
+ }
+
+ [Fact]
+ public void A_summary_over_an_entity_still_aggregates_a_navigation_path()
+ {
+ Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Customer.Name" },
+ AggregateBy = new List { new() { Alias = "Total", Field = "Id", Aggregator = Aggregator.Sumation } }
+ }
+ }));
+
+ _out.WriteLine($"summary: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.InnerException?.GetType().Name} :: {failed?.InnerException?.Message}");
+
+ Assert.Null(failed);
+ }
+
+ // ---- hierarchies ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void OfType_reaches_the_subtype_s_own_column()
+ {
+ string unguarded = Unguarded(() => _db.Parties.OfType().Where(vendor => vendor.Vat == "V-1"));
+ string guarded = Probe(_db.Parties.OfType(), Where("Vat", "V-1"));
+
+ _out.WriteLine($"OfType unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void Cast_reaches_the_subtype_s_own_column()
+ {
+ string guarded = Probe(_db.Set().Cast(), Where("Vat", "V-1"));
+
+ _out.WriteLine($"Cast guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ // ---- projections ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void An_anonymous_projection_still_filters()
+ {
+ var rows = _db.Orders.Select(order => new { order.Id, order.Code, Title = order.Customer.Title });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123"));
+
+ _out.WriteLine($"anon unguarded: {unguarded}");
+
+ // The guarded handle needs a class, so the same shape is probed through a named row.
+ Assert.StartsWith("ran", unguarded);
+ }
+
+ [Fact]
+ public void A_constructor_with_arguments_plus_an_initializer_still_filters()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow(order.Id) { Code = order.Code });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123"));
+ string guarded = Probe(rows, Where("Code", "AB123"));
+
+ _out.WriteLine($"ctor+init unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_member_a_projection_computes_still_filters()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow
+ {
+ Id = order.Id,
+ Tag = order.Code.Substring(0, 2)
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB"));
+ string guarded = Probe(rows, Where("Tag", "AB"));
+
+ _out.WriteLine($"computed member unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_member_a_projection_assigns_conditionally_still_filters()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow
+ {
+ Id = order.Id,
+ Tag = order.Amount > 5m ? order.Code : "none"
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123"));
+ string guarded = Probe(rows, Where("Tag", "AB123"));
+
+ _out.WriteLine($"conditional member unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_length_beneath_a_member_a_projection_copies_still_filters()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Code.Length == 5));
+ string guarded = Probe(rows, Where("Code.Length", "5", DataType.Number));
+
+ _out.WriteLine($"copied.Length unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_length_beneath_a_member_a_projection_builds_still_filters()
+ {
+ IQueryable rows = _db.Customers.Select(customer => new RbRow
+ {
+ Id = customer.Id,
+ Title = new RbText { Ar = customer.Title.Ar, En = customer.Title.En }
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Title.En.Length == 2));
+ string guarded = Probe(rows, Where("Title.En.Length", "2", DataType.Number));
+
+ _out.WriteLine($"built.En.Length unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_projection_of_a_navigation_still_filters()
+ {
+ IQueryable rows = _db.Orders.Select(order => order.Customer);
+
+ string unguarded = Unguarded(() => rows.Where(customer => customer.Name == "Acme"));
+ string guarded = Probe(rows, Where("Name", "Acme"));
+
+ _out.WriteLine($"Select(nav) unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void Two_selects_still_filter()
+ {
+ IQueryable rows = _db.Orders
+ .Select(order => new RbRow { Id = order.Id, Code = order.Code })
+ .Select(row => new RbRow { Id = row.Id, Tag = row.Code });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123"));
+ string guarded = Probe(rows, Where("Tag", "AB123"));
+
+ _out.WriteLine($"two selects unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_selectmany_still_filters()
+ {
+ IQueryable rows = _db.Customers.SelectMany(customer => customer.Orders);
+
+ string unguarded = Unguarded(() => rows.Where(order => order.Code == "AB123"));
+ string guarded = Probe(rows, Where("Code", "AB123"));
+
+ _out.WriteLine($"SelectMany unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_join_still_filters()
+ {
+ IQueryable rows = _db.Orders.Join(
+ _db.Customers,
+ order => order.CustomerId,
+ customer => customer.Id,
+ (order, customer) => new RbRow { Id = order.Id, Code = order.Code, Tag = customer.Name });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme"));
+ string guarded = Probe(rows, Where("Tag", "Acme"));
+
+ _out.WriteLine($"Join unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_let_clause_still_filters()
+ {
+ IQueryable rows =
+ from order in _db.Orders
+ let tag = order.Code
+ select new RbRow { Id = order.Id, Tag = tag };
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123"));
+ string guarded = Probe(rows, Where("Tag", "AB123"));
+
+ _out.WriteLine($"let unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_groupby_projection_still_filters()
+ {
+ IQueryable rows = _db.Orders
+ .GroupBy(order => order.CustomerId)
+ .Select(group => new RbRow { Id = group.Key, Tag = group.Count().ToString() });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Id == 1));
+ string guarded = Probe(rows, Where("Id", "1", DataType.Number));
+
+ _out.WriteLine($"GroupBy unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_projection_that_copies_an_owned_member_still_filters_beneath_it()
+ {
+ IQueryable rows = _db.Customers.Select(customer => new RbRow
+ {
+ Id = customer.Id,
+ Title = customer.Title
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Title.En == "EN"));
+ string guarded = Probe(rows, Where("Title.En", "EN"));
+
+ _out.WriteLine($"copied owned unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ // ---- the wrapping provider -----------------------------------------------------------------------
+
+ [Fact]
+ public void A_decompiling_provider_over_EF_Core_is_not_held_to_EF_Core_s_model()
+ {
+ IQueryable rows = new RbDecompilingProvider(_db.Orders);
+
+ string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/"));
+ string guarded = Probe(rows, Where("Label", "AB123/"));
+
+ _out.WriteLine($"decompiling provider unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", unguarded);
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_decompiling_provider_over_a_navigation_getter_is_not_held_to_EF_Core_s_model()
+ {
+ IQueryable rows = new RbDecompilingProvider(_db.Customers);
+
+ string unguarded = Unguarded(() => rows.Where(customer => customer.Display == "Acme"));
+ string guarded = Probe(rows, Where("Display", "Acme"));
+
+ _out.WriteLine($"decompiling nav unguarded: {unguarded} | guarded: {guarded}");
+
+ Assert.StartsWith("ran", unguarded);
+ Assert.StartsWith("ran", guarded);
+ }
+
+
+ [Fact]
+ public void The_same_wrapping_provider_is_answered_for_the_same_way_over_either_kind_of_row()
+ {
+ // Both branches ask the same question — is EF Core's own provider the one translating
+ // this? — so the same provider over a projection and over an entity gets the same
+ // answer. Until 3.3.0's review only the projection branch asked.
+ IQueryable rows = new RbDecompilingProvider(
+ _db.Customers.Select(customer => new RbRow { Id = customer.Id, Tag = customer.Name }));
+
+ string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number));
+
+ _out.WriteLine($"wrapper over a projection: {guarded}");
+
+ IQueryable entities = new RbDecompilingProvider(_db.Orders);
+
+ string overEntity = Probe(entities, Where("Label", "AB123/"));
+
+ _out.WriteLine($"same wrapper over an entity: {overEntity}");
+
+ Assert.DoesNotContain("REFUSED", guarded);
+ Assert.DoesNotContain("REFUSED", overEntity);
+ }
+
+ // ---- composed clauses ----------------------------------------------------------------------------
+
+ [Fact]
+ public void A_composed_chain_over_a_projection_still_runs()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow
+ {
+ Id = order.Id,
+ Code = order.Code,
+ Tag = order.Code.Substring(0, 2)
+ });
+
+ Exception? failed = Record.Exception(() => Guard(rows)
+ .Where(Group("Tag", "AB"))
+ .Order(new OrderBy { Field = "Code", Direction = Direction.Ascending })
+ .Select(new List { "Id", "Code" })
+ .Page(new PageBy { PageNumber = 1, PageSize = 10 })
+ .ToList(new Filter()));
+
+ _out.WriteLine($"composed: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}");
+
+ Assert.Null(failed);
+ }
+
+ [Fact]
+ public void A_composed_select_of_a_navigation_still_runs()
+ {
+ Exception? failed = Record.Exception(() =>
+ Guard(_db.Orders).Select(new List { "Id", "Customer" }).ToList(new Filter()));
+
+ _out.WriteLine($"composed select nav: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.Message}");
+
+ // Ruled out, not a finding. Customer.Orders reaches RbOrder.Secret, which is denied, and
+ // 3.2.0 refuses this identically: giving the composed Select the real row shape did not
+ // change it.
+ Assert.IsType(failed);
+ }
+
+ [Fact]
+ public void A_composed_select_of_an_owned_member_still_runs()
+ {
+ Exception? failed = Record.Exception(() =>
+ Guard(_db.Customers).Select(new List { "Id", "Title" }).ToList(new Filter()));
+
+ _out.WriteLine($"composed select owned: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}");
+
+ Assert.Null(failed);
+ }
+
+ [Fact]
+ public void A_composed_filter_and_filterdynamic_still_run()
+ {
+ Exception? filtered = Record.Exception(() => Guard(_db.Orders).Filter(Where("Code.Length", "5", DataType.Number)).ToList(new Filter()));
+ Exception? dynamic_ = Record.Exception(() => Guard(_db.Orders).FilterDynamic(Where("Code.Length", "5", DataType.Number)));
+
+ _out.WriteLine($"Filter: {filtered?.GetType().Name ?? "ran"} | FilterDynamic: {dynamic_?.GetType().Name ?? "ran"}");
+
+ Assert.Null(filtered);
+ Assert.Null(dynamic_);
+ }
+
+ // ---- casing ---------------------------------------------------------------------------------------
+
+ [Fact]
+ public void A_path_whose_casing_differs_still_filters()
+ {
+ string guarded = Probe(_db.Orders, Where("customer.title.en", "EN"));
+
+ _out.WriteLine($"casing: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+
+ // ---- batch 2: aliases, denials, copied members, postures ------------------------------------------
+
+ [Fact]
+ public void An_alias_over_a_mapped_column_still_filters()
+ {
+ string guarded = Probe(_db.Orders, Where("ref", "R-1"));
+
+ _out.WriteLine($"alias over column: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void An_alias_whose_target_is_unmapped_matches_the_unguarded_failure()
+ {
+ string unguarded = Unguarded(() => _db.Orders.Where(order => order.AliasedLabel == "AB123!"));
+ string guarded = Probe(_db.Orders, Where("ticket", "AB123!"));
+
+ _out.WriteLine($"alias to unmapped unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void A_member_copied_from_an_unmapped_getter_matches_the_unguarded_failure()
+ {
+ IQueryable rows = _db.Customers.Select(customer => new RbRow
+ {
+ Id = customer.Id,
+ Tag = customer.Display
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag.Length == 4));
+ string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number));
+
+ _out.WriteLine($"copied-from-getter unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void A_member_copied_from_an_unmapped_getter_still_filters_on_itself()
+ {
+ IQueryable rows = _db.Customers.Select(customer => new RbRow
+ {
+ Id = customer.Id,
+ Tag = customer.Display
+ });
+
+ string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme#1"));
+ string guarded = Probe(rows, Where("Tag", "Acme#1"));
+
+ _out.WriteLine($"copied-from-getter itself unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void An_identity_select_is_exempted_rather_than_checked()
+ {
+ IQueryable rows = _db.Orders.Select(order => order);
+
+ string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/1"));
+ string guarded = Probe(rows, Where("Label", "AB123/1"));
+
+ _out.WriteLine($"identity select unguarded: {unguarded} | guarded: {guarded}");
+ }
+
+ [Fact]
+ public void A_selects_naming_a_denied_field_is_still_refused()
+ {
+ string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Secret" } });
+
+ _out.WriteLine($"denied select: {guarded}");
+
+ Assert.StartsWith("REFUSED", guarded);
+ }
+
+ [Fact]
+ public void A_selects_naming_an_unmapped_getter_is_still_allowed()
+ {
+ string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Label" } });
+
+ _out.WriteLine($"select unmapped getter: {guarded}");
+
+ Assert.StartsWith("ran", guarded);
+ }
+
+ [Fact]
+ public void A_summary_over_a_projection_still_groups_on_an_assigned_member()
+ {
+ IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code });
+
+ Exception? failed = Record.Exception(() => Guard(rows).ToList(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "Code" },
+ AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } }
+ }
+ }));
+
+ _out.WriteLine($"summary over projection: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}");
+
+ Assert.Null(failed);
+ }
+
+ [Fact]
+ public void A_summary_over_an_entity_still_groups_on_a_date_part()
+ {
+ Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary
+ {
+ GroupBy = new GroupBy
+ {
+ Fields = new List { "PlacedAt.Year" },
+ AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } }
+ }
+ }));
+
+ _out.WriteLine($"summary date part: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} {(failed as PolicyException)?.Message}");
+
+ Assert.Null(failed);
+ }
+
+ // ---- what must still be refused ---------------------------------------------------------------------
+
+ [Fact]
+ public void A_filter_on_an_unmapped_getter_is_still_refused()
+ {
+ string guarded = Probe(_db.Orders, Where("Label", "AB123/1"));
+
+ _out.WriteLine($"unmapped getter: {guarded}");
+
+ Assert.StartsWith("REFUSED", guarded);
+ }
+ }
+}
diff --git a/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs
new file mode 100644
index 0000000..5aed468
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs
@@ -0,0 +1,336 @@
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Discovery;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.DTOs;
+using DynamicWhere.ex.Policies.Resolution;
+using System.Reflection;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ /// A store source of its own kind, so a second host's instance can be compared by type.
+ public sealed class RbNoRules : IDwPolicyProvider
+ {
+ public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) =>
+ Array.Empty();
+ }
+
+ ///
+ /// DW-16, second pass: pairs of postures a reasonable host would call identical, and what
+ /// DwPolicy.Configure does with the second one.
+ ///
+ public sealed class ReviewPostureProbes
+ {
+ private readonly ITestOutputHelper _out;
+
+ public ReviewPostureProbes(ITestOutputHelper output)
+ {
+ _out = output;
+ PolicyBootstrap.Ensure();
+ }
+
+ /// A posture holding exactly what is in force, value by value.
+ private static DwPolicyOptions Copy()
+ {
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ DwPolicyOptions copy = new()
+ {
+ Tier = inForce.Tier,
+ DryRun = inForce.DryRun,
+ IncludeTraceInResult = inForce.IncludeTraceInResult,
+ AuditRefusals = inForce.AuditRefusals,
+ StoreFailure = inForce.StoreFailure,
+ MaxSnapshotAge = inForce.MaxSnapshotAge,
+ RefreshInterval = inForce.RefreshInterval
+ };
+
+ if (!string.IsNullOrEmpty(inForce.HashSalt))
+ {
+ copy.HashSalt = inForce.HashSalt;
+ }
+
+ copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize;
+ copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize;
+ copy.Caps.MaxConditions = inForce.Caps.MaxConditions;
+ copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth;
+ copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets;
+ copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues;
+ copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates;
+ copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields;
+ copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth;
+ copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost;
+ copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost;
+ copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents;
+ copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth;
+ copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit;
+ copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields;
+
+ if (inForce.Caps.IsMinGroupSizeSet)
+ {
+ copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize;
+ }
+
+ foreach (KeyValuePair exposed in inForce.Entities.Entities)
+ {
+ copy.Entities.Expose(exposed.Key, exposed.Value);
+ }
+
+ return copy;
+ }
+
+ private string Ask(DwPolicyOptions options, params IDwPolicyProvider[] providers)
+ {
+ try
+ {
+ DwPolicy.Configure(options, providers);
+
+ return "accepted";
+ }
+ catch (InvalidOperationException refused)
+ {
+ return $"REFUSED: {refused.Message.Split('.')[0]}";
+ }
+ }
+
+ // ---- caps written as their own default ------------------------------------------------------------
+
+ [Fact]
+ public void MinGroupSize_written_as_its_own_default_is_accepted()
+ {
+ DwPolicyOptions copy = Copy();
+
+ copy.Caps.MinGroupSize = DwCaps.DefaultMinGroupSize;
+
+ string outcome = Ask(copy);
+
+ _out.WriteLine($"MinGroupSize = {DwCaps.DefaultMinGroupSize}: {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ [Fact]
+ public void Every_other_cap_written_as_its_own_default_is_accepted()
+ {
+ DwPolicyOptions copy = Copy();
+
+ // Each already holds the value in force; writing it again is what a host binding the
+ // documented appsettings sample does.
+ copy.Caps.MaxPageSize = 1000;
+ copy.Caps.DefaultPageSize = 0;
+ copy.Caps.MaxConditions = 50;
+ copy.Caps.MaxConditionDepth = 10;
+ copy.Caps.MaxConditionSets = 10;
+ copy.Caps.MaxConditionValues = 1000;
+ copy.Caps.MaxAggregates = 50;
+ copy.Caps.MaxOrderFields = 10;
+ copy.Caps.MaxNavigationDepth = 4;
+ copy.Caps.MaxQueryCost = 1000;
+ copy.Caps.DefaultFieldCost = 1;
+ copy.Caps.MaxAuditEvents = 10_000;
+ copy.Caps.SchemaDepth = 2;
+ copy.Caps.SchemaCycleLimit = 2;
+ copy.Caps.MaxSchemaFields = 2000;
+
+ string outcome = Ask(copy);
+
+ _out.WriteLine($"every cap at its default: {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ // ---- the trace flag, null against the tier's own value --------------------------------------------
+
+ [Fact]
+ public void IncludeTraceInResult_written_as_the_tier_s_own_value_is_accepted()
+ {
+ DwPolicyOptions inForce = DwPolicy.Options;
+
+ Assert.Null(inForce.IncludeTraceInResult);
+
+ DwPolicyOptions copy = Copy();
+
+ // The value the tier already follows: identical in effect, value for value.
+ copy.IncludeTraceInResult = inForce.Tier == DwTier.Convenience;
+
+ string outcome = Ask(copy);
+
+ _out.WriteLine($"IncludeTraceInResult = {copy.IncludeTraceInResult} under tier {inForce.Tier}: {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ // ---- the hash salt ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void The_same_hash_salt_in_both_is_accepted()
+ {
+ DwPolicyOptions copy = Copy();
+
+ string outcome = Ask(copy);
+
+ _out.WriteLine($"same salt '{DwPolicy.Options.HashSalt}': {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ // ---- the catalogue ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void A_catalogue_built_in_a_different_order_is_accepted()
+ {
+ DwPolicyOptions copy = Copy();
+ DwPolicyOptions reversed = Copy();
+
+ // Rebuilt back to front. The pairs are the same; only the order of the Expose calls differs.
+ DwPolicyOptions other = new()
+ {
+ Tier = copy.Tier,
+ DryRun = copy.DryRun,
+ IncludeTraceInResult = copy.IncludeTraceInResult,
+ AuditRefusals = copy.AuditRefusals,
+ StoreFailure = copy.StoreFailure,
+ MaxSnapshotAge = copy.MaxSnapshotAge,
+ RefreshInterval = copy.RefreshInterval
+ };
+
+ foreach (KeyValuePair exposed in reversed.Entities.Entities.Reverse())
+ {
+ other.Entities.Expose(exposed.Key, exposed.Value);
+ }
+
+ string outcome = Ask(other);
+
+ _out.WriteLine($"catalogue reversed ({other.Entities.Entities.Count} types): {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ [Fact]
+ public void A_type_exposed_under_two_names_in_a_different_order_is_compared_by_the_last_one()
+ {
+ // Not a refusal to fix on its own: it says what the two catalogues really do differ in.
+ // Recorded because a host that lists its aliases in a different order is a plausible
+ // second host, and the difference it is refused for is one Entities does not show.
+ DwEntityCatalog first = new();
+ DwEntityCatalog second = new();
+
+ first.Expose("party").Expose("counterparty");
+ second.Expose("counterparty").Expose("party");
+
+ _out.WriteLine($"first names: {string.Join(",", first.Entities.Values)} | resolves party: {first.Resolve("party") is not null}, counterparty: {first.Resolve("counterparty") is not null}");
+ _out.WriteLine($"second names: {string.Join(",", second.Entities.Values)} | resolves party: {second.Resolve("party") is not null}, counterparty: {second.Resolve("counterparty") is not null}");
+
+ Assert.NotEqual(first.Entities[typeof(RbParty)], second.Entities[typeof(RbParty)]);
+ }
+
+ // ---- the providers ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void The_posture_in_force_with_no_provider_is_accepted()
+ {
+ string outcome = Ask(DwPolicy.Options);
+
+ _out.WriteLine($"Configure(DwPolicy.Options): {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+ [Fact]
+ public void The_posture_in_force_with_a_provider_is_refused()
+ {
+ string outcome = Ask(DwPolicy.Options, new RbNoRules());
+
+ _out.WriteLine($"Configure(DwPolicy.Options, provider): {outcome}");
+
+ Assert.StartsWith("REFUSED", outcome);
+ }
+
+ [Fact]
+ public void A_copy_with_a_provider_the_first_call_never_had_is_refused()
+ {
+ string outcome = Ask(Copy(), new RbNoRules());
+
+ _out.WriteLine($"copy + provider: {outcome}");
+
+ Assert.StartsWith("REFUSED", outcome);
+ }
+
+ [Fact]
+ public void The_attribute_provider_alone_is_not_a_difference()
+ {
+ string outcome = Ask(Copy(), new AttributePolicyProvider());
+
+ _out.WriteLine($"copy + AttributePolicyProvider: {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+
+
+ // ---- DwEntityCatalog.SameAs, read directly ---------------------------------------------------------
+
+ private static bool SameAs(DwEntityCatalog left, DwEntityCatalog right) =>
+ (bool)typeof(DwEntityCatalog)
+ .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(left, new object[] { right })!;
+
+ [Fact]
+ public void Two_catalogues_exposing_the_same_types_under_the_same_names_are_the_same()
+ {
+ DwEntityCatalog first = new();
+ DwEntityCatalog second = new();
+
+ first.Expose("party").Expose("order").Expose();
+ second.Expose().Expose("order").Expose("party");
+
+ _out.WriteLine($"same types, same names, different order: {SameAs(first, second)}");
+
+ Assert.True(SameAs(first, second));
+ }
+
+ [Fact]
+ public void Two_catalogues_answering_to_the_same_names_are_refused_when_the_alias_order_differs()
+ {
+ DwEntityCatalog first = new();
+ DwEntityCatalog second = new();
+
+ first.Expose("party").Expose("counterparty");
+ second.Expose("counterparty").Expose("party");
+
+ // Every name resolves to the same type on both sides, so an administrative request is
+ // answered identically whichever one is in force.
+ Assert.Equal(first.Resolve("party"), second.Resolve("party"));
+ Assert.Equal(first.Resolve("counterparty"), second.Resolve("counterparty"));
+ Assert.Equal(first.Resolve(typeof(RbParty).FullName), second.Resolve(typeof(RbParty).FullName));
+
+ _out.WriteLine($"same names, alias order differs: SameAs = {SameAs(first, second)}");
+
+ // Resolve answers the same, and NameOf does not: a type exposed twice is reported under
+ // the last name it was given, so a schema request is answered differently by the two.
+ // That is a posture a second host may not hand over silently.
+ Assert.NotEqual(first.NameOf(typeof(RbParty)), second.NameOf(typeof(RbParty)));
+ Assert.False(SameAs(first, second));
+ }
+
+ [Fact]
+ public void Two_catalogues_that_differ_only_in_the_full_name_map_are_refused()
+ {
+ // The only way _byFullName can differ while the public names agree: two types with the
+ // same Type.FullName, exposed in a different order. Nothing else writes that map.
+ _out.WriteLine("nothing in a single assembly builds this pair; recorded as reasoned, not run");
+ }
+
+ // ---- a plain second call ---------------------------------------------------------------------------
+
+ [Fact]
+ public void A_plain_copy_is_accepted()
+ {
+ string outcome = Ask(Copy());
+
+ _out.WriteLine($"plain copy: {outcome}");
+
+ Assert.Equal("accepted", outcome);
+ }
+ }
+}
diff --git a/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs
new file mode 100644
index 0000000..6485400
--- /dev/null
+++ b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs
@@ -0,0 +1,674 @@
+using System.Linq.Expressions;
+using System.Reflection;
+using DynamicWhere.ex.Classes.Complex;
+using DynamicWhere.ex.Classes.Core;
+using DynamicWhere.ex.Classes.Result;
+using DynamicWhere.ex.Enums;
+using DynamicWhere.ex.Exceptions;
+using DynamicWhere.ex.Policies.Config;
+using DynamicWhere.ex.Policies.Context;
+using DynamicWhere.ex.Policies.Discovery;
+using DynamicWhere.ex.Policies.Enums;
+using DynamicWhere.ex.Policies.Resolution;
+using DynamicWhere.ex.Policies.Source;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace DynamicWhere.Tests.Policies
+{
+ ///
+ /// A provider that is not EF Core's, is not IAsyncQueryProvider, and keeps wrapping
+ /// itself through composition while handing every execution to the query it wraps.
+ ///
+ ///
+ /// The shape LinqKit's AsExpandable and DelegateDecompiler's Decompile have: a
+ /// wrapper in front of EF Core, which still answers the query.
+ ///
+ public sealed class ZzWrapped : IQueryable, IQueryProvider
+ {
+ private readonly IQueryable _inner;
+
+ public ZzWrapped(IQueryable inner)
+ {
+ _inner = inner;
+ Expression = inner.Expression;
+ }
+
+ public Type ElementType => typeof(T);
+
+ public Expression Expression { get; }
+
+ public IQueryProvider Provider => this;
+
+ public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression);
+
+ public IQueryable CreateQuery(Expression expression) =>
+ new ZzWrapped(_inner.Provider.CreateQuery(expression));
+
+ public object? Execute(Expression expression) => _inner.Provider.Execute(expression);
+
+ public TResult Execute(Expression expression) => _inner.Provider.Execute(expression);
+ }
+
+ /// A value object whose text changes between reads, for the clone contract probe.
+ public sealed class ZzShifting
+ {
+ private int _reads;
+
+ public int Reads => _reads;
+
+ public override string ToString() => ++_reads == 1 ? "admin" : "root";
+ }
+
+ ///
+ /// Second-round probes: what the 3.3.0 fixes themselves opened.
+ ///
+ public sealed class ReviewSecondRoundProbes : IDisposable
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly SqliteConnection _connection;
+ private readonly ZyContext _db;
+
+ public ReviewSecondRoundProbes(ITestOutputHelper output)
+ {
+ _out = output;
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _db = new ZyContext(_connection);
+ _db.Database.EnsureCreated();
+ _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "AR", En = "Admin" } });
+ _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" });
+ _db.SaveChanges();
+ _db.ChangeTracker.Clear();
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connection.Dispose();
+ }
+
+ private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool traceInResult = false)
+ where T : class =>
+ source.ApplyPolicy(
+ new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"),
+ traceInResult
+ ? new DwPolicyOptions { Tier = tier, IncludeTraceInResult = true }
+ : new DwPolicyOptions { Tier = tier },
+ new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }));
+
+ private static Filter Where(string field, string value, DataType type = DataType.Text) => new()
+ {
+ ConditionGroup = new ConditionGroup
+ {
+ Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } }
+ }
+ };
+
+ private IQueryable Built() =>
+ _db.Roles.Select(role => new ZyRoleRow
+ {
+ Id = role.Id,
+ Code = role.Code,
+ Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En }
+ });
+
+ // ==== 1. the computed:false exemption where the projection is NOT last ==========================
+
+ ///
+ /// The exemption's premise is "a projection is the last thing the provider builds". The
+ /// composable handle lets a caller put something after it.
+ ///
+ [Fact]
+ public void P1_Composed_select_then_order_on_the_handle()
+ {
+ PolicyQueryable projected =
+ Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" });
+
+ Exception? raised = Record.Exception(
+ () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending })
+ .ToList(new Filter()));
+
+ _out.WriteLine($"P1 select-then-order: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}");
+
+ Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}");
+ }
+
+ [Fact]
+ public void P2_Composed_select_then_page_on_the_handle()
+ {
+ PolicyQueryable projected =
+ Guard(Built(), DwTier.Strict).Select(new List