@cerbos/orm-prisma 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +598 -603
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +23 -13
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2555
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +27 -17
  95. package/src/adversarial.test.ts +376 -1071
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +433 -250
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +10 -0
  104. package/src/generated/prisma-adversarial/client.ts +10 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
  114. package/src/generated/prisma-adversarial/models.ts +2 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
  117. package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
  118. package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  121. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  130. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  131. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  132. package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
  133. package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
  134. package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
  135. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  136. package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
  137. package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
  138. package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
  139. package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
  140. package/src/generated/prisma-adversarial-pg/client.ts +10 -0
  141. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
  142. package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
  143. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
  144. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  148. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
  149. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
  150. package/src/generated/prisma-adversarial-pg/models.ts +2 -0
  151. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
  152. package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
  153. package/src/hierarchy.ts +405 -0
  154. package/src/index.ts +66 -3646
  155. package/src/literals.ts +363 -0
  156. package/src/mapping.ts +474 -0
  157. package/src/narrowing.ts +33 -0
  158. package/src/plan.ts +125 -0
  159. package/src/presence.ts +114 -0
  160. package/src/regex.ts +596 -0
  161. package/src/relations.ts +226 -0
  162. package/src/rewrite.ts +524 -0
  163. package/src/solve.ts +96 -0
  164. package/src/strings.ts +152 -0
  165. package/src/ternary.ts +359 -0
  166. package/src/test-setup.adversarial-url.ts +6 -3
  167. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  168. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  169. package/src/test-setup.adversarial.v7.ts +1 -0
  170. package/src/timestamp.ts +308 -0
  171. package/src/translate.ts +419 -0
  172. package/src/translator.test.ts +497 -1317
  173. package/src/types.ts +1310 -0
  174. package/src/value.ts +16 -0
@@ -1,1305 +1,320 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
-
4
1
  import { describe, expect, test } from "@jest/globals";
2
+ import type { JsonValue } from "@bufbuild/protobuf";
5
3
  import type {
6
4
  PlanExpressionOperand,
7
5
  PlanResourcesResponse,
6
+ Value,
8
7
  } from "@cerbos/core";
9
8
 
10
- import { queryPlanToPrisma, PlanKind } from ".";
9
+ import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
11
10
  import type {
12
11
  Mapper,
12
+ MapperConfig,
13
13
  NullAttributeRepresentation,
14
14
  PrismaFilter,
15
15
  QueryPlanToPrismaResult,
16
16
  } from ".";
17
- import {
18
- CONFORMANCE_DIR,
19
- MAPPER,
20
- MODEL,
21
- classifyActionsForAdapter,
22
- planFromWireFixture,
23
- wireFixtureActions,
24
- } from "./corpus";
25
- import type { ActionsFile } from "./corpus";
17
+ import { MAPPER, MODEL, pdpTags, planOf, readGolden, readGoldens } from "./corpus";
18
+ import { isObject } from "./narrowing";
19
+
20
+ type Relation = NonNullable<MapperConfig["relation"]>;
21
+
22
+ /** `response` with `field` replaced by a value its type rules out: a plan the SDK never decodes. */
23
+ const overwritten = (response: PlanResourcesResponse, field: string, value: Value): PlanResourcesResponse =>
24
+ Object.defineProperty({ ...response }, field, { value, enumerable: true });
26
25
 
27
26
  /**
28
- * Translator unit test: for every action in the shared `../conformance/` corpus, the filter this
29
- * adapter emits. Offline — no Cerbos sidecar, no database, no generated Prisma client.
30
- *
31
- * A per-adapter suite used to braid four assertions into every test. Three of them are somebody
32
- * else's job now, and this file makes only the fourth:
27
+ * Offline unit tests: what a caller can pass that the conformance corpus cannot vary (mapper forms,
28
+ * `subqueryFilter`, element nullability, `nullAttributeRepresentation`), the refusal type, the
29
+ * timestamp literal contract, and plans the planner cannot produce.
33
30
  *
34
- * | assertion | who owns it |
35
- * | --- | --- |
36
- * | the plan the PDP produces for a policy | `conformance/wire-fixtures/`, replanned and diffed by the `Conformance Corpus` workflow |
37
- * | which shapes this adapter must refuse, and with what message | `conformance/actions.json` — read below, not restated |
38
- * | the rows a filter returns | `adversarial.test.ts`, against a real store with `check()` as the oracle |
39
- * | **the filter this adapter emits for a plan** | **here** |
40
- *
41
- * **The plans are read, not written.** A hand-built plan is a *belief* about what the planner
42
- * emits, and this repository keeps golden fixtures because that belief has been wrong before: a
43
- * planner change used to fail fixture regeneration and silently leave every adapter's hand-written
44
- * plans describing a wire contract that no longer existed. Sourcing from fixtures inverts that —
45
- * the drift check now protects the plans this file asserts against. See
46
- * [ADR 0006](../../docs/adr/0006-translator-unit-tests-take-their-plans-from-wire-fixtures.md).
47
- *
48
- * **What a pinned filter buys over the harness.** The harness proves the filter returns the right
49
- * rows *against the 21 rows it seeds*. Two different filters can agree on all of them and
50
- * disagree on the row a consumer has, so a rewrite that quietly changes the emitted SQL passes
51
- * there and shows up here as a diff a reviewer reads. It is also the only place a
52
- * `nullAttributeRepresentation` boundary, a timestamp literal, or a mapping the corpus cannot
53
- * express (`subqueryFilter`) can be pinned at all.
54
- *
55
- * **Adding a corpus action fails this file.** Every wire fixture must be classified here exactly
56
- * once — expected filter, expected plan kind, or expected throw — and the guard at the bottom is
57
- * what makes a new action land as a failure rather than as silence.
31
+ * Which rows a corpus case returns is the conformance harness's job (`adversarial.test.ts`); nothing
32
+ * here pins the filter emitted for a corpus case. Plans come from the current PDP's golden files,
33
+ * read by case id, rather than being typed by hand.
58
34
  */
59
35
 
60
- const actionsFile: ActionsFile = JSON.parse(
61
- fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
62
- );
36
+ const CURRENT = pdpTags()[0]!;
63
37
 
64
- /**
65
- * The shapes `actions.json` says this adapter must refuse, each with the message it must refuse
66
- * them with. Identical to the classification `adversarial.test.ts` asserts against a live PDP;
67
- * asserting it here as well is what lets the completeness guard below be total, and it costs a
68
- * millisecond rather than a container.
69
- */
70
- const { throwingActions: THROWING_ACTIONS } = classifyActionsForAdapter(
71
- actionsFile,
72
- "prisma"
73
- );
38
+ /** The current PDP's recorded plan for a corpus case, `__NOW_MINUS_24H__` filled as `now`. */
39
+ function planFor(id: string, now?: string): Promise<PlanResourcesResponse> {
40
+ return planOf(readGolden(CURRENT, id), now);
41
+ }
74
42
 
