@cerbos/orm-prisma 3.3.0 → 4.1.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 (74) hide show
  1. package/README.md +243 -10
  2. package/lib/index.d.ts +102 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1754 -155
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -15
  7. package/src/adversarial.test.ts +1281 -0
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +54 -0
  10. package/src/generated/prisma-adversarial/client.ts +76 -0
  11. package/src/generated/{prisma → prisma-adversarial}/commonInputTypes.ts +131 -27
  12. package/src/generated/prisma-adversarial/enums.ts +15 -0
  13. package/src/generated/prisma-adversarial/internal/class.ts +264 -0
  14. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1332 -0
  15. package/src/generated/{prisma → prisma-adversarial}/internal/prismaNamespaceBrowser.ts +45 -47
  16. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  17. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  18. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  19. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  20. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1789 -0
  21. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  22. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  23. package/src/generated/prisma-adversarial/models.ts +18 -0
  24. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  25. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  26. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +356 -0
  27. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  28. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1371 -0
  29. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +177 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  31. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  32. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  33. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  34. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  35. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  36. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  37. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  38. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  39. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  40. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  41. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  42. package/src/generated/prisma-adversarial-v6/index.d.ts +11769 -0
  43. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  44. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  45. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  46. package/src/index.ts +2515 -219
  47. package/src/test-setup.adversarial-url.ts +18 -0
  48. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  49. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  50. package/src/test-setup.adversarial.v6.ts +5 -0
  51. package/src/test-setup.adversarial.v7.ts +12 -0
  52. package/src/translator.test.ts +1586 -0
  53. package/src/generated/prisma/internal/class.ts +0 -274
  54. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1333
  55. package/src/generated/prisma/models/Category.ts +0 -1412
  56. package/src/generated/prisma/models/Label.ts +0 -1276
  57. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  58. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  59. package/src/generated/prisma/models/Resource.ts +0 -2136
  60. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  61. package/src/generated/prisma/models/Tag.ts +0 -1276
  62. package/src/generated/prisma/models/User.ts +0 -1503
  63. package/src/generated/prisma/models.ts +0 -19
  64. package/src/generated/prisma-v6/index.d.ts +0 -13639
  65. package/src/index.test.ts +0 -6322
  66. package/src/test-setup.v6.ts +0 -5
  67. package/src/test-setup.v7.ts +0 -7
  68. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  69. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  70. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  71. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  72. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  73. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  74. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -0
@@ -0,0 +1,1586 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import type {
6
+ PlanExpressionOperand,
7
+ PlanResourcesResponse,
8
+ } from "@cerbos/core";
9
+
10
+ import { queryPlanToPrisma, PlanKind } from ".";
11
+ import type {
12
+ Mapper,
13
+ NullAttributeRepresentation,
14
+ PrismaFilter,
15
+ QueryPlanToPrismaResult,
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";
26
+
27
+ /**
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:
33
+ *
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.
58
+ */
59
+
60
+ const actionsFile: ActionsFile = JSON.parse(
61
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
62
+ );
63
+
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
+ );
74
+
75
+ function translate(
76
+ action: string,
77
+ options: {
78
+ mapper?: Mapper;
79
+ nullAttributeRepresentation?: NullAttributeRepresentation;
80
+ } = {}
81
+ ): QueryPlanToPrismaResult {
82
+ return queryPlanToPrisma({
83
+ queryPlan: planFromWireFixture(action),
84
+ mapper: options.mapper ?? MAPPER,
85
+ model: MODEL,
86
+ ...(options.nullAttributeRepresentation
87
+ ? { nullAttributeRepresentation: options.nullAttributeRepresentation }
88
+ : {}),
89
+ });
90
+ }
91
+
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
+ };
106
+
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
+ });
1203
+ }
1204
+ );
1205
+
1206
+ test.each(Object.entries(EXPECTED_KINDS))(
1207
+ "%s resolves without a condition",
1208
+ (action, kind) => {
1209
+ expect(translate(action)).toStrictEqual({ kind });
1210
+ }
1211
+ );
1212
+
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);
1221
+ }
1222
+ );
1223
+
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 });
1247
+ });
1248
+ });
1249
+
1250
+ 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", () => {
1256
+ expect(
1257
+ translate("null-eq-missing", { nullAttributeRepresentation: "explicit" })
1258
+ ).toStrictEqual({
1259
+ kind: PlanKind.CONDITIONAL,
1260
+ filters: { aOptionalString: { equals: null } },
1261
+ });
1262
+ });
1263
+
1264
+ test("omitted: the same plan is refused rather than translated", () => {
1265
+ // 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");
1270
+ });
1271
+ });
1272
+
1273
+ 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) =>
1279
+ queryPlanToPrisma({
1280
+ queryPlan: planFromWireFixture("ts-window", plannedAt),
1281
+ mapper: MAPPER,
1282
+ model: MODEL,
1283
+ });
1284
+
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({
1296
+ kind: PlanKind.CONDITIONAL,
1297
+ filters: { createdAt: { lt: "2026-08-11T09:13:39.123Z" } },
1298
+ });
1299
+ });
1300
+
1301
+ test("excess fractional digits are accepted only when they are zero", () => {
1302
+ expect(at("2026-08-11T09:13:39.123000Z")).toStrictEqual({
1303
+ kind: PlanKind.CONDITIONAL,
1304
+ filters: { createdAt: { lt: "2026-08-11T09:13:39.123Z" } },
1305
+ });
1306
+ });
1307
+
1308
+ // Each of these is refused rather than coerced: a `Date` parsed from a lenient string would
1309
+ // compare against the column as some other instant, which is a filter that returns rows the PDP
1310
+ // denies rather than an error the caller can see.
1311
+ test.each([
1312
+ ["a date with no time part", "2024-01-01"],
1313
+ ["a year outside CEL's instant range", "0000-01-01T00:00:00Z"],
1314
+ ["a day that does not exist", "2024-02-30T00:00:00Z"],
1315
+ ["sub-millisecond precision", "2024-01-01T00:00:00.1234Z"],
1316
+ ["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/);
1319
+ });
1320
+ });
1321
+
1322
+ describe("relation subqueryFilter", () => {
1323
+ /**
1324
+ * The one mapping hazard the corpus cannot express with a policy action, because the policy is
1325
+ * irrelevant to it: the rows the adapter's subquery sees must equal the rows the application
1326
+ * put into the resource attributes. Prisma has no schema-level filtered relation — a `where`
1327
+ * injected by a client extension rewrites the top-level query, never the nested
1328
+ * `some`/`every`/`none` this adapter generates — so a narrowing the application applies to its
1329
+ * own reads does not reach the subquery unless the caller declares it. See "Mapping hazards" in
1330
+ * conformance/README.md and cerbos/query-plan-adapters#314.
1331
+ */
1332
+ const VISIBLE_ONLY: PrismaFilter = { name: { not: "hidden" } };
1333
+
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
+ });
1344
+
1345
+ const filtersFor = (action: string, subqueryFilter?: PrismaFilter) => {
1346
+ const result = translate(action, { mapper: mapperFor(subqueryFilter) });
1347
+ if (result.kind !== PlanKind.CONDITIONAL) {
1348
+ throw new Error(`Expected CONDITIONAL result for ${action}`);
1349
+ }
1350
+ return result.filters;
1351
+ };
1352
+
1353
+ test("declared: exists() examines only the records the application serialised", () => {
1354
+ expect(filtersFor("exists-on-empty", VISIBLE_ONLY)).toStrictEqual({
1355
+ tags: {
1356
+ some: { AND: [{ name: { not: "hidden" } }, { name: { equals: "public" } }] },
1357
+ },
1358
+ });
1359
+ });
1360
+
1361
+ test("declared: all() narrows the records examined, not the records required", () => {
1362
+ // `every: AND(visible, P)` would REQUIRE every record to be visible, dropping any row that
1363
+ // holds a hidden tag. The rewrite to `none: AND(visible, NOT P)` is what makes the
1364
+ // declaration mean "ignore what the application hides" — including the empty-collection case,
1365
+ // where CEL's all() is vacuously true and check() agrees because the application sent an
1366
+ // empty list for the same reason.
1367
+ expect(filtersFor("all-on-empty", VISIBLE_ONLY)).toStrictEqual({
1368
+ tags: {
1369
+ none: {
1370
+ AND: [
1371
+ { name: { not: "hidden" } },
1372
+ { NOT: { name: { equals: "public" } } },
1373
+ ],
1374
+ },
1375
+ },
1376
+ });
1377
+ });
1378
+
1379
+ test("declared: an emptiness check counts only the visible records", () => {
1380
+ expect(filtersFor("not-empty", VISIBLE_ONLY)).toStrictEqual({
1381
+ NOT: { tags: { none: { name: { not: "hidden" } } } },
1382
+ });
1383
+ });
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
+ });
1398
+
1399
+ describe("the mapper contract", () => {
1400
+ // A mapper is caller-supplied, so these are caller errors rather than policy shapes: no corpus
1401
+ // action can produce them, because the corpus fixes one mapper per adapter.
1402
+
1403
+ test("a function mapper resolves a scalar reference", () => {
1404
+ expect(
1405
+ translate("cs-eq", {
1406
+ mapper: (key: string) => ({
1407
+ field: key.replace("request.resource.attr.", ""),
1408
+ }),
1409
+ })
1410
+ ).toStrictEqual({
1411
+ kind: PlanKind.CONDITIONAL,
1412
+ filters: { aString: { equals: "one" } },
1413
+ });
1414
+ });
1415
+
1416
+ test("a function mapper resolves a relation", () => {
1417
+ expect(
1418
+ translate("rel-bool-hop", {
1419
+ mapper: () => ({
1420
+ relation: {
1421
+ name: "parent",
1422
+ type: "one",
1423
+ fields: { aBool: { field: "aBool" } },
1424
+ },
1425
+ }),
1426
+ })
1427
+ ).toStrictEqual({
1428
+ kind: PlanKind.CONDITIONAL,
1429
+ filters: { parent: { is: { aBool: { equals: true } } } },
1430
+ });
1431
+ });
1432
+
1433
+ test("size() against a scalar mapping is refused rather than guessed", () => {
1434
+ expect(() =>
1435
+ translate("not-empty", {
1436
+ mapper: { "request.resource.attr.tags": { field: "tags" } },
1437
+ })
1438
+ ).toThrow("size operator requires a relation mapping");
1439
+ });
1440
+ });
1441
+
1442
+ describe("plans the planner cannot produce", () => {
1443
+ // 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
1446
+ // believe. They exist so a caller who hands the adapter a hand-rolled or half-decoded plan gets
1447
+ // an error rather than a filter.
1448
+ //
1449
+ // The test for a shape CEL *can* express does not belong here, whatever its plan looks like:
1450
+ // it belongs in the corpus, where every adapter is asked about it. Two such shapes were
1451
+ // pinned here by hand-built plan before #377 and are now
1452
+ // [#394](https://github.com/cerbos/query-plan-adapters/issues/394).
1453
+
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;
1463
+
1464
+ test("an unrecognised plan kind", () => {
1465
+ expect(() =>
1466
+ queryPlanToPrisma({
1467
+ queryPlan: { kind: "INVALID_KIND" } as unknown as PlanResourcesResponse,
1468
+ mapper: {},
1469
+ })
1470
+ ).toThrow("Invalid query plan.");
1471
+ });
1472
+
1473
+ test("a condition with neither operator nor operands", () => {
1474
+ expect(() => queryPlanToPrisma({ queryPlan: plan({}), mapper: {} })).toThrow(
1475
+ "Invalid Cerbos expression structure"
1476
+ );
1477
+ });
1478
+
1479
+ test("an operator this adapter has never heard of", () => {
1480
+ expect(() =>
1481
+ queryPlanToPrisma({
1482
+ queryPlan: plan({ operator: "unsupported", operands: [] }),
1483
+ mapper: {},
1484
+ })
1485
+ ).toThrow("Unsupported operator: unsupported");
1486
+ });
1487
+
1488
+ test("an operand that is neither a variable nor a value", () => {
1489
+ expect(() =>
1490
+ queryPlanToPrisma({
1491
+ queryPlan: plan({ operator: "eq", operands: [{}, { value: "test" }] }),
1492
+ mapper: {},
1493
+ })
1494
+ ).toThrow("No valid left operand found");
1495
+ });
1496
+
1497
+ test("a ternary with the wrong arity", () => {
1498
+ expect(() =>
1499
+ queryPlanToPrisma({
1500
+ queryPlan: plan({
1501
+ operator: "if",
1502
+ operands: [{ name: "request.resource.attr.aBool" }, { value: true }],
1503
+ }),
1504
+ mapper: MAPPER,
1505
+ })
1506
+ ).toThrow(
1507
+ "if (ternary) requires exactly 3 operands (condition, then, else), got 2"
1508
+ );
1509
+ });
1510
+
1511
+ test("a ternary whose condition is not a boolean expression", () => {
1512
+ expect(() =>
1513
+ queryPlanToPrisma({
1514
+ queryPlan: plan({
1515
+ operator: "if",
1516
+ operands: [{ value: null }, { value: true }, { value: false }],
1517
+ }),
1518
+ mapper: MAPPER,
1519
+ })
1520
+ ).toThrow("if (ternary) condition must be a boolean expression");
1521
+ });
1522
+
1523
+ test("a comparison against a ternary with three operands", () => {
1524
+ expect(() =>
1525
+ queryPlanToPrisma({
1526
+ queryPlan: plan({
1527
+ operator: "eq",
1528
+ operands: [
1529
+ {
1530
+ operator: "if",
1531
+ operands: [
1532
+ { name: "request.resource.attr.aBool" },
1533
+ { value: true },
1534
+ { value: false },
1535
+ ],
1536
+ },
1537
+ { value: true },
1538
+ { value: false },
1539
+ ],
1540
+ }),
1541
+ mapper: MAPPER,
1542
+ })
1543
+ ).toThrow("eq with a ternary requires exactly 2 operands, got 3");
1544
+ });
1545
+
1546
+ test("a macro over a collection value that is not a list", () => {
1547
+ expect(() =>
1548
+ queryPlanToPrisma({
1549
+ queryPlan: plan({
1550
+ operator: "exists",
1551
+ operands: [
1552
+ { value: "not-a-list" },
1553
+ {
1554
+ operator: "lambda",
1555
+ operands: [
1556
+ {
1557
+ operator: "eq",
1558
+ operands: [
1559
+ { name: "request.resource.attr.aString" },
1560
+ { name: "t" },
1561
+ ],
1562
+ },
1563
+ { name: "t" },
1564
+ ],
1565
+ },
1566
+ ],
1567
+ }),
1568
+ mapper: MAPPER,
1569
+ })
1570
+ ).toThrow("exists over a literal collection requires a list value");
1571
+ });
1572
+
1573
+ test("a constant-false predicate the planner should have folded", () => {
1574
+ 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
+ );
1585
+ });
1586
+ });