75
- function translate(
76
- action: string,
43
+ async function translate(
44
+ id: string,
77
45
  options: {
78
46
  mapper?: Mapper;
79
47
  nullAttributeRepresentation?: NullAttributeRepresentation;
80
48
  } = {}
81
- ): QueryPlanToPrismaResult {
49
+ ): Promise<QueryPlanToPrismaResult> {
82
50
  return queryPlanToPrisma({
83
- queryPlan: planFromWireFixture(action),
51
+ queryPlan: await planFor(id),
84
52
  mapper: options.mapper ?? MAPPER,
85
53
  model: MODEL,
86
- ...(options.nullAttributeRepresentation
87
- ? { nullAttributeRepresentation: options.nullAttributeRepresentation }
88
- : {}),
54
+ // Varied explicitly below; the adapter's own default is pinned under "nullAttributeRepresentation".
55
+ nullAttributeRepresentation: options.nullAttributeRepresentation ?? "explicit",
89
56
  });
90
57
  }
91
58
 
92
- /**
93
- * The plan kind for the two corpus actions the planner resolves without a condition.
94
- *
95
- * `p-has` is `knownDivergences` for every adapter — the planner folds `has(unknown attr)` to
96
- * ALWAYS_ALLOWED, so the harness cannot compare it against the oracle. Translation is still
97
- * defined, and pinning it here is the only assertion the corpus makes about the action.
98
- */
99
- const EXPECTED_KINDS: Record<
100
- string,
101
- PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED
102
- > = {
103
- "in-empty": PlanKind.ALWAYS_DENIED,
104
- "p-has": PlanKind.ALWAYS_ALLOWED,
105
- };
59
+ describe("the refusal type", () => {
60
+ test("an untranslatable shape raises UnsupportedQueryPlanError, which is an Error", async () => {
61
+ let raised: unknown;
106
62
 
107
- /**
108
- * The `where` input this adapter emits for every corpus action it can translate, under `MAPPER`
109
- * and the default (`"explicit"`) null representation.
110
- *
111
- * Alphabetical by action, so the diff of a translator change reads as a list of the shapes it
112
- * moved. Comments are on the entries where the emitted shape is the whole point of the action;
113
- * the rest are pins.
114
- */
115
- const EXPECTED_FILTERS: Record<string, PrismaFilter> = {
116
- // The three-valued-logic guard. `every` collapses UNKNOWN to false at the EXISTS boundary, so
117
- // an element whose `name` is NULL would be silently satisfied; CEL raises a missing-attribute
118
- // error and check() denies. The second conjunct is what keeps the two aligned, and it is only
119
- // emitted because the mapper declares `name` nullable.
120
- "all-on-empty": {
121
- AND: [
122
- { tags: { every: { name: { equals: "public" } } } },
123
- { tags: { none: { name: null } } },
124
- ],
125
- },
126
- "arith-add": { aNumber: { gt: 1 } },
127
- "arith-div": { aNumber: { equals: 2 } },
128
- "arith-div-frac": { aNumber: { gte: 3 } },
129
- "arith-mult-neg": { OR: [{ aNumber: { gt: -1.5 } }, { aNumber: { gte: -1 } }] },
130
- "arith-sub": { aNumber: { lte: 3 } },
131
- // Value-first: the plan reads `K < add(aNumber, K)`, and the mirrored operator is the whole
132
- // assertion. Emitting `lt` here would invert the comparison — this repository's canonical bug
133
- // class, and the reason `vf-*` and `arith-vf` pin the same filter as their column-first twins.
134
- "arith-vf": { aNumber: { gt: 1 } },
135
- // A constant receiver with a column needle: `K.contains(aString)` cannot become a LIKE, so the
136
- // adapter enumerates every substring of the constant and tests membership. The enumeration is
137
- // long, and pinning it verbatim is the point — an off-by-one at either end of the window is
138
- // invisible in a row comparison over 22 seeds and obvious in this list.
139
- "cr-contains": {
140
- aString: {
141
- in: [
142
- "",
143
- "s",
144
- "s1",
145
- "s10",
146
- "s100",
147
- "s100X",
148
- "s100Xd",
149
- "s100Xdo",
150
- "s100Xdon",
151
- "s100Xdone",
152
- "s100Xdone-",
153
- "s100Xdone-t",
154
- "s100Xdone-ta",
155
- "s100Xdone-tai",
156
- "s100Xdone-tail",
157
- "s100Xdone-tail\\",
158
- "s100Xdone-tail\\o",
159
- "s100Xdone-tail\\on",
160
- "s100Xdone-tail\\one",
161
- "s100Xdone-tail\\one-",
162
- "s100Xdone-tail\\one-e",
163
- "s100Xdone-tail\\one-en",
164
- "s100Xdone-tail\\one-end",
165
- "1",
166
- "10",
167
- "100",
168
- "100X",
169
- "100Xd",
170
- "100Xdo",
171
- "100Xdon",
172
- "100Xdone",
173
- "100Xdone-",
174
- "100Xdone-t",
175
- "100Xdone-ta",
176
- "100Xdone-tai",
177
- "100Xdone-tail",
178
- "100Xdone-tail\\",
179
- "100Xdone-tail\\o",
180
- "100Xdone-tail\\on",
181
- "100Xdone-tail\\one",
182
- "100Xdone-tail\\one-",
183
- "100Xdone-tail\\one-e",
184
- "100Xdone-tail\\one-en",
185
- "100Xdone-tail\\one-end",
186
- "0",
187
- "00",
188
- "00X",
189
- "00Xd",
190
- "00Xdo",
191
- "00Xdon",
192
- "00Xdone",
193
- "00Xdone-",
194
- "00Xdone-t",
195
- "00Xdone-ta",
196
- "00Xdone-tai",
197
- "00Xdone-tail",
198
- "00Xdone-tail\\",
199
- "00Xdone-tail\\o",
200
- "00Xdone-tail\\on",
201
- "00Xdone-tail\\one",
202
- "00Xdone-tail\\one-",
203
- "00Xdone-tail\\one-e",
204
- "00Xdone-tail\\one-en",
205
- "00Xdone-tail\\one-end",
206
- "0X",
207
- "0Xd",
208
- "0Xdo",
209
- "0Xdon",
210
- "0Xdone",
211
- "0Xdone-",
212
- "0Xdone-t",
213
- "0Xdone-ta",
214
- "0Xdone-tai",
215
- "0Xdone-tail",
216
- "0Xdone-tail\\",
217
- "0Xdone-tail\\o",
218
- "0Xdone-tail\\on",
219
- "0Xdone-tail\\one",
220
- "0Xdone-tail\\one-",
221
- "0Xdone-tail\\one-e",
222
- "0Xdone-tail\\one-en",
223
- "0Xdone-tail\\one-end",
224
- "X",
225
- "Xd",
226
- "Xdo",
227
- "Xdon",
228
- "Xdone",
229
- "Xdone-",
230
- "Xdone-t",
231
- "Xdone-ta",
232
- "Xdone-tai",
233
- "Xdone-tail",
234
- "Xdone-tail\\",
235
- "Xdone-tail\\o",
236
- "Xdone-tail\\on",
237
- "Xdone-tail\\one",
238
- "Xdone-tail\\one-",
239
- "Xdone-tail\\one-e",
240
- "Xdone-tail\\one-en",
241
- "Xdone-tail\\one-end",
242
- "d",
243
- "do",
244
- "don",
245
- "done",
246
- "done-",
247
- "done-t",
248
- "done-ta",
249
- "done-tai",
250
- "done-tail",
251
- "done-tail\\",
252
- "done-tail\\o",
253
- "done-tail\\on",
254
- "done-tail\\one",
255
- "done-tail\\one-",
256
- "done-tail\\one-e",
257
- "done-tail\\one-en",
258
- "done-tail\\one-end",
259
- "o",
260
- "on",
261
- "one",
262
- "one-",
263
- "one-t",
264
- "one-ta",
265
- "one-tai",
266
- "one-tail",
267
- "one-tail\\",
268
- "one-tail\\o",
269
- "one-tail\\on",
270
- "one-tail\\one",
271
- "one-tail\\one-",
272
- "one-tail\\one-e",
273
- "one-tail\\one-en",
274
- "one-tail\\one-end",
275
- "n",
276
- "ne",
277
- "ne-",
278
- "ne-t",
279
- "ne-ta",
280
- "ne-tai",
281
- "ne-tail",
282
- "ne-tail\\",
283
- "ne-tail\\o",
284
- "ne-tail\\on",
285
- "ne-tail\\one",
286
- "ne-tail\\one-",
287
- "ne-tail\\one-e",
288
- "ne-tail\\one-en",
289
- "ne-tail\\one-end",
290
- "e",
291
- "e-",
292
- "e-t",
293
- "e-ta",
294
- "e-tai",
295
- "e-tail",
296
- "e-tail\\",
297
- "e-tail\\o",
298
- "e-tail\\on",
299
- "e-tail\\one",
300
- "e-tail\\one-",
301
- "e-tail\\one-e",
302
- "e-tail\\one-en",
303
- "e-tail\\one-end",
304
- "-",
305
- "-t",
306
- "-ta",
307
- "-tai",
308
- "-tail",
309
- "-tail\\",
310
- "-tail\\o",
311
- "-tail\\on",
312
- "-tail\\one",
313
- "-tail\\one-",
314
- "-tail\\one-e",
315
- "-tail\\one-en",
316
- "-tail\\one-end",
317
- "t",
318
- "ta",
319
- "tai",
320
- "tail",
321
- "tail\\",
322
- "tail\\o",
323
- "tail\\on",
324
- "tail\\one",
325
- "tail\\one-",
326
- "tail\\one-e",
327
- "tail\\one-en",
328
- "tail\\one-end",
329
- "a",
330
- "ai",
331
- "ail",
332
- "ail\\",
333
- "ail\\o",
334
- "ail\\on",
335
- "ail\\one",
336
- "ail\\one-",
337
- "ail\\one-e",
338
- "ail\\one-en",
339
- "ail\\one-end",
340
- "i",
341
- "il",
342
- "il\\",
343
- "il\\o",
344
- "il\\on",
345
- "il\\one",
346
- "il\\one-",
347
- "il\\one-e",
348
- "il\\one-en",
349
- "il\\one-end",
350
- "l",
351
- "l\\",
352
- "l\\o",
353
- "l\\on",
354
- "l\\one",
355
- "l\\one-",
356
- "l\\one-e",
357
- "l\\one-en",
358
- "l\\one-end",
359
- "\\",
360
- "\\o",
361
- "\\on",
362
- "\\one",
363
- "\\one-",
364
- "\\one-e",
365
- "\\one-en",
366
- "\\one-end",
367
- "one-e",
368
- "one-en",
369
- "one-end",
370
- "ne-e",
371
- "ne-en",
372
- "ne-end",
373
- "e-e",
374
- "e-en",
375
- "e-end",
376
- "-e",
377
- "-en",
378
- "-end",
379
- "en",
380
- "end",
381
- "nd",
382
- ],
383
- },
384
- },
385
- "cr-endswith": {
386
- aString: {
387
- in: [
388
- "",
389
- "prefix-xaXby",
390
- "refix-xaXby",
391
- "efix-xaXby",
392
- "fix-xaXby",
393
- "ix-xaXby",
394
- "x-xaXby",
395
- "-xaXby",
396
- "xaXby",
397
- "aXby",
398
- "Xby",
399
- "by",
400
- "y",
401
- ],
402
- },
403
- },
404
- "cr-startswith": {
405
- aString: {
406
- in: [
407
- "",
408
- "x",
409
- "xa",
410
- "xaX",
411
- "xaXb",
412
- "xaXby",
413
- "xaXby-",
414
- "xaXby-t",
415
- "xaXby-ta",
416
- "xaXby-tai",
417
- "xaXby-tail",
418
- ],
419
- },
420
- },
421
- "cr-startswith-concat": {
422
- aString: {
423
- in: [
424
- "",
425
- "x",
426
- "xa",
427
- "xaX",
428
- "xaXb",
429
- "xaXby",
430
- "xaXby-",
431
- "xaXby-t",
432
- "xaXby-ta",
433
- "xaXby-tai",
434
- "xaXby-tail",
435
- ],
436
- },
437
- },
438
- "cs-contains": { aString: { contains: "one" } },
439
- "cs-endswith": { aString: { endsWith: "one" } },
440
- "cs-eq": { aString: { equals: "one" } },
441
- "cs-startswith": { aString: { startsWith: "one" } },
442
- "double-negation": { NOT: { aBool: { equals: false } } },
443
- "double-threshold": { AND: [{ aNumber: { gte: 1.5 } }, { aNumber: { gt: 1 } }] },
444
- "empty-string-eq": { aString: { equals: "" } },
445
- "exists-on-empty": { tags: { some: { name: { equals: "public" } } } },
446
- "field-to-field": {
447
- aString: { equals: { _ref: "aOptionalString", _container: "AdversarialResource" } },
448
- },
449
- "gt-bare": { aNumber: { gt: 1 } },
450
- "hier-ancestor-cf": { scope: { startsWith: "dept.eng." } },
451
- "hier-ancestor-ff": { scope: { in: ["dept", "dept.eng"] } },
452
- "hier-descendent-cf": { scope: { in: ["dept", "dept.eng"] } },
453
- "hier-descendent-ff": { scope: { startsWith: "dept.eng." } },
454
- "hier-list-id": { id: { equals: "f1" } },
455
- "hier-meta-in": { scope: { in: ["50%", "50%.a_b"] } },
456
- "hier-overlaps-cf": {
457
- OR: [
458
- { scope: { in: ["dept"] } },
459
- { scope: { equals: "dept.eng" } },
460
- { scope: { startsWith: "dept.eng." } },
461
- ],
462
- },
463
- "hier-overlaps-ff": {
464
- OR: [
465
- { scope: { in: ["dept"] } },
466
- { scope: { equals: "dept.eng" } },
467
- { scope: { startsWith: "dept.eng." } },
468
- ],
469
- },
470
- "id-concat-vf": { id: { equals: "f1" } },
471
- // The primary key, which arrives as the variable `request.resource.id` rather than through
472
- // `attr` — the planner leaves the resource's own identity symbolic because PlanResources is
473
- // asked about a kind, not a row. An adapter that resolves references by stripping a
474
- // `request.resource.attr.` prefix never reaches this name, and fails silently when it does not.
475
- "id-eq-const": { id: { equals: "f1" } },
476
- // `_ref`/`_container` is Prisma's field-reference encoding: comparing two columns needs the
477
- // model name, which is why `queryPlanToPrisma` takes `model`.
478
- "id-f2f": { aString: { equals: { _ref: "id", _container: "AdversarialResource" } } },
479
- "id-f2f-ne": {
480
- NOT: { aString: { equals: { _ref: "id", _container: "AdversarialResource" } } },
481
- },
482
- "in-null-elem-hasint": {
483
- OR: [{ tags: { some: { name: "public" } } }, { tags: { some: { name: null } } }],
484
- },
485
- "in-null-elem-mixed": {
486
- OR: [{ aOptionalString: { in: ["x", "one_two"] } }, { aOptionalString: null }],
487
- },
488
- "in-null-elem-neg": {
489
- NOT: {
490
- OR: [{ aOptionalString: { in: ["x", "one_two"] } }, { aOptionalString: null }],
491
- },
492
- },
493
- "in-null-elem-only": { aOptionalString: { equals: null } },
494
- "in-null-elem-only-neg": { NOT: { aOptionalString: { equals: null } } },
495
- "in-null-elem-rel": { tags: { some: { name: null } } },
496
- "in-null-elem-rel-neg": { NOT: { tags: { some: { name: null } } } },
497
- "in-single": { aString: { equals: "one" } },
498
- "lambda-in-principal": { tags: { some: { name: { in: ["public", "special"] } } } },
499
- "le-bare": { aNumber: { lte: 2 } },
500
- "like-bracket": { aString: { startsWith: "[SEC]" } },
501
- "macro-depth3-all": {
502
- categories: {
503
- some: {
504
- subCategories: { every: { labels: { some: { name: { equals: "gold" } } } } },
505
- },
506
- },
507
- },
508
- "macro-depth3-exists": {
509
- categories: {
510
- some: {
511
- subCategories: { some: { labels: { some: { name: { equals: "gold" } } } } },
512
- },
513
- },
514
- },
515
- "macro-depth3-not-exists": {
516
- AND: [
517
- {
518
- NOT: {
519
- categories: {
520
- some: {
521
- subCategories: { some: { labels: { some: { name: { equals: "gold" } } } } },
522
- },
523
- },
524
- },
525
- },
526
- {
527
- NOT: {
528
- categories: {
529
- some: {
530
- AND: [
531
- {
532
- NOT: {
533
- subCategories: { some: { labels: { some: { name: { equals: "gold" } } } } },
534
- },
535
- },
536
- {
537
- subCategories: {
538
- some: {
539
- AND: [
540
- {
541
- NOT: { labels: { some: { name: { equals: "gold" } } } },
542
- },
543
- { labels: { some: { name: null } } },
544
- ],
545
- },
546
- },
547
- },
548
- ],
549
- },
550
- },
551
- },
552
- },
553
- ],
554
- },
555
- "n-all-mixed-null": {
556
- AND: [
557
- { tags: { every: { name: { not: "x" } } } },
558
- { tags: { none: { name: null } } },
559
- ],
560
- },
561
- "n-not-all-absorb": { tags: { some: { NOT: { name: { not: "public" } } } } },
562
- "n-not-all-null": { tags: { some: { NOT: { name: { equals: "public" } } } } },
563
- "nan-ord-inf": {
564
- OR: [
565
- { aBool: { equals: true } },
566
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
567
- ],
568
- },
569
- "nan-ord-le": {
570
- OR: [
571
- {
572
- AND: [
573
- { aBool: { equals: true } },
574
- {
575
- OR: [
576
- {
577
- AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }],
578
- },
579
- ],
580
- },
581
- ],
582
- },
583
- {
584
- AND: [
585
- { aBool: { equals: false } },
586
- {
587
- OR: [
588
- { aBool: { equals: true } },
589
- {
590
- AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }],
591
- },
592
- ],
593
- },
594
- ],
595
- },
596
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
597
- ],
598
- },
599
- "nan-ord-ternary": {
600
- OR: [
601
- { aBool: { equals: true } },
602
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
603
- ],
604
- },
605
- "nan-ord-ternary-vf": {
606
- OR: [
607
- { aBool: { equals: true } },
608
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
609
- ],
610
- },
611
- "nary-and": {
612
- AND: [
613
- { aBool: { equals: true } },
614
- { aNumber: { gte: 0 } },
615
- { aString: { not: "one" } },
616
- ],
617
- },
618
- "neg-number": { aNumber: { lt: -1 } },
619
- // The De Morgan branch. `NOT` over a conjunction was unreachable from the corpus until #387,
620
- // and it is the same wire shape a DENY-composed `nand` rule produces — byte-identical
621
- // `filter.condition` JSON, which is why the DENY spelling ported as a delete.
622
- "not-and": { NOT: { AND: [{ aBool: { equals: true } }, { aString: { not: "one" } }] } },
623
- "not-empty": { NOT: { tags: { none: {} } } },
624
- "not-exists": {
625
- AND: [
626
- { NOT: { tags: { some: { name: { equals: "private" } } } } },
627
- { NOT: { tags: { some: { name: null } } } },
628
- ],
629
- },
630
- "not-gt": { NOT: { aNumber: { gt: 1 } } },
631
- "not-lt": { NOT: { aNumber: { lt: 2 } } },
632
- "null-eq": { aOptionalString: { equals: null } },
633
- "null-eq-missing": { aOptionalString: { equals: null } },
634
- "null-ne": { aOptionalString: { not: null } },
635
- "null-not-eq": { NOT: { aOptionalString: { equals: null } } },
636
- "null-value-f2f": {
637
- OR: [
638
- { AND: [{ aOptionalString: null }, { scope: null }] },
639
- {
640
- AND: [
641
- { aOptionalString: { not: null } },
642
- { scope: { not: null } },
643
- {
644
- aOptionalString: { equals: { _ref: "scope", _container: "AdversarialResource" } },
645
- },
646
- ],
647
- },
648
- ],
649
- },
650
- "null-value-ne-const": {
651
- NOT: {
652
- AND: [{ aOptionalString: { not: null } }, { aOptionalString: { equals: "x" } }],
653
- },
654
- },
655
- // Under the explicit-null convention CEL holds a null VALUE, so `null != "x"` is TRUE and
656
- // `null == "x"` is FALSE — both definite. Prisma's bare `{ not: "x" }` drops the row under both
657
- // polarities, so the equality family is rendered so it can never depend on SQL's UNKNOWN. The
658
- // `not: null` conjunct is that rendering, and it is what the `owner`/`coOwner` declarations in
659
- // the mapper buy (cerbos/query-plan-adapters#308).
660
- "null-value-not-eq-const": {
661
- NOT: {
662
- AND: [{ aOptionalString: { not: null } }, { aOptionalString: { equals: "x" } }],
663
- },
664
- },
665
- "null-value-not-in-const": {
666
- NOT: {
667
- AND: [
668
- { aOptionalString: { not: null } },
669
- { aOptionalString: { in: ["x", "one_two"] } },
670
- ],
671
- },
672
- },
673
- "null-value-pv-not-exists": {
674
- AND: [
675
- {
676
- NOT: {
677
- AND: [
678
- { aOptionalString: { not: null } },
679
- { aOptionalString: { equals: "set" } },
680
- ],
681
- },
682
- },
683
- {
684
- NOT: {
685
- AND: [
686
- { aOptionalString: { not: null } },
687
- { aOptionalString: { equals: "same" } },
688
- ],
689
- },
690
- },
691
- {
692
- NOT: {
693
- AND: [
694
- { aOptionalString: { not: null } },
695
- { aOptionalString: { equals: "" } },
696
- ],
697
- },
698
- },
699
- {
700
- NOT: {
701
- AND: [
702
- { aOptionalString: { not: null } },
703
- { aOptionalString: { equals: "%_o" } },
704
- ],
705
- },
706
- },
707
- {
708
- NOT: {
709
- AND: [
710
- { aOptionalString: { not: null } },
711
- { aOptionalString: { equals: "X" } },
712
- ],
713
- },
714
- },
715
- {
716
- NOT: {
717
- AND: [
718
- { aOptionalString: { not: null } },
719
- { aOptionalString: { equals: "Y" } },
720
- ],
721
- },
722
- },
723
- {
724
- NOT: {
725
- AND: [
726
- { aOptionalString: { not: null } },
727
- { aOptionalString: { equals: "MIRROR" } },
728
- ],
729
- },
730
- },
731
- {
732
- NOT: {
733
- AND: [
734
- { aOptionalString: { not: null } },
735
- { aOptionalString: { equals: "filler-1" } },
736
- ],
737
- },
738
- },
739
- {
740
- NOT: {
741
- AND: [
742
- { aOptionalString: { not: null } },
743
- { aOptionalString: { equals: "filler-2" } },
744
- ],
745
- },
746
- },
747
- {
748
- NOT: {
749
- AND: [
750
- { aOptionalString: { not: null } },
751
- { aOptionalString: { equals: "filler-3" } },
752
- ],
753
- },
754
- },
755
- {
756
- NOT: {
757
- AND: [
758
- { aOptionalString: { not: null } },
759
- { aOptionalString: { equals: "filler-4" } },
760
- ],
761
- },
762
- },
763
- ],
764
- },
765
- "optional-ne": { aOptionalString: { not: "x" } },
766
- "or-eq-exists": {
767
- OR: [
768
- { aBool: { equals: true } },
769
- { tags: { some: { name: { equals: "public" } } } },
770
- ],
771
- },
772
- "or-eq-in": { OR: [{ aBool: { equals: true } }, { tags: { some: { name: "public" } } }] },
773
- "outer-attr-depth2": {
774
- AND: [
775
- {
776
- categories: { some: { subCategories: { some: { name: { equals: "finance" } } } } },
777
- },
778
- { aBool: { equals: true } },
779
- ],
780
- },
781
- "p-arith-in-lambda": {
782
- AND: [{ tags: { some: { name: { equals: "public" } } } }, { aNumber: { gt: 1 } }],
783
- },
784
- "p-double-frac": {
785
- AND: [
786
- { aNumber: { equals: 2.9999999999999996 } },
787
- { aNumber: { gt: 2 } },
788
- { aNumber: { lt: 3 } },
789
- ],
790
- },
791
- "p-hasintersection-map": {
792
- AND: [
793
- {
794
- tags: {
795
- some: { name: { in: ["public", "h\u00e9llo\ud83d\ude80", "100%_x"] } },
796
- },
797
- },
798
- { NOT: { tags: { some: { name: null } } } },
799
- ],
800
- },
801
- "p-in-null-multi": { aOptionalString: { in: ["x", "one_two"] } },
802
- "p-in-null-single": { aOptionalString: { equals: "x" } },
803
- "p-lambda-inner-f2f": {
804
- tags: {
805
- some: { tagId: { equals: { _ref: "name", _container: "AdversarialTag" } } },
806
- },
807
- },
808
- "p-not-exists-empty": {
809
- AND: [
810
- { NOT: { tags: { some: { name: { equals: "public" } } } } },
811
- { NOT: { tags: { some: { name: null } } } },
812
- ],
813
- },
814
- "p-not-ternary-null": {
815
- NOT: {
816
- OR: [
817
- { AND: [{ aOptionalString: { not: "x" } }, { aNumber: { gt: 1 } }] },
818
- {
819
- AND: [
820
- { aOptionalString: { not: "x" } },
821
- { NOT: { aOptionalString: { not: "x" } } },
822
- ],
823
- },
824
- ],
825
- },
826
- },
827
- "p-struct": { aString: { equals: "one" } },
828
- "p-ternary-in-exists": {
829
- OR: [
830
- {
831
- AND: [
832
- { aBool: { equals: true } },
833
- { tags: { some: { name: { equals: "public" } } } },
834
- ],
835
- },
836
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
837
- ],
838
- },
839
- "p-ternary-of-ternaries": {
840
- OR: [
841
- {
842
- AND: [
843
- { aBool: { equals: true } },
844
- {
845
- OR: [
846
- {
847
- AND: [{ NOT: { aString: { equals: "" } } }, { aNumber: { gt: 1 } }],
848
- },
849
- {
850
- AND: [
851
- { aString: { equals: "" } },
852
- { NOT: { aString: { equals: "" } } },
853
- ],
854
- },
855
- ],
856
- },
857
- ],
858
- },
859
- {
860
- AND: [
861
- { aBool: { equals: false } },
862
- {
863
- OR: [
864
- { NOT: { aNumber: { lt: 0 } } },
865
- { AND: [{ aNumber: { lt: 0 } }, { NOT: { aNumber: { lt: 0 } } }] },
866
- ],
867
- },
868
- ],
869
- },
870
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
871
- ],
872
- },
873
- "p-ternary-under-all": {
874
- OR: [
875
- {
876
- AND: [
877
- { aBool: { equals: true } },
878
- {
879
- AND: [
880
- { tags: { every: { name: { not: "private" } } } },
881
- { tags: { none: { name: null } } },
882
- ],
883
- },
884
- ],
885
- },
886
- { aBool: { equals: false } },
887
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
888
- ],
889
- },
890
- "p-ternary-vs-ternary": {
891
- OR: [
892
- {
893
- AND: [
894
- { aBool: { equals: true } },
895
- {
896
- OR: [
897
- { AND: [{ aString: { equals: "" } }, { aNumber: { gt: 1 } }] },
898
- {
899
- AND: [{ NOT: { aString: { equals: "" } } }, { aNumber: { gt: 2 } }],
900
- },
901
- {
902
- AND: [
903
- { aString: { equals: "" } },
904
- { NOT: { aString: { equals: "" } } },
905
- ],
906
- },
907
- ],
908
- },
909
- ],
910
- },
911
- {
912
- AND: [
913
- { aBool: { equals: false } },
914
- {
915
- OR: [
916
- {
917
- AND: [
918
- { aString: { equals: "" } },
919
- { NOT: { aString: { equals: "" } } },
920
- ],
921
- },
922
- ],
923
- },
924
- ],
925
- },
926
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
927
- ],
928
- },
929
- "pv-all": {
930
- AND: [
931
- { aOptionalString: { not: "set" } },
932
- { aOptionalString: { not: "same" } },
933
- { aOptionalString: { not: "" } },
934
- { aOptionalString: { not: "%_o" } },
935
- { aOptionalString: { not: "X" } },
936
- { aOptionalString: { not: "Y" } },
937
- { aOptionalString: { not: "MIRROR" } },
938
- { aOptionalString: { not: "filler-1" } },
939
- { aOptionalString: { not: "filler-2" } },
940
- { aOptionalString: { not: "filler-3" } },
941
- { aOptionalString: { not: "filler-4" } },
942
- ],
943
- },
944
- "pv-all-unrolled": {
945
- AND: [
946
- { aOptionalString: { not: "set" } },
947
- {
948
- AND: [{ aOptionalString: { not: "" } }, { aOptionalString: { not: "%_o" } }],
949
- },
950
- ],
951
- },
952
- "pv-exists": {
953
- OR: [
954
- { aOptionalString: { equals: "set" } },
955
- { aOptionalString: { equals: "same" } },
956
- { aOptionalString: { equals: "" } },
957
- { aOptionalString: { equals: "%_o" } },
958
- { aOptionalString: { equals: "X" } },
959
- { aOptionalString: { equals: "Y" } },
960
- { aOptionalString: { equals: "MIRROR" } },
961
- { aOptionalString: { equals: "filler-1" } },
962
- { aOptionalString: { equals: "filler-2" } },
963
- { aOptionalString: { equals: "filler-3" } },
964
- { aOptionalString: { equals: "filler-4" } },
965
- ],
966
- },
967
- "pv-exists-unrolled": {
968
- OR: [
969
- { aOptionalString: { equals: "set" } },
970
- {
971
- OR: [
972
- { aOptionalString: { equals: "" } },
973
- { aOptionalString: { equals: "%_o" } },
974
- ],
975
- },
976
- ],
977
- },
978
- "rel-bool-hop": { parent: { is: { aBool: { equals: true } } } },
979
- "rel-bool-hop2": { parent: { is: { inner: { is: { aBool: { equals: true } } } } } },
980
- "rel-contains-hop": { parent: { is: { aString: { contains: "done" } } } },
981
- "rel-eq-hop": { parent: { is: { aString: { equals: "One" } } } },
982
- "rel-eq-num-hop": { parent: { is: { aNumber: { equals: 2 } } } },
983
- "rel-ge-hop": { parent: { is: { aNumber: { gte: 2 } } } },
984
- "rel-gt-hop": { parent: { is: { aNumber: { gt: 2 } } } },
985
- "rel-hop-and-root": {
986
- AND: [
987
- { aBool: { equals: true } },
988
- { parent: { is: { aString: { contains: "re" } } } },
989
- ],
990
- },
991
- "rel-hop2-or-exists": {
992
- OR: [
993
- { parent: { is: { inner: { is: { aBool: { equals: true } } } } } },
994
- { categories: { some: { name: { equals: "business" } } } },
995
- ],
996
- },
997
- "rel-le-hop": { parent: { is: { aNumber: { lte: 2 } } } },
998
- "rel-lt-hop": { parent: { is: { aNumber: { lt: 2 } } } },
999
- "rel-ne-null-hop": { parent: { is: { aOptionalString: { not: null } } } },
1000
- // The absent-parent guard. `NOT { parent: { is: P } }` is satisfied by a row with no parent at
1001
- // all, while CEL raises a missing-attribute error on the hop and check() denies. The first
1002
- // conjunct — an existence test on the hop itself — is what keeps a negated shape over an
1003
- // OPTIONAL to-one relation from over-granting. The positive shapes cannot discriminate it,
1004
- // which is why the corpus carries the negated ones (ADR 0005).
1005
- "rel-not-bool-hop": {
1006
- AND: [
1007
- { parent: { is: {} } },
1008
- { NOT: { parent: { is: { aBool: { equals: true } } } } },
1009
- ],
1010
- },
1011
- "rel-range-hop": {
1012
- AND: [
1013
- { parent: { is: { aNumber: { gt: 2 } } } },
1014
- { parent: { is: { aNumber: { lt: 12 } } } },
1015
- ],
1016
- },
1017
- "rel-startswith-hop2": { parent: { is: { inner: { is: { aString: { startsWith: "100" } } } } } },
1018
- "root-bare-bool": { aBool: { equals: true } },
1019
- "root-or": { OR: [{ aBool: { equals: true } }, { aNumber: { lt: 0 } }] },
1020
- "ternary-bare": {
1021
- OR: [
1022
- { AND: [{ aBool: { equals: true } }, { aString: { equals: "one" } }] },
1023
- { AND: [{ aBool: { equals: false } }, { aNumber: { lt: 0 } }] },
1024
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
1025
- ],
1026
- },
1027
- "ternary-cmp": {
1028
- OR: [
1029
- { AND: [{ aBool: { equals: true } }, { aNumber: { gt: 1 } }] },
1030
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
1031
- ],
1032
- },
1033
- "ternary-expr-cond": {
1034
- OR: [
1035
- { AND: [{ aString: { startsWith: "100" } }, { aNumber: { gte: 0 } }] },
1036
- {
1037
- AND: [
1038
- { aString: { startsWith: "100" } },
1039
- { NOT: { aString: { startsWith: "100" } } },
1040
- ],
1041
- },
1042
- ],
1043
- },
1044
- "ternary-negated": {
1045
- NOT: {
1046
- OR: [
1047
- { AND: [{ aBool: { equals: true } }, { aNumber: { gt: 1 } }] },
1048
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
1049
- ],
1050
- },
1051
- },
1052
- "ternary-nested": {
1053
- OR: [
1054
- {
1055
- AND: [
1056
- { aBool: { equals: true } },
1057
- {
1058
- OR: [
1059
- {
1060
- AND: [{ NOT: { aString: { equals: "" } } }, { aNumber: { gte: 2 } }],
1061
- },
1062
- {
1063
- AND: [
1064
- { aString: { equals: "" } },
1065
- { NOT: { aString: { equals: "" } } },
1066
- ],
1067
- },
1068
- ],
1069
- },
1070
- ],
1071
- },
1072
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
1073
- ],
1074
- },
1075
- "ternary-null-cond": {
1076
- OR: [
1077
- { AND: [{ aOptionalString: { not: "x" } }, { aNumber: { gt: 1 } }] },
1078
- {
1079
- AND: [
1080
- { aOptionalString: { not: "x" } },
1081
- { NOT: { aOptionalString: { not: "x" } } },
1082
- ],
1083
- },
1084
- ],
1085
- },
1086
- "ternary-value-first": {
1087
- OR: [
1088
- { AND: [{ aBool: { equals: true } }, { aNumber: { gt: 0 } }] },
1089
- { AND: [{ aBool: { equals: true } }, { aBool: { equals: false } }] },
1090
- ],
1091
- },
1092
- "triple-negation": { NOT: { NOT: { aBool: { equals: false } } } },
1093
- "ts-eq": { createdAt: { equals: "2024-06-01T00:00:00.000Z" } },
1094
- "ts-eq-offset": { createdAt: { equals: "2024-06-01T00:00:00.000Z" } },
1095
- "ts-ne": { createdAt: { not: "2024-06-01T00:00:00.000Z" } },
1096
- "unicode-eq": { aString: { equals: "h\u00e9llo\ud83d\ude80" } },
1097
- "vf-ge": { aNumber: { lte: 2 } },
1098
- "vf-hasint": { tags: { some: { name: { in: ["public", "other"] } } } },
1099
- "vf-le": { aNumber: { gte: 3 } },
1100
- "vf-lt": { aNumber: { gt: 1 } },
1101
- "vf-ne": { aString: { not: "one" } },
1102
- "vf-null-ne": { aOptionalString: { not: null } },
1103
- "vf-size": { tags: { some: {} } },
1104
- "w1-exists-chain": {
1105
- categories: { some: { subCategories: { some: { name: { equals: "finance" } } } } },
1106
- },
1107
- "w1-in-chain": { categories: { some: { subCategories: { some: { name: "finance" } } } } },
1108
- // The same absent-hop guard as `rel-not-bool-hop`, one level up a multi-hop chain: the negation
1109
- // is joined through every intermediate relation, never off the root, and the leading existence
1110
- // test is what stops a row with no category at all from satisfying the negation.
1111
- "w1-not-exists-chain": {
1112
- AND: [
1113
- { categories: { some: {} } },
1114
- {
1115
- NOT: {
1116
- categories: { some: { subCategories: { some: { name: { equals: "finance" } } } } },
1117
- },
1118
- },
1119
- ],
1120
- },
1121
- "w1-not-hasint-chain": {
1122
- AND: [
1123
- { categories: { some: {} } },
1124
- {
1125
- NOT: {
1126
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1127
- },
1128
- },
1129
- ],
1130
- },
1131
- "w1-not-in-chain": {
1132
- AND: [
1133
- { categories: { some: {} } },
1134
- {
1135
- NOT: {
1136
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1137
- },
1138
- },
1139
- ],
1140
- },
1141
- "w1-not-size-chain": {
1142
- AND: [
1143
- { categories: { some: {} } },
1144
- { NOT: { categories: { some: { subCategories: { some: {} } } } } },
1145
- ],
1146
- },
1147
- "w1-size-chain": { categories: { some: { subCategories: { some: {} } } } },
1148
- "w1-size-zero-chain": { categories: { some: { subCategories: { none: {} } } } },
1149
- "w1-ternary-chain-cond": {
1150
- OR: [
1151
- {
1152
- AND: [
1153
- {
1154
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1155
- },
1156
- { aBool: { equals: true } },
1157
- ],
1158
- },
1159
- {
1160
- AND: [
1161
- {
1162
- AND: [
1163
- { categories: { some: {} } },
1164
- {
1165
- NOT: {
1166
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1167
- },
1168
- },
1169
- ],
1170
- },
1171
- { aBool: { equals: false } },
1172
- ],
1173
- },
1174
- {
1175
- AND: [
1176
- {
1177
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1178
- },
1179
- {
1180
- AND: [
1181
- { categories: { some: {} } },
1182
- {
1183
- NOT: {
1184
- categories: { some: { subCategories: { some: { name: "finance" } } } },
1185
- },
1186
- },
1187
- ],
1188
- },
1189
- ],
1190
- },
1191
- ],
1192
- },
1193
- };
1194
-
1195
- describe("corpus shapes", () => {
1196
- test.each(Object.entries(EXPECTED_FILTERS))(
1197
- "%s emits the pinned filter",
1198
- (action, filters) => {
1199
- expect(translate(action)).toStrictEqual({
1200
- kind: PlanKind.CONDITIONAL,
1201
- filters,
1202
- });
63
+ try {
64
+ await translate("collection/index/first-element-of-string-list");
65
+ } catch (error) {
66
+ raised = error;
1203
67
  }
1204
- );
1205
68
 
1206
- test.each(Object.entries(EXPECTED_KINDS))(
1207
- "%s resolves without a condition",
1208
- (action, kind) => {
1209
- expect(translate(action)).toStrictEqual({ kind });
1210
- }
1211
- );
69
+ expect(raised).toBeInstanceOf(UnsupportedQueryPlanError);
70
+ expect(raised).toBeInstanceOf(Error);
71
+ expect(raised).toMatchObject({ name: "UnsupportedQueryPlanError" });
72
+ });
1212
73
 
1213
- // The message, not just the throw: a mapper typo or an unrelated validation satisfies a bare
1214
- // `toThrow()` just as well as the limitation the corpus documents (#326). The harness makes the
1215
- // same assertion against a live PDP; here it costs a millisecond and covers the whole roster,
1216
- // which is what lets the completeness guard below be total.
1217
- test.each(THROWING_ACTIONS)(
1218
- "%s is refused with the message actions.json pins (%s)",
1219
- (action, _reason, message) => {
1220
- expect(() => translate(action)).toThrow(message);
74
+ test("mapper misconfiguration is a plain Error, not a refusal", async () => {
75
+ const queryPlan = await planFor("comparison/equals/field-to-field");
76
+ let raised: unknown;
77
+
78
+ try {
79
+ queryPlanToPrisma({ queryPlan, mapper: MAPPER });
80
+ } catch (error) {
81
+ raised = error;
1221
82
  }
1222
- );
1223
83
 
1224
- test("every corpus action is classified here exactly once", () => {
1225
- const filters = Object.keys(EXPECTED_FILTERS);
1226
- const kinds = Object.keys(EXPECTED_KINDS);
1227
- const throwing = THROWING_ACTIONS.map(([action]) => action);
1228
- const classified = [...filters, ...kinds, ...throwing].sort();
1229
-
1230
- // Total: a corpus action with no entry here lands as a failure rather than as silence. This
1231
- // is the assertion that makes the file self-maintaining — adding a hostile shape to the
1232
- // corpus forces someone to look at the filter this adapter emits for it.
1233
- expect(classified).toEqual(wireFixtureActions());
1234
- // Disjoint: an action pinned as a filter AND declared unsupported would satisfy the union
1235
- // above while asserting two contradictory things.
1236
- expect(classified).toEqual([...new Set(classified)].sort());
1237
- // The table is alphabetical, so a translator change reads as the list of shapes it moved.
1238
- expect(filters).toEqual([...filters].sort());
1239
-
1240
- // Tripwires. Bump them deliberately: a count that moves without anyone noticing is how a
1241
- // shape gets dropped from a table nobody reads end to end.
1242
- expect({
1243
- filters: filters.length,
1244
- kinds: kinds.length,
1245
- throwing: throwing.length,
1246
- }).toEqual({ filters: 136, kinds: 2, throwing: 61 });
84
+ expect(raised).toBeInstanceOf(Error);
85
+ expect(raised).not.toBeInstanceOf(UnsupportedQueryPlanError);
86
+ expect(raised).toHaveProperty("message", expect.stringContaining("requires the `model` option"));
87
+ });
88
+ });
89
+
90
+ describe("declared scalar types", () => {
91
+ test("an undeclared type preserves the legacy mapper contract", async () => {
92
+ expect(
93
+ await translate("type-mismatch/equals/string-field-against-number-principal", {
94
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
95
+ }),
96
+ ).toEqual({
97
+ kind: PlanKind.CONDITIONAL,
98
+ filters: { aString: { equals: 0 } },
99
+ });
1247
100
  });
1248
101
  });
1249
102
 
103
+ /** The shared mapper with every per-attribute null declaration stripped. */
104
+ const UNDECLARED: Record<string, MapperConfig> = Object.fromEntries(
105
+ Object.entries(MAPPER).map(([reference, config]) => {
106
+ const { nullAttributeRepresentation: _stripped, ...rest } = config;
107
+
108
+ return [reference, rest];
109
+ })
110
+ );
111
+
112
+ /** Whether any operand anywhere in the wire plan is a literal null, or a list containing one. */
113
+ function carriesNullLiteral(node: JsonValue | undefined): boolean {
114
+ if (Array.isArray(node)) return node.some(carriesNullLiteral);
115
+
116
+ if (!isObject(node)) return false;
117
+ const record = node;
118
+
119
+ if ("value" in record) {
120
+ const value = record["value"];
121
+
122
+ return value === null || (Array.isArray(value) && value.includes(null));
123
+ }
124
+
125
+ return Object.values(record).some((child) =>
126
+ Array.isArray(child) ? child.some(carriesNullLiteral) : carriesNullLiteral(child)
127
+ );
128
+ }
129
+
1250
130
  describe("nullAttributeRepresentation", () => {
1251
- // `null-eq-missing` is the corpus's `nullRepresentationOmitted` probe: `== null` against an
1252
- // attribute the caller OMITS when the column is NULL. The two conventions are indistinguishable
1253
- // on the wire — the planner emits the same `eq(attr, null)` either way — so the adapter has to
1254
- // be told, and the whole behaviour is a translator property with no store in it.
1255
- test("explicit: a null operand becomes an IS NULL filter", () => {
131
+ // `== null` against an attribute the caller OMITS when the column is NULL. The two conventions
132
+ // are indistinguishable on the wire — the planner emits the same `eq(attr, null)` either way — so
133
+ // the adapter has to be told. The corpus mapping declares it per attribute; these vary the
134
+ // call-level option and the declaration, which the corpus cannot.
135
+ const MISSING = "null/equals/null-literal-on-missing-attribute";
136
+ const NEVER = { kind: PlanKind.CONDITIONAL, filters: { NOT: { aOptionalString: { startsWith: "" } } } };
137
+
138
+ test("unset: the default is omitted, so == null selects no row", async () => {
139
+ // The default is the convention whose wrong guess fails closed (ADR 0011).
1256
140
  expect(
1257
- translate("null-eq-missing", { nullAttributeRepresentation: "explicit" })
1258
- ).toStrictEqual({
141
+ queryPlanToPrisma({ queryPlan: await planFor(MISSING), mapper: UNDECLARED, model: MODEL })
142
+ ).toEqual(NEVER);
143
+ });
144
+
145
+ test("explicit: == null is an IS NULL filter", async () => {
146
+ expect(await translate(MISSING, { mapper: UNDECLARED })).toStrictEqual({
1259
147
  kind: PlanKind.CONDITIONAL,
1260
148
  filters: { aOptionalString: { equals: null } },
1261
149
  });
1262
150
  });
1263
151
 
1264
- test("omitted: the same plan is refused rather than translated", () => {
152
+ test("omitted: the same plan selects no row rather than the NULL ones", async () => {
1265
153
  // A NULL column sends no attribute, so check() denies on a missing-attribute error while the
1266
- // filter above would return exactly those rows (#302).
1267
- expect(() =>
1268
- translate("null-eq-missing", { nullAttributeRepresentation: "omitted" })
1269
- ).toThrow("missing-attribute error");
154
+ // IS NULL filter would return exactly those rows (#302); a present value is never null, so
155
+ // `== null` is FALSE for it — `NOT LIKE '%'`, which stays UNKNOWN on the NULL rows.
156
+ expect(
157
+ await translate(MISSING, { mapper: UNDECLARED, nullAttributeRepresentation: "omitted" })
158
+ ).toEqual(NEVER);
159
+ });
160
+
161
+ test("a per-attribute declaration overrides the call-level option, in both directions", async () => {
162
+ // Declared omitted, called explicit: no IS NULL filter (#308).
163
+ expect(await translate(MISSING)).toEqual(NEVER);
164
+ // Declared explicit (`owner`), called omitted: translated.
165
+ expect(
166
+ (await translate("null/equals/null-literal", { nullAttributeRepresentation: "omitted" })).kind
167
+ ).toBe(PlanKind.CONDITIONAL);
168
+ // Strip the declaration and the same call selects no row.
169
+ expect(
170
+ await translate("null/equals/null-literal", {
171
+ mapper: UNDECLARED,
172
+ nullAttributeRepresentation: "omitted",
173
+ })
174
+ ).toEqual(NEVER);
175
+ });
176
+
177
+ // The rejection must key off the null OPERAND, not a list of operators: `hasIntersection(tagNames,
178
+ // ["public", null])` carries one in its value list. Enumerating the goldens rather than naming
179
+ // shapes covers a newly added case carrying a null constant automatically.
180
+ // A plan whose null comparison settles passes too: `x == null` is FALSE for a present value and
181
+ // an error for a missing one, rendered with presence tests that are UNKNOWN on NULL. Any
182
+ // NULL-selecting filter needs an IS NULL leaf, i.e. a null literal in the emitted where-input.
183
+ test("no plan carrying a null literal emits an IS NULL filter under call-level omitted", async () => {
184
+ const carrying = readGoldens(CURRENT).filter((golden) => carriesNullLiteral(golden.plan));
185
+ const ids = carrying.map((golden) => golden.id);
186
+ expect(ids).toContain(MISSING);
187
+ expect(ids).toContain("null/has-intersection/literal-list-with-null-element");
188
+
189
+ const plans = await Promise.all(carrying.map((golden) => planOf(golden)));
190
+
191
+ const notRejected = carrying.flatMap((golden, index) => {
192
+ try {
193
+ const result = queryPlanToPrisma({
194
+ queryPlan: plans[index]!,
195
+ mapper: UNDECLARED,
196
+ model: MODEL,
197
+ nullAttributeRepresentation: "omitted",
198
+ });
199
+
200
+ return result.kind === PlanKind.CONDITIONAL && JSON.stringify(result.filters).includes("null")
201
+ ? [golden.id]
202
+ : [];
203
+ } catch (error) {
204
+ // A positional read of a list compares an element, not an optionally absent field; the
205
+ // index operator has no Prisma filter form under either representation. A null inside a
206
+ // whole-list literal is an element too, and comparing a list is refused under either, as
207
+ // is an `in` over a computed member. A null arithmetic operand has no CEL overload, so its
208
+ // fold is refused under either.
209
+ const message = String(error);
210
+
211
+ return message.includes("missing-attribute error") ||
212
+ message.includes("Unsupported operator: index") ||
213
+ message.includes("in requires a field member and literal list") ||
214
+ message.includes("Prisma cannot compare list or map elements") ||
215
+ message.includes("in over a scalar is a CEL error") ||
216
+ message.includes("operator requires string or number operands")
217
+ ? []
218
+ : [`${golden.id} (${message})`];
219
+ }
220
+ });
221
+
222
+ expect(notRejected).toEqual([]);
223
+ });
224
+ });
225
+
226
+ describe("reentrant function mappers", () => {
227
+ // Function mappers are caller-supplied, so the corpus cannot exercise nested adapter calls. The
228
+ // nested call's "omitted" must not leak into the outer "explicit" translation of `== null`.
229
+ test.each([
230
+ "null/equals/null-literal-on-missing-attribute",
231
+ "collection/all/empty-collection",
232
+ ])("keeps %s isolated from a nested translation", async (action) => {
233
+ const expected = await translate(action, { mapper: UNDECLARED });
234
+ const nested = await planFor("arithmetic/add/field-plus-constant");
235
+ let calls = 0;
236
+
237
+ const mapper: Mapper = (key) => {
238
+ calls++;
239
+ queryPlanToPrisma({
240
+ queryPlan: nested,
241
+ mapper: MAPPER,
242
+ model: "NestedModel",
243
+ nullAttributeRepresentation: "omitted",
244
+ });
245
+
246
+ return UNDECLARED[key] ?? { field: key };
247
+ };
248
+
249
+ expect(await translate(action, { mapper })).toStrictEqual(expected);
250
+ expect(calls).toBeGreaterThan(0);
251
+ });
252
+ });
253
+
254
+ test("an unplannable nested map does not register nullable fields on the outer lambda", async () => {
255
+ // CEL cannot reach this branch: Cerbos 0.54.0 rejects
256
+ // R.attr.tags.all(t, R.attr.tags.map(x, x.name)) with
257
+ // "expected type 'bool' but found 'list(dyn)'". This is a hand-crafted plan contract. A list
258
+ // where a boolean is required is a CEL error, so the body settles to false (all() holds only
259
+ // over no tags) before any lambda scope is entered.
260
+ const condition: PlanExpressionOperand = {
261
+ operator: "all",
262
+ operands: [
263
+ { name: "request.resource.attr.tags" },
264
+ {
265
+ operator: "lambda",
266
+ operands: [
267
+ {
268
+ operator: "map",
269
+ operands: [
270
+ { name: "request.resource.attr.tags" },
271
+ {
272
+ operator: "lambda",
273
+ operands: [{ name: "x.name" }, { name: "x" }],
274
+ },
275
+ ],
276
+ },
277
+ { name: "t" },
278
+ ],
279
+ },
280
+ ],
281
+ };
282
+
283
+ const result = queryPlanToPrisma({
284
+ queryPlan: { ...(await planFor("collection/all/empty-collection")), kind: PlanKind.CONDITIONAL, condition },
285
+ mapper: MAPPER,
286
+ model: MODEL,
287
+ });
288
+
289
+ expect(result).toStrictEqual({
290
+ kind: PlanKind.CONDITIONAL,
291
+ filters: { tags: { none: {} } },
1270
292
  });
1271
293
  });
1272
294
 
1273
295
  describe("timestamp literals", () => {
1274
- // `regenerate-wire-fixtures.sh` rewrites the folded `now() - duration("24h")` literal in
1275
- // `ts-window` to a placeholder, because it differs on every capture. That makes this the one
1276
- // fixture whose value the reader chooses — so it is also the one place the whole timestamp
1277
- // boundary can be walked, by substituting the instant and asking what the adapter does with it.
1278
- const at = (plannedAt: string) =>
296
+ // The golden records the folded `now() - duration("24h")` literal as `__NOW_MINUS_24H__`, because
297
+ // it differs on every capture. That makes this the one plan whose value the reader chooses — so
298
+ // it is also the one place the whole timestamp boundary can be walked, by substituting the
299
+ // instant and asking what the adapter does with it.
300
+ const at = async (plannedAt: string) =>
1279
301
  queryPlanToPrisma({
1280
- queryPlan: planFromWireFixture("ts-window", plannedAt),
302
+ queryPlan: await planFor("timestamp/less-than/relative-window", plannedAt),
1281
303
  mapper: MAPPER,
1282
304
  model: MODEL,
1283
305
  });
1284
306
 
1285
- test("a nanosecond instant — what the PDP actually folds — is refused", () => {
1286
- // This, and nothing else, is why `ts-window` and `ts-vf` are `adapterUnsupported`. A tidy
1287
- // millisecond substitution in the loader would translate cleanly and quietly contradict
1288
- // actions.json.
1289
- expect(() => translate("ts-window")).toThrow(
1290
- "Timestamp value exceeds millisecond precision"
1291
- );
1292
- });
1293
-
1294
- test("the same plan at millisecond precision translates", () => {
1295
- expect(at("2026-08-11T09:13:39.123Z")).toStrictEqual({
307
+ test("a nanosecond instant — what the PDP actually folds — compares against the next millisecond", async () => {
308
+ // A DateTime attribute is a whole millisecond, so `a < T` for a T between two milliseconds is
309
+ // `a < ceil(T)`. The corpus loader substitutes a nanosecond instant for the same reason.
310
+ expect(await at("2026-08-11T09:13:39.123456789Z")).toStrictEqual({
1296
311
  kind: PlanKind.CONDITIONAL,
1297
- filters: { createdAt: { lt: "2026-08-11T09:13:39.123Z" } },
312
+ filters: { createdAt: { lt: "2026-08-11T09:13:39.124Z" } },
1298
313
  });
1299
314
  });
1300
315
 
1301
- test("excess fractional digits are accepted only when they are zero", () => {
1302
- expect(at("2026-08-11T09:13:39.123000Z")).toStrictEqual({
316
+ test("excess fractional digits are accepted only when they are zero", async () => {
317
+ expect(await at("2026-08-11T09:13:39.123000Z")).toStrictEqual({
1303
318
  kind: PlanKind.CONDITIONAL,
1304
319
  filters: { createdAt: { lt: "2026-08-11T09:13:39.123Z" } },
1305
320
  });
@@ -1312,10 +327,9 @@ describe("timestamp literals", () => {
1312
327
  ["a date with no time part", "2024-01-01"],
1313
328
  ["a year outside CEL's instant range", "0000-01-01T00:00:00Z"],
1314
329
  ["a day that does not exist", "2024-02-30T00:00:00Z"],
1315
- ["sub-millisecond precision", "2024-01-01T00:00:00.1234Z"],
1316
330
  ["an offset that pushes past the maximum instant", "9999-12-31T23:00:00-02:00"],
1317
- ])("%s fails closed", (_label, value) => {
1318
- expect(() => at(value)).toThrow(/RFC 3339|millisecond|instant range/);
331
+ ])("%s fails closed", async (_label, value) => {
332
+ await expect(at(value)).rejects.toThrow(/RFC 3339|millisecond|instant range/);
1319
333
  });
1320
334
  });
1321
335
 
@@ -1331,40 +345,45 @@ describe("relation subqueryFilter", () => {
1331
345
  */
1332
346
  const VISIBLE_ONLY: PrismaFilter = { name: { not: "hidden" } };
1333
347
 
1334
- const mapperFor = (subqueryFilter?: PrismaFilter): Mapper => ({
1335
- "request.resource.attr.tags": {
1336
- relation: {
1337
- name: "tags",
1338
- type: "many",
1339
- fields: { name: { field: "name" } },
1340
- ...(subqueryFilter ? { subqueryFilter } : {}),
1341
- },
1342
- },
1343
- });
348
+ const mapperFor = (subqueryFilter?: PrismaFilter): Mapper => {
349
+ const relation: Relation = {
350
+ name: "tags",
351
+ type: "many",
352
+ // `nullable: false` keeps the element NULL guard (see "relation element nullability"
353
+ // below) out of these filters, so they isolate what the declaration changes.
354
+ fields: { name: { field: "name", nullable: false } },
355
+ };
356
+
357
+ if (subqueryFilter) relation.subqueryFilter = subqueryFilter;
358
+
359
+ return { "request.resource.attr.tags": { relation } };
360
+ };
361
+
362
+ const filtersFor = async (action: string, subqueryFilter?: PrismaFilter) => {
363
+ const result = await translate(action, { mapper: mapperFor(subqueryFilter) });
1344
364
 
1345
- const filtersFor = (action: string, subqueryFilter?: PrismaFilter) => {
1346
- const result = translate(action, { mapper: mapperFor(subqueryFilter) });
1347
365
  if (result.kind !== PlanKind.CONDITIONAL) {
1348
366
  throw new Error(`Expected CONDITIONAL result for ${action}`);
1349
367
  }
368
+
1350
369
  return result.filters;
1351
370
  };
1352
371
 
1353
- test("declared: exists() examines only the records the application serialised", () => {
1354
- expect(filtersFor("exists-on-empty", VISIBLE_ONLY)).toStrictEqual({
372
+ test("declared: exists() examines only the records the application serialised", async () => {
373
+ expect(await filtersFor("collection/exists/empty-collection", VISIBLE_ONLY)).toStrictEqual({
1355
374
  tags: {
1356
375
  some: { AND: [{ name: { not: "hidden" } }, { name: { equals: "public" } }] },
1357
376
  },
1358
377
  });
1359
378
  });
1360
379
 
1361
- test("declared: all() narrows the records examined, not the records required", () => {
380
+ test("declared: all() narrows the records examined, not the records required", async () => {
1362
381
  // `every: AND(visible, P)` would REQUIRE every record to be visible, dropping any row that
1363
382
  // holds a hidden tag. The rewrite to `none: AND(visible, NOT P)` is what makes the
1364
383
  // declaration mean "ignore what the application hides" — including the empty-collection case,
1365
384
  // where CEL's all() is vacuously true and check() agrees because the application sent an
1366
385
  // empty list for the same reason.
1367
- expect(filtersFor("all-on-empty", VISIBLE_ONLY)).toStrictEqual({
386
+ expect(await filtersFor("collection/all/empty-collection", VISIBLE_ONLY)).toStrictEqual({
1368
387
  tags: {
1369
388
  none: {
1370
389
  AND: [
@@ -1376,33 +395,41 @@ describe("relation subqueryFilter", () => {
1376
395
  });
1377
396
  });
1378
397
 
1379
- test("declared: an emptiness check counts only the visible records", () => {
1380
- expect(filtersFor("not-empty", VISIBLE_ONLY)).toStrictEqual({
398
+ test("declared: an emptiness check counts only the visible records", async () => {
399
+ expect(await filtersFor("size/equals/negated-collection-zero", VISIBLE_ONLY)).toStrictEqual({
1381
400
  NOT: { tags: { none: { name: { not: "hidden" } } } },
1382
401
  });
1383
402
  });
1384
-
1385
- test("undeclared: the emitted filter is what it was before the field existed", () => {
1386
- // The non-breaking guarantee. Silence must not add a clause, and must not warn.
1387
- expect(filtersFor("exists-on-empty")).toStrictEqual({
1388
- tags: { some: { name: { equals: "public" } } },
1389
- });
1390
- expect(filtersFor("all-on-empty")).toStrictEqual({
1391
- tags: { every: { name: { equals: "public" } } },
1392
- });
1393
- expect(filtersFor("not-empty")).toStrictEqual({
1394
- NOT: { tags: { none: {} } },
1395
- });
1396
- });
1397
403
  });
1398
404
 
1399
405
  describe("the mapper contract", () => {
1400
406
  // A mapper is caller-supplied, so these are caller errors rather than policy shapes: no corpus
1401
407
  // action can produce them, because the corpus fixes one mapper per adapter.
1402
408
 
1403
- test("a function mapper resolves a scalar reference", () => {
409
+ test.each([undefined, true, false])(
410
+ "hierarchy segment nullability %s is respected",
411
+ async (nullable) => {
412
+ const result = await translate("hierarchy/overlaps/path-built-from-list-value-first", {
413
+ mapper: { "request.resource.attr.scope": { field: "scope", nullable } },
414
+ });
415
+
416
+ expect(result).toStrictEqual({
417
+ kind: PlanKind.CONDITIONAL,
418
+ filters:
419
+ nullable === false
420
+ ? {}
421
+ : {
422
+ AND: [
423
+ { OR: [{ scope: { equals: "" } }, { scope: { not: "" } }] },
424
+ ],
425
+ },
426
+ });
427
+ },
428
+ );
429
+
430
+ test("a function mapper resolves a scalar reference", async () => {
1404
431
  expect(
1405
- translate("cs-eq", {
432
+ await translate("string/equals/case-sensitive", {
1406
433
  mapper: (key: string) => ({
1407
434
  field: key.replace("request.resource.attr.", ""),
1408
435
  }),
@@ -1413,36 +440,166 @@ describe("the mapper contract", () => {
1413
440
  });
1414
441
  });
1415
442
 
1416
- test("a function mapper resolves a relation", () => {
1417
- expect(
1418
- translate("rel-bool-hop", {
1419
- mapper: () => ({
443
+ test("a key under a scalar-mapped prefix is refused, not resolved to the prefix's column", async () => {
444
+ // cerbos/query-plan-adapters#689: the plan reads aString beneath a prefix mapped to one column.
445
+ await expect(
446
+ translate("string/equals/case-sensitive", {
447
+ mapper: { "request.resource.attr": { field: "owner" } },
448
+ }),
449
+ ).rejects.toThrow(UnsupportedQueryPlanError);
450
+ });
451
+
452
+ test.each(["collection/exists/scalar-list-equals"])(
453
+ "%s resolves a projection supplied through a prefix mapper",
454
+ async (action) => {
455
+ const direct = await translate(action);
456
+
457
+ if (direct.kind !== PlanKind.CONDITIONAL) {
458
+ throw new Error("Expected a conditional projection plan");
459
+ }
460
+
461
+ const mapper: Mapper = {
462
+ "request.resource.attr": {
1420
463
  relation: {
1421
- name: "parent",
1422
- type: "one",
1423
- fields: { aBool: { field: "aBool" } },
464
+ name: "categories",
465
+ type: "many",
466
+ fields: {
467
+ tagNames: {
468
+ relation: { name: "tags", type: "many", field: "name" },
469
+ },
470
+ },
1424
471
  },
1425
- }),
472
+ },
473
+ };
474
+
475
+ expect(await translate(action, { mapper })).toStrictEqual({
476
+ kind: PlanKind.CONDITIONAL,
477
+ filters: { categories: { some: direct.filters } },
478
+ });
479
+ },
480
+ );
481
+
482
+ test("negating an overlaps that folds to an unconditional filter is refused", async () => {
483
+ // `nullable: false` on every segment lets `overlaps(hierarchy("dept"), hierarchy(["dept",
484
+ // scope]))` fold to `{}` (asserted above). Prisma evaluates `{ NOT: {} }` as true, so the
485
+ // negation would match every row where CEL's `!true` matches none; the adapter has no
486
+ // field-free false condition to emit instead (cerbos/query-plan-adapters#495).
487
+ const fixture = await planFor("hierarchy/overlaps/path-built-from-list-value-first");
488
+
489
+ if (fixture.kind !== PlanKind.CONDITIONAL) {
490
+ throw new Error("Expected a conditional hierarchy plan");
491
+ }
492
+
493
+ expect(() =>
494
+ queryPlanToPrisma({
495
+ queryPlan: {
496
+ ...fixture,
497
+ condition: { operator: "not", operands: [fixture.condition] },
498
+ },
499
+ mapper: { "request.resource.attr.scope": { field: "scope", nullable: false } },
1426
500
  })
1427
- ).toStrictEqual({
1428
- kind: PlanKind.CONDITIONAL,
1429
- filters: { parent: { is: { aBool: { equals: true } } } },
501
+ ).toThrow("Cannot negate an unconditional filter");
502
+ });
503
+
504
+ describe("relation element nullability", () => {
505
+ // Omitting the element NULL guard is what over-grants — a negated exists(), an all() or a
506
+ // hasIntersection() over map() would admit rows holding a NULL element that check() denies
507
+ // — so silence means nullable, and only `nullable: false` drops the guard
508
+ // (cerbos/query-plan-adapters#495). The corpus maps one element column per nullability, so
509
+ // it cannot vary the declaration itself.
510
+ const guarded = {
511
+ AND: [
512
+ { tags: { every: { name: { equals: "public" } } } },
513
+ { tags: { none: { name: null } } },
514
+ ],
515
+ };
516
+
517
+ const unguarded = { tags: { every: { name: { equals: "public" } } } };
518
+
519
+ const allFor = (fields?: Relation["fields"]) => {
520
+ const relation: Relation = { name: "tags", type: "many" };
521
+
522
+ if (fields) relation.fields = fields;
523
+
524
+ return translate("collection/all/empty-collection", {
525
+ mapper: { "request.resource.attr.tags": { relation } },
526
+ });
527
+ };
528
+
529
+ test.each<[string, Relation["fields"], PrismaFilter]>([
530
+ ["undeclared", { name: { field: "name" } }, guarded],
531
+ ["nullable: true", { name: { field: "name", nullable: true } }, guarded],
532
+ ["nullable: false", { name: { field: "name", nullable: false } }, unguarded],
533
+ ["an element with no field mapping at all", undefined, guarded],
534
+ ])("%s", async (_label, fields, filters) => {
535
+ expect(await allFor(fields)).toStrictEqual({
536
+ kind: PlanKind.CONDITIONAL,
537
+ filters,
538
+ });
1430
539
  });
1431
540
  });
1432
541
 
1433
- test("size() against a scalar mapping is refused rather than guessed", () => {
1434
- expect(() =>
1435
- translate("not-empty", {
542
+ test("size() against a scalar mapping is refused rather than guessed", async () => {
543
+ await expect(
544
+ translate("size/equals/negated-collection-zero", {
1436
545
  mapper: { "request.resource.attr.tags": { field: "tags" } },
1437
546
  })
1438
- ).toThrow("size operator requires a relation mapping");
547
+ ).rejects.toThrow("size operator requires a relation mapping");
548
+ });
549
+ });
550
+
551
+ describe("which patterns RE2 rejects", () => {
552
+ // matches() raises an error on a pattern RE2 rejects, which denies every row, so the adapter
553
+ // must agree with RE2 on exactly which patterns those are. The corpus pins the rejected shapes
554
+ // it carries; this pins the one the previous hand-rolled check got wrong: a digit after a
555
+ // backslash is a backreference to RE2 only on its own, and an octal escape when another octal
556
+ // digit follows, so `\11` is a tab.
557
+ const matches = (pattern: string, sibling = false): PlanResourcesResponse => {
558
+ const test: PlanExpressionOperand = {
559
+ operator: "matches",
560
+ operands: [{ name: "request.resource.attr.aString" }, { value: pattern }],
561
+ };
562
+
563
+ const condition: PlanExpressionOperand = sibling
564
+ ? {
565
+ operator: "or",
566
+ operands: [test, { operator: "eq", operands: [{ name: "request.resource.attr.aNumber" }, { value: 5 }] }],
567
+ }
568
+ : test;
569
+
570
+ return {
571
+ kind: PlanKind.CONDITIONAL,
572
+ condition,
573
+ cerbosCallId: "",
574
+ requestId: "",
575
+ validationErrors: [],
576
+ metadata: undefined,
577
+ };
578
+ };
579
+
580
+ test("an octal escape is the character it names, not a backreference", () => {
581
+ const result = queryPlanToPrisma({ queryPlan: matches("^\\11"), mapper: MAPPER, model: MODEL });
582
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
583
+ });
584
+
585
+ test("a backreference is an error on every row, so only its sibling decides", () => {
586
+ const result = queryPlanToPrisma({
587
+ queryPlan: matches("(a)\\1", true),
588
+ mapper: MAPPER,
589
+ model: MODEL,
590
+ });
591
+
592
+ expect(result).toEqual({ kind: PlanKind.CONDITIONAL, filters: { aNumber: { equals: 5 } } });
593
+ expect(queryPlanToPrisma({ queryPlan: matches("\\8"), mapper: MAPPER, model: MODEL })).toEqual({
594
+ kind: PlanKind.ALWAYS_DENIED,
595
+ });
1439
596
  });
1440
597
  });
1441
598
 
1442
599
  describe("plans the planner cannot produce", () => {
1443
600
  // Input validation on a public function, not policy shapes. Every other assertion in this file
1444
- // reads its plan from a fixture precisely because a typed plan is a belief about the planner —
1445
- // but these are malformed by construction, so there is no fixture to read and nothing to
601
+ // reads its plan from a golden file precisely because a typed plan is a belief about the planner —
602
+ // but these are malformed by construction, so there is no golden to read and nothing to
1446
603
  // believe. They exist so a caller who hands the adapter a hand-rolled or half-decoded plan gets
1447
604
  // an error rather than a filter.
1448
605
  //
@@ -1451,20 +608,28 @@ describe("plans the planner cannot produce", () => {
1451
608
  // pinned here by hand-built plan before #377 and are now
1452
609
  // [#394](https://github.com/cerbos/query-plan-adapters/issues/394).
1453
610
 
1454
- const plan = (condition: unknown): PlanResourcesResponse =>
1455
- ({
1456
- kind: PlanKind.CONDITIONAL,
1457
- condition: condition as PlanExpressionOperand,
1458
- cerbosCallId: "",
1459
- requestId: "",
1460
- validationErrors: [],
1461
- metadata: undefined,
1462
- }) as PlanResourcesResponse;
611
+ const plan = (condition: Value): PlanResourcesResponse =>
612
+ overwritten(
613
+ {
614
+ kind: PlanKind.CONDITIONAL,
615
+ condition: { value: true },
616
+ cerbosCallId: "",
617
+ requestId: "",
618
+ validationErrors: [],
619
+ metadata: undefined,
620
+ },
621
+ "condition",
622
+ condition,
623
+ );
1463
624
 
1464
625
  test("an unrecognised plan kind", () => {
1465
626
  expect(() =>
1466
627
  queryPlanToPrisma({
1467
- queryPlan: { kind: "INVALID_KIND" } as unknown as PlanResourcesResponse,
628
+ queryPlan: overwritten(
629
+ { kind: PlanKind.ALWAYS_ALLOWED, cerbosCallId: "", requestId: "", validationErrors: [], metadata: undefined },
630
+ "kind",
631
+ "INVALID_KIND"
632
+ ),
1468
633
  mapper: {},
1469
634
  })
1470
635
  ).toThrow("Invalid query plan.");
@@ -1570,17 +735,32 @@ describe("plans the planner cannot produce", () => {
1570
735
  ).toThrow("exists over a literal collection requires a list value");
1571
736
  });
1572
737
 
1573
- test("a constant-false predicate the planner should have folded", () => {
738
+ // An empty conjunction is `{ AND: [] }` in Prisma, which matches every row, and the constant
739
+ // folder would otherwise reduce it to `true` before the translator saw it
740
+ // (cerbos/query-plan-adapters#495). The empty disjunction is refused alongside it: neither is
741
+ // something the planner emits.
742
+ test.each<[string, Value, string]>([
743
+ ["an empty and", { operator: "and", operands: [] }, "and requires at least one operand"],
744
+ ["an empty or", { operator: "or", operands: [] }, "or requires at least one operand"],
745
+ [
746
+ "an empty and under an or",
747
+ {
748
+ operator: "or",
749
+ operands: [
750
+ { name: "request.resource.attr.aBool" },
751
+ { operator: "and", operands: [] },
752
+ ],
753
+ },
754
+ "and requires at least one operand",
755
+ ],
756
+ [
757
+ "an empty and under a not",
758
+ { operator: "not", operands: [{ operator: "and", operands: [] }] },
759
+ "and requires at least one operand",
760
+ ],
761
+ ])("%s", (_label, condition, message) => {
1574
762
  expect(() =>
1575
- queryPlanToPrisma({
1576
- queryPlan: plan({
1577
- operator: "if",
1578
- operands: [{ value: true }, { value: false }, { value: true }],
1579
- }),
1580
- mapper: MAPPER,
1581
- })
1582
- ).toThrow(
1583
- "A constant-false conditional predicate must be folded by the Cerbos planner"
1584
- );
763
+ queryPlanToPrisma({ queryPlan: plan(condition), mapper: MAPPER })
764
+ ).toThrow(message);
1585
765
  });
1586
766
  });