@cerbos/orm-prisma 3.3.0 → 4.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 (44) hide show
  1. package/README.md +83 -6
  2. package/lib/index.d.ts +29 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1459 -114
  5. package/lib/index.js.map +1 -1
  6. package/package.json +19 -13
  7. package/src/adversarial.test.ts +586 -0
  8. package/src/generated/prisma/commonInputTypes.ts +26 -0
  9. package/src/generated/prisma/internal/class.ts +9 -9
  10. package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
  11. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
  12. package/src/generated/prisma/models/Category.ts +1 -1
  13. package/src/generated/prisma/models/Label.ts +1 -1
  14. package/src/generated/prisma/models/NestedResource.ts +1 -1
  15. package/src/generated/prisma/models/NextLevelNestedResource.ts +1 -1
  16. package/src/generated/prisma/models/Resource.ts +62 -2
  17. package/src/generated/prisma/models/SubCategory.ts +1 -1
  18. package/src/generated/prisma/models/Tag.ts +1 -1
  19. package/src/generated/prisma/models/User.ts +1 -1
  20. package/src/generated/prisma-adversarial/browser.ts +44 -0
  21. package/src/generated/prisma-adversarial/client.ts +66 -0
  22. package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
  23. package/src/generated/prisma-adversarial/enums.ts +15 -0
  24. package/src/generated/prisma-adversarial/internal/class.ts +244 -0
  25. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
  26. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
  27. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  28. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  29. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
  30. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  31. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  32. package/src/generated/prisma-adversarial/models.ts +16 -0
  33. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  34. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  35. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  36. package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
  37. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  38. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  39. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  40. package/src/generated/prisma-v6/index.d.ts +90 -3
  41. package/src/index.test.ts +3199 -636
  42. package/src/index.ts +2055 -148
  43. package/src/test-setup.adversarial.v6.ts +5 -0
  44. package/src/test-setup.adversarial.v7.ts +12 -0
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- import {
1
+ import { PlanKind } from "@cerbos/core";
2
+ import type {
2
3
  PlanResourcesResponse,
3
4
  PlanExpressionOperand,
4
- PlanKind,
5
5
  Value,
6
6
  } from "@cerbos/core";
7
7
 
@@ -16,14 +16,123 @@ const CERBOS_TO_PRISMA_OPERATOR: Record<string, string> = {
16
16
  ge: "gte",
17
17
  };
18
18
 
19
+ // Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
20
+ const MIRRORED_OPERATOR: Record<string, string> = {
21
+ lt: "gt",
22
+ gt: "lt",
23
+ le: "ge",
24
+ ge: "le",
25
+ };
26
+
27
+ const RFC3339_MILLISECOND_TIMESTAMP =
28
+ /^((?!0000)\d{4})-(\d{2})-(\d{2})[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.(\d{1,9}))?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
29
+ const MIN_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
30
+ "0001-01-01T00:00:00.000Z"
31
+ );
32
+ const MAX_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
33
+ "9999-12-31T23:59:59.999Z"
34
+ );
35
+
36
+ function normalizeRfc3339Milliseconds(value: string): string {
37
+ const match = RFC3339_MILLISECOND_TIMESTAMP.exec(value);
38
+ const yearText = match?.[1];
39
+ const monthText = match?.[2];
40
+ const dayText = match?.[3];
41
+ const fraction = match?.[4] ?? "";
42
+ if (!yearText || !monthText || !dayText) {
43
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
44
+ }
45
+ if ([...fraction.slice(3)].some((digit) => digit !== "0")) {
46
+ throw new Error(
47
+ `Timestamp value exceeds millisecond precision: ${value}`
48
+ );
49
+ }
50
+
51
+ const year = Number(yearText);
52
+ const month = Number(monthText);
53
+ const day = Number(dayText);
54
+ const calendarDate = new Date(0);
55
+ calendarDate.setUTCHours(0, 0, 0, 0);
56
+ calendarDate.setUTCFullYear(year, month - 1, day);
57
+ if (
58
+ calendarDate.getUTCFullYear() !== year ||
59
+ calendarDate.getUTCMonth() !== month - 1 ||
60
+ calendarDate.getUTCDate() !== day
61
+ ) {
62
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
63
+ }
64
+
65
+ const milliseconds = Date.parse(value);
66
+ if (Number.isNaN(milliseconds)) {
67
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
68
+ }
69
+ if (
70
+ milliseconds < MIN_RFC3339_TIMESTAMP_MILLISECONDS ||
71
+ milliseconds > MAX_RFC3339_TIMESTAMP_MILLISECONDS
72
+ ) {
73
+ throw new Error(
74
+ `Timestamp value is outside CEL's supported instant range: ${value}`
75
+ );
76
+ }
77
+ return new Date(milliseconds).toISOString();
78
+ }
79
+
80
+ /**
81
+ * Normalize a binary comparison to field-side-first. The planner preserves policy source
82
+ * order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
83
+ * `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
84
+ * handlers can assume the field/expression side is first. Without this, value-first
85
+ * comparisons are silently inverted (#256).
86
+ */
87
+ function normalizeBinaryOperands(
88
+ operator: string,
89
+ operands: PlanExpressionOperand[]
90
+ ): { operator: string; operands: PlanExpressionOperand[] } {
91
+ const first = operands[0];
92
+ const second = operands[1];
93
+ if (
94
+ operands.length === 2 &&
95
+ first !== undefined &&
96
+ second !== undefined &&
97
+ isValueOperand(first) &&
98
+ !isValueOperand(second)
99
+ ) {
100
+ return {
101
+ operator: MIRRORED_OPERATOR[operator] ?? operator,
102
+ operands: [second, first],
103
+ };
104
+ }
105
+ return { operator, operands };
106
+ }
107
+
19
108
  // Type Definitions
20
109
  export type PrismaFilter = Record<string, any>;
21
110
 
22
111
  export type MapperConfig = {
23
112
  field?: string;
113
+ /**
114
+ * Declares the database value kind when the Cerbos expression wraps a field in a typed
115
+ * constructor. Date-time metadata is required for timestamp(field) so a string column is
116
+ * never silently treated as a temporal column merely because it happens to contain ISO text.
117
+ */
118
+ valueType?: "dateTime";
119
+ /**
120
+ * Marks the mapped column as nullable in the database. Cerbos treats a missing attribute as
121
+ * an evaluation error (deny), which matches SQL three-valued logic for simple predicates —
122
+ * but relation subqueries (some/every/none) collapse UNKNOWN to false at the EXISTS boundary,
123
+ * so collection macros over elements with NULL fields need an explicit guard to stay
124
+ * deny-aligned. Declaring nullability here enables those guards.
125
+ */
126
+ nullable?: boolean;
24
127
  relation?: {
25
128
  name: string;
26
129
  type: "one" | "many";
130
+ /**
131
+ * The Prisma model name of the related record (e.g. "Tag"). Required only for
132
+ * field-to-field comparisons between two columns of the related model, which compile to
133
+ * Prisma field references and need the model name as their container.
134
+ */
135
+ model?: string;
27
136
  field?: string;
28
137
  fields?: Record<string, MapperConfig>;
29
138
  };
@@ -36,6 +145,12 @@ export type Mapper =
36
145
  export interface QueryPlanToPrismaArgs {
37
146
  queryPlan: PlanResourcesResponse;
38
147
  mapper?: Mapper;
148
+ /**
149
+ * The Prisma model name the generated filter targets (e.g. "Resource"). Required only for
150
+ * field-to-field comparisons between two root columns, which compile to Prisma field
151
+ * references and need the model name as their container.
152
+ */
153
+ model?: string;
39
154
  }
40
155
 
41
156
  export type QueryPlanToPrismaResult =
@@ -91,6 +206,203 @@ function assertDefined<T>(value: T | undefined, message: string): T {
91
206
  return value;
92
207
  }
93
208
 
209
+ // Operators whose second operand is a lambda that binds an iteration variable.
210
+ const LAMBDA_BINDING_OPERATORS = new Set([
211
+ "exists",
212
+ "exists_one",
213
+ "all",
214
+ "filter",
215
+ "map",
216
+ "except",
217
+ ]);
218
+
219
+ /**
220
+ * Substitute a lambda iteration variable with a concrete collection element
221
+ * inside a lambda body. A bare reference to the variable becomes the element
222
+ * itself; a `variable.path.to.field` reference drills into the element. A
223
+ * nested collection macro whose lambda rebinds the same variable name shadows
224
+ * the outer variable, so substitution only descends into its collection
225
+ * operand.
226
+ */
227
+ function substituteLambdaVariable(
228
+ operand: PlanExpressionOperand,
229
+ variableName: string,
230
+ element: Value
231
+ ): PlanExpressionOperand {
232
+ if (isNamedOperand(operand)) {
233
+ if (operand.name === variableName) {
234
+ return { value: element };
235
+ }
236
+ if (operand.name.startsWith(`${variableName}.`)) {
237
+ let current: Value = element;
238
+ for (const segment of operand.name
239
+ .slice(variableName.length + 1)
240
+ .split(".")) {
241
+ if (
242
+ current === null ||
243
+ typeof current !== "object" ||
244
+ Array.isArray(current) ||
245
+ !(segment in current) ||
246
+ current[segment] === undefined
247
+ ) {
248
+ throw new Error(
249
+ `Cannot resolve "${operand.name}": collection element has no field "${segment}"`
250
+ );
251
+ }
252
+ current = current[segment];
253
+ }
254
+ return { value: current };
255
+ }
256
+ return operand;
257
+ }
258
+
259
+ if (isOperatorOperand(operand)) {
260
+ if (
261
+ LAMBDA_BINDING_OPERATORS.has(operand.operator) &&
262
+ operand.operands.length === 2
263
+ ) {
264
+ const [nestedCollection, nestedLambda] = operand.operands;
265
+ if (
266
+ nestedCollection !== undefined &&
267
+ nestedLambda !== undefined &&
268
+ isOperatorOperand(nestedLambda) &&
269
+ nestedLambda.operator === "lambda"
270
+ ) {
271
+ const nestedVariable = nestedLambda.operands[1];
272
+ if (
273
+ nestedVariable !== undefined &&
274
+ isNamedOperand(nestedVariable) &&
275
+ nestedVariable.name === variableName
276
+ ) {
277
+ // The nested lambda shadows our variable: substitute only in the
278
+ // collection operand.
279
+ return {
280
+ operator: operand.operator,
281
+ operands: [
282
+ substituteLambdaVariable(nestedCollection, variableName, element),
283
+ nestedLambda,
284
+ ],
285
+ };
286
+ }
287
+ }
288
+ }
289
+ return {
290
+ operator: operand.operator,
291
+ operands: operand.operands.map((o) =>
292
+ substituteLambdaVariable(o, variableName, element)
293
+ ),
294
+ };
295
+ }
296
+
297
+ return operand;
298
+ }
299
+
300
+ /**
301
+ * Fold a collection macro whose collection operand is a literal value list.
302
+ *
303
+ * The planner emits this shape when a known-value collection (typically a
304
+ * folded principal attribute) has more than 10 elements — at 10 or fewer it
305
+ * unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
306
+ * cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
307
+ * the same fold here so the translated filter does not depend on which side of
308
+ * that threshold the collection lands: substitute each element into the lambda
309
+ * body and combine the per-element filters with OR (`exists`) or AND (`all`).
310
+ *
311
+ * An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
312
+ * match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
313
+ * over an empty collection.
314
+ */
315
+ function handleKnownValueCollectionOperator(
316
+ operator: string,
317
+ collection: ValueOperand,
318
+ lambda: PlanExpressionOperand,
319
+ mapper: Mapper,
320
+ negated: boolean
321
+ ): PrismaFilter {
322
+ if (operator !== "exists" && operator !== "all") {
323
+ throw new Error(
324
+ `${operator} over a literal collection value is not supported. ` +
325
+ "Only exists() and all() can be folded into a flat filter."
326
+ );
327
+ }
328
+
329
+ const elements = collection.value;
330
+ if (!Array.isArray(elements)) {
331
+ throw new Error(
332
+ `${operator} over a literal collection requires a list value`
333
+ );
334
+ }
335
+
336
+ if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
337
+ throw new Error(
338
+ `Second operand of ${operator} must be a lambda expression`
339
+ );
340
+ }
341
+ if (lambda.operands.length !== 2) {
342
+ throw new Error(
343
+ `${operator} over a literal collection supports single-variable lambdas only`
344
+ );
345
+ }
346
+
347
+ const body = assertDefined(
348
+ lambda.operands[0],
349
+ "Lambda expression must provide a condition"
350
+ );
351
+ const variable = assertDefined(
352
+ lambda.operands[1],
353
+ "Lambda variable must have a name"
354
+ );
355
+ if (!isNamedOperand(variable)) {
356
+ throw new Error("Lambda variable must have a name");
357
+ }
358
+
359
+ const filters = elements.map((element) => {
360
+ const substituted = substituteLambdaVariable(
361
+ body,
362
+ variable.name,
363
+ element
364
+ );
365
+ return negated
366
+ ? buildNegatedFilter(substituted, mapper)
367
+ : buildPrismaFilterFromCerbosExpression(substituted, mapper);
368
+ });
369
+
370
+ const combinesWithOr = operator === "exists" ? !negated : negated;
371
+ return combinesWithOr ? { OR: filters } : { AND: filters };
372
+ }
373
+
374
+ function tryHandleKnownValueCollectionOperator(
375
+ operator: string,
376
+ operands: PlanExpressionOperand[],
377
+ mapper: Mapper,
378
+ negated: boolean
379
+ ): PrismaFilter | undefined {
380
+ if (operands.length !== 2) {
381
+ return undefined;
382
+ }
383
+
384
+ const collection = operands[0];
385
+ const lambda = operands[1];
386
+ if (
387
+ collection === undefined ||
388
+ lambda === undefined ||
389
+ !isValueOperand(collection)
390
+ ) {
391
+ return undefined;
392
+ }
393
+
394
+ // A literal value list arrives when the planner could not unroll a macro
395
+ // over a known collection (more than 10 elements). Fold it before attempting
396
+ // relation resolution because there is no relation mapping for a literal.
397
+ return handleKnownValueCollectionOperator(
398
+ operator,
399
+ collection,
400
+ lambda,
401
+ mapper,
402
+ negated
403
+ );
404
+ }
405
+
94
406
  function getLeafField(path: string[]): string {
95
407
  const fieldName = path[path.length - 1];
96
408
  if (!fieldName) {
@@ -111,6 +423,7 @@ function getFilterEntry(filter: Record<string, unknown>): [string, unknown] {
111
423
  type RelationConfig = {
112
424
  name: string;
113
425
  type: "one" | "many";
426
+ model?: string;
114
427
  field?: string;
115
428
  nestedMapper?: Record<string, MapperConfig>;
116
429
  };
@@ -118,6 +431,7 @@ type RelationConfig = {
118
431
  type ResolvedFieldReference = {
119
432
  path: string[];
120
433
  relations?: RelationConfig[];
434
+ valueType?: "dateTime";
121
435
  };
122
436
 
123
437
  type ResolvedValue = {
@@ -208,31 +522,457 @@ function buildFieldDirectOrInFilter(
208
522
  return wrapRelations(fieldRef.relations, baseFilter);
209
523
  }
210
524
 
525
+ function buildMembershipFilter(
526
+ fieldRef: ResolvedFieldReference,
527
+ values: Value[]
528
+ ): PrismaFilter {
529
+ const nonNullValues = values.filter((value) => value !== null);
530
+ const filters: PrismaFilter[] = [];
531
+
532
+ if (nonNullValues.length > 0 || values.length === 0) {
533
+ filters.push(buildFieldDirectOrInFilter(fieldRef, nonNullValues));
534
+ }
535
+ if (nonNullValues.length !== values.length) {
536
+ filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
537
+ }
538
+
539
+ if (filters.length === 1) {
540
+ return assertDefined(filters[0], "Membership filter is missing");
541
+ }
542
+ return { OR: filters };
543
+ }
544
+
545
+ function buildAlwaysTrueFilter(): PrismaFilter {
546
+ return {};
547
+ }
548
+
549
+ type TernaryBranchPredicate =
550
+ | { kind: "constant"; value: boolean }
551
+ | { kind: "filter"; filter: PrismaFilter };
552
+
553
+ // Translation-scoped context. Set at queryPlanToPrisma entry; the lambda stack tracks which
554
+ // collection variable (and related Prisma model) encloses the expression currently being built,
555
+ // so leaf handlers can distinguish element columns from outer columns and collect nullable
556
+ // element fields for three-valued-logic guards. Translation is synchronous, so module scope is
557
+ // safe.
558
+ type LambdaScope = {
559
+ variableName: string;
560
+ relationModel: string | undefined;
561
+ nullableFields: Set<string>;
562
+ unknownFilters: PrismaFilter[];
563
+ };
564
+ let lambdaScopes: LambdaScope[] = [];
565
+ let rootModelName: string | undefined;
566
+
211
567
  /**
212
568
  * Converts a Cerbos query plan to a Prisma filter.
213
569
  */
214
570
  export function queryPlanToPrisma({
215
571
  queryPlan,
216
572
  mapper = {},
573
+ model,
217
574
  }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
218
575
  switch (queryPlan.kind) {
219
576
  case PlanKind.ALWAYS_ALLOWED:
220
577
  return { kind: PlanKind.ALWAYS_ALLOWED };
221
578
  case PlanKind.ALWAYS_DENIED:
222
579
  return { kind: PlanKind.ALWAYS_DENIED };
223
- case PlanKind.CONDITIONAL:
580
+ case PlanKind.CONDITIONAL: {
581
+ lambdaScopes = [];
582
+ rootModelName = model;
583
+ const condition = constantFoldExpression(
584
+ hoistOuterScopeReferences(queryPlan.condition, [])
585
+ );
586
+ if (isValueOperand(condition)) {
587
+ // Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
588
+ // they reach the adapter; this can only surface with hand-crafted plans.
589
+ if (condition.value === true) {
590
+ return { kind: PlanKind.CONDITIONAL, filters: {} };
591
+ }
592
+ // Prisma has no model-agnostic always-false `where` shape: empty logical arrays
593
+ // are ignored.
594
+ throw new Error(
595
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
596
+ );
597
+ }
224
598
  return {
225
599
  kind: PlanKind.CONDITIONAL,
226
- filters: buildPrismaFilterFromCerbosExpression(
227
- queryPlan.condition,
228
- mapper
229
- ),
600
+ filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
230
601
  };
602
+ }
231
603
  default:
232
604
  throw Error(`Invalid query plan.`);
233
605
  }
234
606
  }
235
607
 
608
+ const COLLECTION_OPERATORS = new Set([
609
+ "exists",
610
+ "exists_one",
611
+ "all",
612
+ "except",
613
+ "filter",
614
+ ]);
615
+
616
+ function mentionsVariable(
617
+ expr: PlanExpressionOperand,
618
+ variableName: string
619
+ ): boolean {
620
+ if (isNamedOperand(expr)) {
621
+ return (
622
+ expr.name === variableName || expr.name.startsWith(variableName + ".")
623
+ );
624
+ }
625
+ if (isOperatorOperand(expr)) {
626
+ return expr.operands.some((operand) =>
627
+ mentionsVariable(operand, variableName)
628
+ );
629
+ }
630
+ return false;
631
+ }
632
+
633
+ function isOuterScopeName(name: string, enclosingVariables: string[]): boolean {
634
+ return !enclosingVariables.some(
635
+ (variable) => name === variable || name.startsWith(variable + ".")
636
+ );
637
+ }
638
+
639
+ /** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
640
+ function substituteNamedOperand(
641
+ expr: PlanExpressionOperand,
642
+ name: string,
643
+ value: boolean
644
+ ): PlanExpressionOperand {
645
+ if (isNamedOperand(expr)) {
646
+ return expr.name === name ? { value } : expr;
647
+ }
648
+ if (isOperatorOperand(expr)) {
649
+ return {
650
+ operator: expr.operator,
651
+ operands: expr.operands.map((operand) =>
652
+ substituteNamedOperand(operand, name, value)
653
+ ),
654
+ };
655
+ }
656
+ return expr;
657
+ }
658
+
659
+ /**
660
+ * Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
661
+ * boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
662
+ * the body itself.
663
+ */
664
+ function findOuterBooleanReference(
665
+ expr: PlanExpressionOperand,
666
+ enclosingVariables: string[]
667
+ ): string | undefined {
668
+ if (isNamedOperand(expr)) {
669
+ return isOuterScopeName(expr.name, enclosingVariables)
670
+ ? expr.name
671
+ : undefined;
672
+ }
673
+ if (!isOperatorOperand(expr)) {
674
+ return undefined;
675
+ }
676
+ const booleanPositions: PlanExpressionOperand[] = [];
677
+ switch (expr.operator) {
678
+ case "and":
679
+ case "or":
680
+ case "not":
681
+ booleanPositions.push(...expr.operands);
682
+ break;
683
+ case "if":
684
+ booleanPositions.push(...expr.operands);
685
+ break;
686
+ default:
687
+ // Comparisons wrapping a ternary keep the outer reference in the ternary condition.
688
+ booleanPositions.push(
689
+ ...expr.operands.filter(
690
+ (operand) =>
691
+ isOperatorOperand(operand) &&
692
+ (operand.operator === "if" ||
693
+ operand.operator === "and" ||
694
+ operand.operator === "or" ||
695
+ operand.operator === "not")
696
+ )
697
+ );
698
+ break;
699
+ }
700
+ for (const operand of booleanPositions) {
701
+ const found = findOuterBooleanReference(operand, enclosingVariables);
702
+ if (found !== undefined) {
703
+ return found;
704
+ }
705
+ }
706
+ return undefined;
707
+ }
708
+
709
+ /**
710
+ * Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
711
+ * outer lambda's element) so that every filter lands on the model it belongs to. Without this,
712
+ * an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
713
+ * tag model. Two sound transforms, applied bottom-up:
714
+ *
715
+ * - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
716
+ * `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
717
+ * OR), including the empty-collection case (both sides are FALSE).
718
+ * - Case split (any collection op): a bare outer boolean reference Q in a boolean position
719
+ * becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
720
+ * keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
721
+ * guarded-ternary encoding.
722
+ */
723
+ function hoistOuterScopeReferences(
724
+ expr: PlanExpressionOperand,
725
+ enclosingVariables: string[]
726
+ ): PlanExpressionOperand {
727
+ if (!isOperatorOperand(expr)) {
728
+ return expr;
729
+ }
730
+
731
+ if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
732
+ const [collection, lambda] = expr.operands;
733
+ if (
734
+ collection !== undefined &&
735
+ lambda !== undefined &&
736
+ isOperatorOperand(lambda) &&
737
+ lambda.operator === "lambda" &&
738
+ lambda.operands.length === 2 &&
739
+ lambda.operands[1] !== undefined &&
740
+ isNamedOperand(lambda.operands[1])
741
+ ) {
742
+ const variable = lambda.operands[1];
743
+ const body = hoistOuterScopeReferences(
744
+ assertDefined(lambda.operands[0], "Lambda requires a condition"),
745
+ [...enclosingVariables, variable.name]
746
+ );
747
+
748
+ const rebuild = (newBody: PlanExpressionOperand): OperatorOperand => ({
749
+ operator: expr.operator,
750
+ operands: [
751
+ collection,
752
+ { operator: "lambda", operands: [newBody, variable] },
753
+ ],
754
+ });
755
+
756
+ // Conjunct hoisting out of exists().
757
+ if (
758
+ expr.operator === "exists" &&
759
+ isOperatorOperand(body) &&
760
+ body.operator === "and"
761
+ ) {
762
+ const elementConjuncts = body.operands.filter((operand) =>
763
+ mentionsVariable(operand, variable.name)
764
+ );
765
+ const outerConjuncts = body.operands.filter(
766
+ (operand) => !mentionsVariable(operand, variable.name)
767
+ );
768
+ if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
769
+ const innerBody =
770
+ elementConjuncts.length === 1
771
+ ? elementConjuncts[0]!
772
+ : { operator: "and", operands: elementConjuncts };
773
+ return {
774
+ operator: "and",
775
+ operands: [rebuild(innerBody), ...outerConjuncts],
776
+ };
777
+ }
778
+ }
779
+
780
+ // Case split on a bare outer boolean reference.
781
+ const outerRef = findOuterBooleanReference(body, [
782
+ ...enclosingVariables,
783
+ variable.name,
784
+ ]);
785
+ if (outerRef !== undefined) {
786
+ const q: PlanExpressionOperand = { name: outerRef };
787
+ const notQ: PlanExpressionOperand = { operator: "not", operands: [q] };
788
+ return {
789
+ operator: "or",
790
+ operands: [
791
+ {
792
+ operator: "and",
793
+ operands: [
794
+ q,
795
+ rebuild(substituteNamedOperand(body, outerRef, true)),
796
+ ],
797
+ },
798
+ {
799
+ operator: "and",
800
+ operands: [
801
+ notQ,
802
+ rebuild(substituteNamedOperand(body, outerRef, false)),
803
+ ],
804
+ },
805
+ // Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
806
+ { operator: "and", operands: [q, notQ] },
807
+ ],
808
+ };
809
+ }
810
+
811
+ return rebuild(body);
812
+ }
813
+ }
814
+
815
+ return {
816
+ operator: expr.operator,
817
+ operands: expr.operands.map((operand) =>
818
+ hoistOuterScopeReferences(operand, enclosingVariables)
819
+ ),
820
+ };
821
+ }
822
+
823
+ const FOLDABLE_COMPARISON_OPERATORS = new Set([
824
+ "eq",
825
+ "ne",
826
+ "lt",
827
+ "le",
828
+ "gt",
829
+ "ge",
830
+ ]);
831
+
832
+ /**
833
+ * Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
834
+ * produce need folding (constant ternary conditions, comparisons between two constants,
835
+ * logical operators with constant operands, collection macros with constant bodies); real
836
+ * planner output arrives pre-folded.
837
+ */
838
+ function constantFoldExpression(
839
+ expr: PlanExpressionOperand
840
+ ): PlanExpressionOperand {
841
+ if (!isOperatorOperand(expr)) {
842
+ return expr;
843
+ }
844
+ const operands = expr.operands.map(constantFoldExpression);
845
+ const folded: OperatorOperand = { operator: expr.operator, operands };
846
+
847
+ switch (expr.operator) {
848
+ case "if": {
849
+ const [condition, thenBranch, elseBranch] = operands;
850
+ if (condition !== undefined && isValueOperand(condition)) {
851
+ if (typeof condition.value !== "boolean") {
852
+ throw new Error("if (ternary) condition must be a boolean expression");
853
+ }
854
+ return assertDefined(
855
+ condition.value ? thenBranch : elseBranch,
856
+ "if (ternary) requires branch operands"
857
+ );
858
+ }
859
+ return folded;
860
+ }
861
+ case "and": {
862
+ if (operands.some((o) => isValueOperand(o) && o.value === false)) {
863
+ return { value: false };
864
+ }
865
+ const remaining = operands.filter((o) => !isValueOperand(o));
866
+ if (remaining.length === 0) return { value: true };
867
+ if (remaining.length === 1) return remaining[0]!;
868
+ return { operator: "and", operands: remaining };
869
+ }
870
+ case "or": {
871
+ if (operands.some((o) => isValueOperand(o) && o.value === true)) {
872
+ return { value: true };
873
+ }
874
+ const remaining = operands.filter((o) => !isValueOperand(o));
875
+ if (remaining.length === 0) return { value: false };
876
+ if (remaining.length === 1) return remaining[0]!;
877
+ return { operator: "or", operands: remaining };
878
+ }
879
+ case "not": {
880
+ const operand = operands[0];
881
+ if (operand !== undefined && isValueOperand(operand)) {
882
+ return { value: operand.value !== true };
883
+ }
884
+ return folded;
885
+ }
886
+ case "add":
887
+ case "sub":
888
+ case "mult":
889
+ case "div": {
890
+ const [left, right] = operands;
891
+ if (
892
+ left !== undefined &&
893
+ right !== undefined &&
894
+ isValueOperand(left) &&
895
+ isValueOperand(right)
896
+ ) {
897
+ return {
898
+ value: foldArithmetic(expr.operator, left.value, right.value),
899
+ };
900
+ }
901
+ return folded;
902
+ }
903
+ case "contains":
904
+ case "startsWith":
905
+ case "endsWith": {
906
+ const [receiver, needle] = operands;
907
+ if (
908
+ receiver !== undefined &&
909
+ needle !== undefined &&
910
+ isValueOperand(receiver) &&
911
+ isValueOperand(needle) &&
912
+ typeof receiver.value === "string" &&
913
+ typeof needle.value === "string"
914
+ ) {
915
+ const r = receiver.value;
916
+ const n = needle.value;
917
+ return {
918
+ value:
919
+ expr.operator === "contains"
920
+ ? r.includes(n)
921
+ : expr.operator === "startsWith"
922
+ ? r.startsWith(n)
923
+ : r.endsWith(n),
924
+ };
925
+ }
926
+ return folded;
927
+ }
928
+ case "exists":
929
+ case "all": {
930
+ const [collection, lambda] = operands;
931
+ if (
932
+ collection !== undefined &&
933
+ lambda !== undefined &&
934
+ isOperatorOperand(lambda) &&
935
+ lambda.operator === "lambda" &&
936
+ lambda.operands[0] !== undefined &&
937
+ isValueOperand(lambda.operands[0])
938
+ ) {
939
+ const bodyValue = lambda.operands[0].value === true;
940
+ if (expr.operator === "exists" && !bodyValue) return { value: false };
941
+ if (expr.operator === "all" && bodyValue) return { value: true };
942
+ const sizeExpr: PlanExpressionOperand = {
943
+ operator: "size",
944
+ operands: [collection],
945
+ };
946
+ // exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
947
+ return expr.operator === "exists"
948
+ ? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
949
+ : { operator: "eq", operands: [sizeExpr, { value: 0 }] };
950
+ }
951
+ return folded;
952
+ }
953
+ default: {
954
+ if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
955
+ const [left, right] = operands;
956
+ if (
957
+ left !== undefined &&
958
+ right !== undefined &&
959
+ isValueOperand(left) &&
960
+ isValueOperand(right)
961
+ ) {
962
+ return {
963
+ value: evaluateConstantComparison(
964
+ expr.operator,
965
+ left.value,
966
+ right.value
967
+ ),
968
+ };
969
+ }
970
+ }
971
+ return folded;
972
+ }
973
+ }
974
+ }
975
+
236
976
  /**
237
977
  * Resolves a field reference considering relations and nested fields.
238
978
  */
@@ -277,6 +1017,7 @@ function resolveFieldReference(
277
1017
  {
278
1018
  name,
279
1019
  type,
1020
+ model: activeConfig.relation.model,
280
1021
  field: activeConfig.relation.field,
281
1022
  nestedMapper: fields,
282
1023
  },
@@ -302,6 +1043,7 @@ function resolveFieldReference(
302
1043
  relations.push({
303
1044
  name: nextConfig.relation.name,
304
1045
  type: nextConfig.relation.type,
1046
+ model: nextConfig.relation.model,
305
1047
  field: nextConfig.relation.field,
306
1048
  nestedMapper: nextConfig.relation.fields,
307
1049
  });
@@ -318,11 +1060,18 @@ function resolveFieldReference(
318
1060
  }
319
1061
  }
320
1062
 
321
- return { path: field ? [field] : remainingParts, relations };
1063
+ return {
1064
+ path: field ? [field] : remainingParts,
1065
+ relations,
1066
+ valueType: activeConfig.valueType,
1067
+ };
322
1068
  }
323
1069
 
324
1070
  // Simple field mapping
325
- return { path: [activeConfig?.field || reference] };
1071
+ return {
1072
+ path: [activeConfig?.field || reference],
1073
+ valueType: activeConfig?.valueType,
1074
+ };
326
1075
  }
327
1076
 
328
1077
  /**
@@ -383,6 +1132,9 @@ function resolveOperand(
383
1132
  } else if (isValueOperand(operand)) {
384
1133
  return { value: operand.value };
385
1134
  } else if (isOperatorOperand(operand)) {
1135
+ if (operand.operator === "timestamp") {
1136
+ return resolveTimestampOperand(operand, mapper);
1137
+ }
386
1138
  const folded = tryFoldValueExpression(operand, mapper);
387
1139
  if (folded !== null) return { value: folded };
388
1140
  const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
@@ -391,11 +1143,39 @@ function resolveOperand(
391
1143
  throw new Error("Operand must have name, value, or be an expression");
392
1144
  }
393
1145
 
1146
+ function resolveTimestampOperand(
1147
+ expression: OperatorOperand,
1148
+ mapper: Mapper
1149
+ ): ResolvedOperand {
1150
+ if (expression.operands.length !== 1) {
1151
+ throw new Error("timestamp() requires exactly one operand");
1152
+ }
1153
+
1154
+ const operand = assertDefined(
1155
+ expression.operands[0],
1156
+ "timestamp() requires an operand"
1157
+ );
1158
+ if (isNamedOperand(operand)) {
1159
+ const fieldRef = resolveFieldReference(operand.name, mapper);
1160
+ if (fieldRef.valueType !== "dateTime") {
1161
+ throw new Error(
1162
+ `timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`
1163
+ );
1164
+ }
1165
+ return fieldRef;
1166
+ }
1167
+
1168
+ if (!isValueOperand(operand) || typeof operand.value !== "string") {
1169
+ throw new Error("timestamp() requires a field reference or RFC 3339 string");
1170
+ }
1171
+ return { value: normalizeRfc3339Milliseconds(operand.value) };
1172
+ }
1173
+
394
1174
  function tryFoldValueExpression(
395
1175
  expr: OperatorOperand,
396
1176
  mapper: Mapper
397
1177
  ): Value | null {
398
- if (expr.operator !== "add") return null;
1178
+ if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
399
1179
  const leftOp = expr.operands[0];
400
1180
  const rightOp = expr.operands[1];
401
1181
  if (!leftOp || !rightOp) return null;
@@ -406,12 +1186,27 @@ function tryFoldValueExpression(
406
1186
  if (!isResolvedValue(right)) return null;
407
1187
 
408
1188
  try {
409
- return foldAdd(left.value, right.value);
1189
+ return foldArithmetic(expr.operator, left.value, right.value);
410
1190
  } catch {
411
1191
  return null;
412
1192
  }
413
1193
  }
414
1194
 
1195
+ /**
1196
+ * Records that the lambda body being built touches a nullable element column, so the
1197
+ * enclosing collection operator can add its three-valued-logic guard.
1198
+ */
1199
+ function recordNullableElementField(
1200
+ config: MapperConfig,
1201
+ defaultField: string
1202
+ ): void {
1203
+ if (!config.nullable) {
1204
+ return;
1205
+ }
1206
+ const scope = lambdaScopes[lambdaScopes.length - 1];
1207
+ scope?.nullableFields.add(config.field || defaultField);
1208
+ }
1209
+
415
1210
  /**
416
1211
  * Creates a scoped mapper for collection operations
417
1212
  */
@@ -463,7 +1258,9 @@ function createScopedMapper(
463
1258
  }
464
1259
 
465
1260
  // Return the field config if it exists, otherwise create a default one
466
- return currentConfig[field] || { field };
1261
+ const fieldConfig = currentConfig[field] || { field };
1262
+ recordNullableElementField(fieldConfig, field);
1263
+ return fieldConfig;
467
1264
  }
468
1265
  return { field: strippedKey };
469
1266
  }
@@ -516,18 +1313,11 @@ function buildPrismaFilterFromCerbosExpression(
516
1313
  if (!operand) {
517
1314
  throw new Error("not operator requires an operand");
518
1315
  }
519
- if (isNamedOperand(operand)) {
520
- const { relations, ...fieldRef } = resolveFieldReference(
521
- operand.name,
522
- mapper
523
- );
524
- if (!relations || relations.length === 0) {
525
- return buildFieldEqualsFilter(fieldRef, false);
526
- }
527
- }
528
- return {
529
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
530
- };
1316
+ return buildNegatedFilter(operand, mapper);
1317
+ }
1318
+
1319
+ case "if": {
1320
+ return handleBooleanTernaryOperator(operands, mapper);
531
1321
  }
532
1322
 
533
1323
  case "eq":
@@ -590,6 +1380,467 @@ function buildPrismaFilterFromCerbosExpression(
590
1380
  }
591
1381
  }
592
1382
 
1383
+ function containsCollectionOperator(expr: PlanExpressionOperand): boolean {
1384
+ if (!isOperatorOperand(expr)) {
1385
+ return false;
1386
+ }
1387
+ if (COLLECTION_OPERATORS.has(expr.operator)) {
1388
+ return true;
1389
+ }
1390
+ return expr.operands.some(containsCollectionOperator);
1391
+ }
1392
+
1393
+ /**
1394
+ * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
1395
+ * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
1396
+ * subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
1397
+ * wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
1398
+ * attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
1399
+ * therefore pushed down to the collection operators, which encode CEL's exact error
1400
+ * semantics:
1401
+ *
1402
+ * - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
1403
+ * and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
1404
+ * UNKNOWN-element (the nullable-field guard).
1405
+ * - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
1406
+ * elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
1407
+ */
1408
+ function buildNegatedFilter(
1409
+ operand: PlanExpressionOperand,
1410
+ mapper: Mapper
1411
+ ): PrismaFilter {
1412
+ if (isNamedOperand(operand)) {
1413
+ const { relations, ...fieldRef } = resolveFieldReference(
1414
+ operand.name,
1415
+ mapper
1416
+ );
1417
+ if (!relations || relations.length === 0) {
1418
+ return buildFieldEqualsFilter(fieldRef, false);
1419
+ }
1420
+ return {
1421
+ NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
1422
+ };
1423
+ }
1424
+
1425
+ if (isOperatorOperand(operand) && containsCollectionOperator(operand)) {
1426
+ switch (operand.operator) {
1427
+ case "and":
1428
+ return {
1429
+ OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1430
+ };
1431
+ case "or":
1432
+ return {
1433
+ AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1434
+ };
1435
+ case "not":
1436
+ return buildPrismaFilterFromCerbosExpression(
1437
+ assertDefined(
1438
+ operand.operands[0],
1439
+ "not operator requires an operand"
1440
+ ),
1441
+ mapper
1442
+ );
1443
+ case "exists":
1444
+ case "all":
1445
+ case "except":
1446
+ case "exists_one":
1447
+ case "filter":
1448
+ return buildNegatedCollectionFilter(
1449
+ operand.operator,
1450
+ operand.operands,
1451
+ mapper
1452
+ );
1453
+ }
1454
+ }
1455
+
1456
+ return {
1457
+ NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
1458
+ };
1459
+ }
1460
+
1461
+ function getTernaryOperands(operands: PlanExpressionOperand[]): {
1462
+ condition: PlanExpressionOperand;
1463
+ thenBranch: PlanExpressionOperand;
1464
+ elseBranch: PlanExpressionOperand;
1465
+ } {
1466
+ if (operands.length !== 3) {
1467
+ throw new Error(
1468
+ `if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
1469
+ );
1470
+ }
1471
+
1472
+ return {
1473
+ condition: assertDefined(
1474
+ operands[0],
1475
+ "if (ternary) requires a condition operand"
1476
+ ),
1477
+ thenBranch: assertDefined(
1478
+ operands[1],
1479
+ "if (ternary) requires a then operand"
1480
+ ),
1481
+ elseBranch: assertDefined(
1482
+ operands[2],
1483
+ "if (ternary) requires an else operand"
1484
+ ),
1485
+ };
1486
+ }
1487
+
1488
+ function getConstantBooleanCondition(
1489
+ condition: PlanExpressionOperand
1490
+ ): boolean | undefined {
1491
+ if (!isValueOperand(condition)) {
1492
+ return undefined;
1493
+ }
1494
+ if (typeof condition.value !== "boolean") {
1495
+ throw new Error("if (ternary) condition must be a boolean expression");
1496
+ }
1497
+ return condition.value;
1498
+ }
1499
+
1500
+ function buildBooleanBranchFilter(
1501
+ branch: PlanExpressionOperand,
1502
+ mapper: Mapper
1503
+ ): TernaryBranchPredicate {
1504
+ if (!isValueOperand(branch)) {
1505
+ return {
1506
+ kind: "filter",
1507
+ filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
1508
+ };
1509
+ }
1510
+ if (typeof branch.value !== "boolean") {
1511
+ throw new Error(
1512
+ "if (ternary) branch in boolean position must be a boolean"
1513
+ );
1514
+ }
1515
+ return { kind: "constant", value: branch.value };
1516
+ }
1517
+
1518
+ function buildFalseConditionFilter(
1519
+ condition: PlanExpressionOperand,
1520
+ mapper: Mapper
1521
+ ): PrismaFilter {
1522
+ if (isValueOperand(condition)) {
1523
+ throw new Error("Constant ternary conditions must be folded before use");
1524
+ }
1525
+
1526
+ if (isNamedOperand(condition)) {
1527
+ const fieldRef = resolveFieldReference(condition.name, mapper);
1528
+ if (!fieldRef.relations || fieldRef.relations.length === 0) {
1529
+ return buildFieldEqualsFilter(fieldRef, false);
1530
+ }
1531
+ return {
1532
+ NOT: buildFieldEqualsFilter(fieldRef, true),
1533
+ };
1534
+ }
1535
+
1536
+ if (isOperatorOperand(condition) && condition.operator === "not") {
1537
+ return buildPrismaFilterFromCerbosExpression(
1538
+ assertDefined(condition.operands[0], "not operator requires an operand"),
1539
+ mapper
1540
+ );
1541
+ }
1542
+
1543
+ return {
1544
+ NOT: buildPrismaFilterFromCerbosExpression(condition, mapper),
1545
+ };
1546
+ }
1547
+
1548
+ function buildGuardedTernaryFilter({
1549
+ condition,
1550
+ thenFilter,
1551
+ elseFilter,
1552
+ mapper,
1553
+ }: {
1554
+ condition: PlanExpressionOperand;
1555
+ thenFilter: TernaryBranchPredicate;
1556
+ elseFilter: TernaryBranchPredicate;
1557
+ mapper: Mapper;
1558
+ }): PrismaFilter {
1559
+ const conditionTrue = buildPrismaFilterFromCerbosExpression(
1560
+ condition,
1561
+ mapper
1562
+ );
1563
+ const conditionFalse = buildFalseConditionFilter(condition, mapper);
1564
+ const guardedBranches: PrismaFilter[] = [];
1565
+
1566
+ if (thenFilter.kind === "filter") {
1567
+ guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
1568
+ } else if (thenFilter.value) {
1569
+ guardedBranches.push(conditionTrue);
1570
+ }
1571
+
1572
+ if (elseFilter.kind === "filter") {
1573
+ guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
1574
+ } else if (elseFilter.value) {
1575
+ guardedBranches.push(conditionFalse);
1576
+ }
1577
+
1578
+ // For a known condition this contradiction is false. If the condition is
1579
+ // NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
1580
+ // ternary UNKNOWN under an outer NOT instead of authorizing the row.
1581
+ guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
1582
+
1583
+ return {
1584
+ OR: guardedBranches,
1585
+ };
1586
+ }
1587
+
1588
+ function finalizeTernaryBranchPredicate(
1589
+ predicate: TernaryBranchPredicate
1590
+ ): PrismaFilter {
1591
+ if (predicate.kind === "filter") {
1592
+ return predicate.filter;
1593
+ }
1594
+ if (predicate.value) {
1595
+ return buildAlwaysTrueFilter();
1596
+ }
1597
+ // Prisma has no model-agnostic always-false `where` shape: empty logical
1598
+ // arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
1599
+ throw new Error(
1600
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
1601
+ );
1602
+ }
1603
+
1604
+ function handleBooleanTernaryOperator(
1605
+ operands: PlanExpressionOperand[],
1606
+ mapper: Mapper
1607
+ ): PrismaFilter {
1608
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
1609
+ const constantCondition = getConstantBooleanCondition(condition);
1610
+ if (constantCondition !== undefined) {
1611
+ return finalizeTernaryBranchPredicate(
1612
+ buildBooleanBranchFilter(
1613
+ constantCondition ? thenBranch : elseBranch,
1614
+ mapper
1615
+ )
1616
+ );
1617
+ }
1618
+
1619
+ return buildGuardedTernaryFilter({
1620
+ condition,
1621
+ thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
1622
+ elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
1623
+ mapper,
1624
+ });
1625
+ }
1626
+
1627
+ function buildTernaryComparisonBranch({
1628
+ operator,
1629
+ operands,
1630
+ ternaryIndex,
1631
+ branch,
1632
+ mapper,
1633
+ }: {
1634
+ operator: string;
1635
+ operands: PlanExpressionOperand[];
1636
+ ternaryIndex: number;
1637
+ branch: PlanExpressionOperand;
1638
+ mapper: Mapper;
1639
+ }): TernaryBranchPredicate {
1640
+ const substitutedOperands = operands.map((operand, index) =>
1641
+ index === ternaryIndex ? branch : operand
1642
+ );
1643
+ assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
1644
+ assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
1645
+
1646
+ const normalized = normalizeBinaryOperands(operator, substitutedOperands);
1647
+
1648
+ const normalizedFirst = normalized.operands[0];
1649
+ const normalizedSecond = normalized.operands[1];
1650
+ if (
1651
+ normalized.operands.length === 2 &&
1652
+ normalizedFirst !== undefined &&
1653
+ normalizedSecond !== undefined &&
1654
+ isValueOperand(normalizedFirst) &&
1655
+ isValueOperand(normalizedSecond)
1656
+ ) {
1657
+ return {
1658
+ kind: "constant",
1659
+ value: evaluateConstantComparison(
1660
+ normalized.operator,
1661
+ normalizedFirst.value,
1662
+ normalizedSecond.value
1663
+ ),
1664
+ };
1665
+ }
1666
+
1667
+ return {
1668
+ kind: "filter",
1669
+ filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
1670
+ };
1671
+ }
1672
+
1673
+ function tryHandleTernaryComparison(
1674
+ operator: string,
1675
+ operands: PlanExpressionOperand[],
1676
+ mapper: Mapper
1677
+ ): PrismaFilter | null {
1678
+ if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
1679
+ return null;
1680
+ }
1681
+
1682
+ const ternaryIndex = operands.findIndex(
1683
+ (operand) => isOperatorOperand(operand) && operand.operator === "if"
1684
+ );
1685
+ if (ternaryIndex === -1) {
1686
+ return null;
1687
+ }
1688
+ if (operands.length !== 2) {
1689
+ throw new Error(
1690
+ `${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
1691
+ );
1692
+ }
1693
+
1694
+ const ternary = assertDefined(
1695
+ operands[ternaryIndex],
1696
+ "Ternary comparison operand is missing"
1697
+ );
1698
+ if (!isOperatorOperand(ternary)) {
1699
+ throw new Error("Ternary comparison operand must be an expression");
1700
+ }
1701
+
1702
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(
1703
+ ternary.operands
1704
+ );
1705
+ const constantCondition = getConstantBooleanCondition(condition);
1706
+ if (constantCondition !== undefined) {
1707
+ return finalizeTernaryBranchPredicate(
1708
+ buildTernaryComparisonBranch({
1709
+ operator,
1710
+ operands,
1711
+ ternaryIndex,
1712
+ branch: constantCondition ? thenBranch : elseBranch,
1713
+ mapper,
1714
+ })
1715
+ );
1716
+ }
1717
+
1718
+ return buildGuardedTernaryFilter({
1719
+ condition,
1720
+ thenFilter: buildTernaryComparisonBranch({
1721
+ operator,
1722
+ operands,
1723
+ ternaryIndex,
1724
+ branch: thenBranch,
1725
+ mapper,
1726
+ }),
1727
+ elseFilter: buildTernaryComparisonBranch({
1728
+ operator,
1729
+ operands,
1730
+ ternaryIndex,
1731
+ branch: elseBranch,
1732
+ mapper,
1733
+ }),
1734
+ mapper,
1735
+ });
1736
+ }
1737
+
1738
+ function evaluateConstantComparison(
1739
+ operator: string,
1740
+ left: Value,
1741
+ right: Value
1742
+ ): boolean {
1743
+ switch (operator) {
1744
+ case "eq":
1745
+ return areValuesEqual(left, right);
1746
+ case "ne":
1747
+ return !areValuesEqual(left, right);
1748
+ case "lt":
1749
+ case "le":
1750
+ case "gt":
1751
+ case "ge": {
1752
+ if (
1753
+ typeof left === "number" &&
1754
+ typeof right === "number" &&
1755
+ (Number.isNaN(left) || Number.isNaN(right))
1756
+ ) {
1757
+ return false;
1758
+ }
1759
+ if (
1760
+ !(
1761
+ (typeof left === "number" && typeof right === "number") ||
1762
+ (typeof left === "string" && typeof right === "string")
1763
+ )
1764
+ ) {
1765
+ throw new Error(
1766
+ `${operator} constant comparison requires two numbers or two strings`
1767
+ );
1768
+ }
1769
+ switch (operator) {
1770
+ case "lt":
1771
+ return compareConstantValues(left, right) < 0;
1772
+ case "le":
1773
+ return compareConstantValues(left, right) <= 0;
1774
+ case "gt":
1775
+ return compareConstantValues(left, right) > 0;
1776
+ case "ge":
1777
+ return compareConstantValues(left, right) >= 0;
1778
+ }
1779
+ }
1780
+ }
1781
+
1782
+ throw new Error(`Unsupported constant comparison operator: ${operator}`);
1783
+ }
1784
+
1785
+ function compareConstantValues(left: number | string, right: number | string) {
1786
+ if (typeof left === "number" && typeof right === "number") {
1787
+ return left === right ? 0 : left < right ? -1 : 1;
1788
+ }
1789
+ if (typeof left !== "string" || typeof right !== "string") {
1790
+ throw new Error("Cannot order constant values of different types");
1791
+ }
1792
+
1793
+ const leftCodePoints = Array.from(left, (character) =>
1794
+ character.codePointAt(0)
1795
+ );
1796
+ const rightCodePoints = Array.from(right, (character) =>
1797
+ character.codePointAt(0)
1798
+ );
1799
+ const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
1800
+ for (let index = 0; index < sharedLength; index++) {
1801
+ const leftCodePoint = leftCodePoints[index]!;
1802
+ const rightCodePoint = rightCodePoints[index]!;
1803
+ if (leftCodePoint !== rightCodePoint) {
1804
+ return leftCodePoint < rightCodePoint ? -1 : 1;
1805
+ }
1806
+ }
1807
+ return leftCodePoints.length === rightCodePoints.length
1808
+ ? 0
1809
+ : leftCodePoints.length < rightCodePoints.length
1810
+ ? -1
1811
+ : 1;
1812
+ }
1813
+
1814
+ function areValuesEqual(left: Value, right: Value): boolean {
1815
+ if (Array.isArray(left)) {
1816
+ return (
1817
+ Array.isArray(right) &&
1818
+ left.length === right.length &&
1819
+ left.every((value, index) => areValuesEqual(value, right[index]!))
1820
+ );
1821
+ }
1822
+
1823
+ if (Array.isArray(right)) {
1824
+ return false;
1825
+ }
1826
+
1827
+ if (typeof left === "object" && left !== null) {
1828
+ if (typeof right !== "object" || right === null) {
1829
+ return false;
1830
+ }
1831
+ const leftKeys = Object.keys(left);
1832
+ const rightKeys = Object.keys(right);
1833
+ return (
1834
+ leftKeys.length === rightKeys.length &&
1835
+ leftKeys.every(
1836
+ (key) => key in right && areValuesEqual(left[key]!, right[key]!)
1837
+ )
1838
+ );
1839
+ }
1840
+
1841
+ return left === right;
1842
+ }
1843
+
593
1844
  function handleSizeComparison(
594
1845
  operator: string,
595
1846
  sizeOperand: OperatorOperand,
@@ -652,6 +1903,16 @@ function handleRelationalOperator(
652
1903
  operands: PlanExpressionOperand[],
653
1904
  mapper: Mapper
654
1905
  ): PrismaFilter {
1906
+ const ternaryFilter = tryHandleTernaryComparison(
1907
+ operator,
1908
+ operands,
1909
+ mapper
1910
+ );
1911
+ if (ternaryFilter) {
1912
+ return ternaryFilter;
1913
+ }
1914
+
1915
+ ({ operator, operands } = normalizeBinaryOperands(operator, operands));
655
1916
  const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
656
1917
 
657
1918
  if (!prismaOperator) {
@@ -670,27 +1931,215 @@ function handleRelationalOperator(
670
1931
  return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
671
1932
  }
672
1933
 
673
- const addOperand = [leftOperand, rightOperand].find(
674
- (o): o is OperatorOperand =>
675
- isOperatorOperand(o) && o.operator === "add"
1934
+ const arithOperand = [leftOperand, rightOperand].find(
1935
+ (o): o is OperatorOperand =>
1936
+ isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator)
1937
+ );
1938
+ if (arithOperand) {
1939
+ const otherOperand =
1940
+ arithOperand === leftOperand ? rightOperand : leftOperand;
1941
+ return handleArithmeticComparison(
1942
+ operator,
1943
+ arithOperand,
1944
+ otherOperand,
1945
+ arithOperand === leftOperand,
1946
+ mapper
1947
+ );
1948
+ }
1949
+
1950
+ const mapOperand = [leftOperand, rightOperand].find(
1951
+ (o): o is OperatorOperand =>
1952
+ isOperatorOperand(o) && o.operator === "map"
1953
+ );
1954
+ if (mapOperand) {
1955
+ throw new Error(
1956
+ `Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
1957
+ `Wrap the map() expression in hasIntersection(map(...), [...]) instead.`
1958
+ );
1959
+ }
1960
+
1961
+ const left = resolveOperand(leftOperand, mapper);
1962
+ const right = resolveOperand(rightOperand, mapper);
1963
+
1964
+ if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
1965
+ return buildFieldToFieldFilter(
1966
+ operator,
1967
+ leftOperand,
1968
+ left,
1969
+ rightOperand,
1970
+ right
1971
+ );
1972
+ }
1973
+
1974
+ if (isResolvedValue(left) && isResolvedFieldReference(right)) {
1975
+ return buildComparisonFilter(
1976
+ right,
1977
+ MIRRORED_OPERATOR[operator] ?? operator,
1978
+ left.value
1979
+ );
1980
+ }
1981
+
1982
+ if (isResolvedValue(left) && isResolvedValue(right)) {
1983
+ const matches = evaluateConstantComparison(
1984
+ operator,
1985
+ left.value,
1986
+ right.value
1987
+ );
1988
+ if (matches) {
1989
+ return buildAlwaysTrueFilter();
1990
+ }
1991
+ throw new Error(
1992
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
1993
+ );
1994
+ }
1995
+
1996
+ const rightValue = requireResolvedValue(
1997
+ right,
1998
+ "Right operand must be a value"
1999
+ );
2000
+
2001
+ if (isResolvedFieldReference(left)) {
2002
+ return buildComparisonFilter(left, operator, rightValue.value);
2003
+ }
2004
+
2005
+ return { [prismaOperator]: rightValue.value };
2006
+ }
2007
+
2008
+ /**
2009
+ * Builds a field-vs-constant comparison. Fractional constants against ordering (and
2010
+ * equality) operators are emitted as a two-clause bracket because Prisma coerces filter
2011
+ * values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
2012
+ * inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
2013
+ * neighbor so the combination is correct BOTH for Float columns (the extra clause is
2014
+ * redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
2015
+ */
2016
+ function buildComparisonFilter(
2017
+ fieldRef: ResolvedFieldReference,
2018
+ operator: string,
2019
+ value: Value
2020
+ ): PrismaFilter {
2021
+ const prismaOperator = assertDefined(
2022
+ CERBOS_TO_PRISMA_OPERATOR[operator],
2023
+ `Unsupported operator: ${operator}`
2024
+ );
2025
+
2026
+ if (typeof value === "number" && !Number.isInteger(value)) {
2027
+ const fieldName = getLeafField(fieldRef.path);
2028
+ const lower = Math.floor(value);
2029
+ const upper = Math.ceil(value);
2030
+ const equalsBracket = {
2031
+ AND: [
2032
+ { [fieldName]: { equals: value } },
2033
+ { [fieldName]: { gt: lower } },
2034
+ { [fieldName]: { lt: upper } },
2035
+ ],
2036
+ };
2037
+ let bracket: PrismaFilter | undefined;
2038
+ switch (operator) {
2039
+ case "gt":
2040
+ bracket = {
2041
+ OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
2042
+ };
2043
+ break;
2044
+ case "ge":
2045
+ bracket = {
2046
+ AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
2047
+ };
2048
+ break;
2049
+ case "lt":
2050
+ bracket = {
2051
+ OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
2052
+ };
2053
+ break;
2054
+ case "le":
2055
+ bracket = {
2056
+ AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
2057
+ };
2058
+ break;
2059
+ case "eq":
2060
+ bracket = equalsBracket;
2061
+ break;
2062
+ case "ne":
2063
+ bracket = { NOT: equalsBracket };
2064
+ break;
2065
+ }
2066
+ if (bracket) {
2067
+ return wrapRelations(fieldRef.relations, bracket);
2068
+ }
2069
+ }
2070
+
2071
+ return buildFieldFilter(fieldRef, prismaOperator, value);
2072
+ }
2073
+
2074
+ /**
2075
+ * Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
2076
+ * references between fields of the SAME model, so the container is the root model for
2077
+ * root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
2078
+ * (an element column against an outer column) are cross-model and must fail loudly.
2079
+ */
2080
+ function buildFieldToFieldFilter(
2081
+ operator: string,
2082
+ leftOperand: PlanExpressionOperand,
2083
+ left: ResolvedFieldReference,
2084
+ rightOperand: PlanExpressionOperand,
2085
+ right: ResolvedFieldReference
2086
+ ): PrismaFilter {
2087
+ const prismaOperator = assertDefined(
2088
+ CERBOS_TO_PRISMA_OPERATOR[operator],
2089
+ `Unsupported operator: ${operator}`
676
2090
  );
677
- if (addOperand) {
678
- const otherOperand =
679
- addOperand === leftOperand ? rightOperand : leftOperand;
680
- return handleAddComparison(operator, addOperand, otherOperand, mapper);
2091
+
2092
+ if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
2093
+ throw new Error("Field-to-field comparison requires two named operands");
681
2094
  }
682
2095
 
683
- const left = resolveOperand(leftOperand, mapper);
684
- const right = requireResolvedValue(
685
- resolveOperand(rightOperand, mapper),
686
- "Right operand must be a value"
687
- );
2096
+ const scope = lambdaScopes[lambdaScopes.length - 1];
2097
+ let container: string | undefined;
688
2098
 
689
- if (isResolvedFieldReference(left)) {
690
- return buildFieldFilter(left, prismaOperator, right.value);
2099
+ if (scope) {
2100
+ const prefix = scope.variableName + ".";
2101
+ const leftInScope = leftOperand.name.startsWith(prefix);
2102
+ const rightInScope = rightOperand.name.startsWith(prefix);
2103
+ if (leftInScope !== rightInScope) {
2104
+ throw new Error(
2105
+ `Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
2106
+ "Prisma field references only work between fields of the same model"
2107
+ );
2108
+ }
2109
+ if (leftInScope) {
2110
+ container = scope.relationModel;
2111
+ if (!container) {
2112
+ throw new Error(
2113
+ "Field-to-field comparison inside a collection requires `relation.model` in the mapper"
2114
+ );
2115
+ }
2116
+ }
2117
+ }
2118
+
2119
+ if (container === undefined) {
2120
+ if (
2121
+ (left.relations && left.relations.length > 0) ||
2122
+ (right.relations && right.relations.length > 0)
2123
+ ) {
2124
+ throw new Error(
2125
+ "Cannot compare columns across relations: Prisma field references only work between fields of the same model"
2126
+ );
2127
+ }
2128
+ container = rootModelName;
2129
+ if (!container) {
2130
+ throw new Error(
2131
+ "Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
2132
+ );
2133
+ }
691
2134
  }
692
2135
 
693
- return { [prismaOperator]: right.value };
2136
+ const leftField = getLeafField(left.path);
2137
+ const rightField = getLeafField(right.path);
2138
+ return {
2139
+ [leftField]: {
2140
+ [prismaOperator]: { _ref: rightField, _container: container },
2141
+ },
2142
+ };
694
2143
  }
695
2144
 
696
2145
  /**
@@ -700,46 +2149,137 @@ function handleInOperator(
700
2149
  operands: PlanExpressionOperand[],
701
2150
  mapper: Mapper
702
2151
  ): PrismaFilter {
703
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
704
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
705
- const fieldRef = requireResolvedFieldReference(
706
- resolveOperand(nameOperand, mapper),
707
- "Name operand must resolve to a field reference"
2152
+ if (operands.length !== 2) {
2153
+ throw new Error("in requires exactly two operands");
2154
+ }
2155
+ const member = assertDefined(operands[0], "in requires a member operand");
2156
+ const collection = assertDefined(
2157
+ operands[1],
2158
+ "in requires a collection operand"
708
2159
  );
709
- const { value } = requireResolvedValue(
710
- resolveOperand(valueOperand, mapper),
711
- "Value operand must resolve to a value"
2160
+
2161
+ if (isNamedOperand(member) && isValueOperand(collection)) {
2162
+ const fieldRef = resolveFieldReference(member.name, mapper);
2163
+ const values = Array.isArray(collection.value)
2164
+ ? collection.value
2165
+ : [collection.value];
2166
+ return buildMembershipFilter(fieldRef, values);
2167
+ }
2168
+
2169
+ if (isValueOperand(member) && isNamedOperand(collection)) {
2170
+ return buildMembershipFilter(
2171
+ resolveFieldReference(collection.name, mapper),
2172
+ [member.value]
2173
+ );
2174
+ }
2175
+
2176
+ if (isNamedOperand(member) && isNamedOperand(collection)) {
2177
+ throw new Error(
2178
+ "Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column"
2179
+ );
2180
+ }
2181
+
2182
+ throw new Error(
2183
+ "in requires a field member and literal list, or a literal member and field collection"
712
2184
  );
713
- const values = Array.isArray(value) ? value : [value];
714
- return buildFieldDirectOrInFilter(fieldRef, values);
715
2185
  }
716
2186
 
2187
+ // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
2188
+ const MAX_ENUMERATED_NEEDLES = 1000;
2189
+
717
2190
  /**
718
- * Helper function to handle string operators (contains, startsWith, endsWith)
2191
+ * Helper function to handle string operators (contains, startsWith, endsWith).
2192
+ *
2193
+ * These operators are receiver-sensitive and the planner preserves policy source order:
2194
+ * operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
2195
+ * inverts the match direction, so unlike symmetric comparisons they are never normalized.
719
2196
  */
720
2197
  function handleStringOperator(
721
2198
  operator: string,
722
2199
  operands: PlanExpressionOperand[],
723
2200
  mapper: Mapper
724
2201
  ): PrismaFilter {
725
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
726
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
727
- const fieldRef = requireResolvedFieldReference(
728
- resolveOperand(nameOperand, mapper),
729
- "Name operand must resolve to a field reference"
2202
+ if (operands.length !== 2) {
2203
+ throw new Error(`${operator} requires exactly two operands`);
2204
+ }
2205
+ const receiver = resolveOperand(
2206
+ assertDefined(operands[0], `${operator} requires a receiver operand`),
2207
+ mapper
730
2208
  );
731
- const { value } = requireResolvedValue(
732
- resolveOperand(valueOperand, mapper),
733
- "Value operand must resolve to a value"
2209
+ const needle = resolveOperand(
2210
+ assertDefined(operands[1], `${operator} requires a needle operand`),
2211
+ mapper
734
2212
  );
735
- if (typeof value !== "string") {
736
- throw new Error(`${operator} operator requires string value`);
2213
+
2214
+ // Column receiver, constant needle: Prisma's LIKE-based filter.
2215
+ if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
2216
+ const { value } = needle;
2217
+ if (typeof value !== "string") {
2218
+ throw new Error(`${operator} operator requires string value`);
2219
+ }
2220
+ // Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
2221
+ // so a needle containing % or _ would match as a pattern instead of literally (e.g.
2222
+ // startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
2223
+ if (/[%_]/.test(value)) {
2224
+ throw new Error(
2225
+ `Cannot translate ${operator} with a needle containing LIKE metacharacters (% or _): ` +
2226
+ "Prisma does not escape wildcards in string filters"
2227
+ );
2228
+ }
2229
+ const fieldName = getLeafField(receiver.path);
2230
+ return wrapRelations(receiver.relations, {
2231
+ [fieldName]: { [operator]: value },
2232
+ });
737
2233
  }
738
2234
 
739
- const fieldName = getLeafField(fieldRef.path);
740
- return wrapRelations(fieldRef.relations, {
741
- [fieldName]: { [operator]: value },
742
- });
2235
+ // Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
2236
+ // candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
2237
+ // substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
2238
+ // NULL needle column stays excluded (CEL missing-attribute deny).
2239
+ if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
2240
+ if (typeof receiver.value !== "string") {
2241
+ throw new Error(`${operator} operator requires a string receiver`);
2242
+ }
2243
+ const haystack = receiver.value;
2244
+ const candidates = new Set<string>([""]);
2245
+ if (operator === "startsWith") {
2246
+ for (let i = 1; i <= haystack.length; i++) {
2247
+ candidates.add(haystack.slice(0, i));
2248
+ }
2249
+ } else if (operator === "endsWith") {
2250
+ for (let i = 0; i < haystack.length; i++) {
2251
+ candidates.add(haystack.slice(i));
2252
+ }
2253
+ } else {
2254
+ for (let start = 0; start < haystack.length; start++) {
2255
+ for (let end = start + 1; end <= haystack.length; end++) {
2256
+ candidates.add(haystack.slice(start, end));
2257
+ }
2258
+ }
2259
+ }
2260
+ if (candidates.size > MAX_ENUMERATED_NEEDLES) {
2261
+ throw new Error(
2262
+ `Cannot translate ${operator} with a constant receiver of this length: ` +
2263
+ `enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
2264
+ );
2265
+ }
2266
+ const fieldName = getLeafField(needle.path);
2267
+ return wrapRelations(needle.relations, {
2268
+ [fieldName]: { in: [...candidates] },
2269
+ });
2270
+ }
2271
+
2272
+ if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
2273
+ // A column-valued needle would need its LIKE metacharacters escaped per row, which
2274
+ // Prisma's filters cannot do (and Prisma leaks wildcards from field references).
2275
+ throw new Error(
2276
+ `Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
2277
+ );
2278
+ }
2279
+
2280
+ throw new Error(
2281
+ `${operator} between two constants must be folded by the Cerbos planner`
2282
+ );
743
2283
  }
744
2284
 
745
2285
  /**
@@ -777,6 +2317,11 @@ function handleHasIntersectionOperator(
777
2317
  throw new Error("hasIntersection requires exactly two operands");
778
2318
  }
779
2319
 
2320
+ // Intersection is symmetric, and the planner preserves policy source order — e.g.
2321
+ // `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
2322
+ // field/map-first (see #256).
2323
+ ({ operands } = normalizeBinaryOperands("hasIntersection", operands));
2324
+
780
2325
  const leftOperand = assertDefined(
781
2326
  operands[0],
782
2327
  "hasIntersection requires a left operand"
@@ -838,13 +2383,42 @@ function handleHasIntersectionOperator(
838
2383
  throw new Error("Invalid map lambda expression structure");
839
2384
  }
840
2385
 
841
- // Use scoped mapper for resolving the projection
842
- const resolved = resolveFieldReference(projection.name, scopedMapper);
2386
+ // Use scoped mapper for resolving the projection, collecting nullable element columns.
2387
+ const scope: LambdaScope = {
2388
+ variableName: variable.name,
2389
+ relationModel: relations[relations.length - 1]?.model,
2390
+ nullableFields: new Set(),
2391
+ unknownFilters: [],
2392
+ };
2393
+ lambdaScopes.push(scope);
2394
+ let resolved: ResolvedFieldReference;
2395
+ try {
2396
+ resolved = resolveFieldReference(projection.name, scopedMapper);
2397
+ } finally {
2398
+ lambdaScopes.pop();
2399
+ }
843
2400
  const fieldName = getLeafField(resolved.path);
844
2401
 
845
- return buildNestedRelationFilter(relations, {
2402
+ const base = buildNestedRelationFilter(relations, {
846
2403
  [fieldName]: { in: rightOperand.value },
847
2404
  });
2405
+ if (scope.nullableFields.size === 0) {
2406
+ return base;
2407
+ }
2408
+ // CEL map() errors if ANY element is missing the projected attribute — even alongside a
2409
+ // matching element — and Cerbos treats that error as deny. Exclude rows holding an
2410
+ // element whose projected (nullable) column is NULL.
2411
+ return {
2412
+ AND: [
2413
+ base,
2414
+ {
2415
+ NOT: buildNestedRelationFilter(
2416
+ relations,
2417
+ buildNullWitnessFilter(scope.nullableFields)
2418
+ ),
2419
+ },
2420
+ ],
2421
+ };
848
2422
  }
849
2423
 
850
2424
  // Handle regular field reference
@@ -865,11 +2439,7 @@ function handleHasIntersectionOperator(
865
2439
  }
866
2440
 
867
2441
  if (relations && relations.length > 0) {
868
- const fieldName = getLeafField(path);
869
- const fieldFilter = {
870
- [fieldName]: { in: rightOperand.value },
871
- };
872
- return buildNestedRelationFilter(relations, fieldFilter);
2442
+ return buildMembershipFilter({ path, relations }, rightOperand.value);
873
2443
  }
874
2444
 
875
2445
  const fieldName = getLeafField(path);
@@ -898,14 +2468,22 @@ function handleLambdaOperator(operands: PlanExpressionOperand[]): PrismaFilter {
898
2468
  }));
899
2469
  }
900
2470
 
901
- /**
902
- * Helper function to handle collection operators (exists, all, except, filter)
903
- */
904
- function handleCollectionOperator(
2471
+ type CollectionLambdaParts = {
2472
+ head: RelationConfig;
2473
+ restRelations: RelationConfig[];
2474
+ /** Element predicate, already wrapped through any relations beyond the first. */
2475
+ filterValue: PrismaFilter;
2476
+ /** Nullable element columns referenced by the lambda body (for 3VL guards). */
2477
+ nullableFields: Set<string>;
2478
+ /** Element-level predicates under which a nested collection expression is UNKNOWN. */
2479
+ unknownFilters: PrismaFilter[];
2480
+ };
2481
+
2482
+ function buildCollectionLambdaParts(
905
2483
  operator: string,
906
2484
  operands: PlanExpressionOperand[],
907
2485
  mapper: Mapper
908
- ): PrismaFilter {
2486
+ ): CollectionLambdaParts {
909
2487
  if (operands.length !== 2) {
910
2488
  throw new Error(`${operator} requires exactly two operands`);
911
2489
  }
@@ -952,23 +2530,46 @@ function handleCollectionOperator(
952
2530
  throw new Error(`${operator} operator requires a relation mapping`);
953
2531
  }
954
2532
 
2533
+ const head = assertDefined(
2534
+ relations[0],
2535
+ `${operator} operator requires a relation mapping`
2536
+ );
2537
+ const restRelations = relations.slice(1);
2538
+ const deepest = assertDefined(
2539
+ relations[relations.length - 1],
2540
+ `${operator} operator requires a relation mapping`
2541
+ );
2542
+
955
2543
  const lambdaConditionOperand = assertDefined(
956
2544
  lambda.operands[0],
957
2545
  "Lambda expression must provide a condition"
958
2546
  );
959
- const lambdaCondition = buildPrismaFilterFromCerbosExpression(
960
- lambdaConditionOperand, // Use the condition part of the lambda
961
- scopedMapper
962
- );
963
2547
 
964
- const relation = assertDefined(
965
- relations[0],
966
- `${operator} operator requires a relation mapping`
967
- );
2548
+ const scope: LambdaScope = {
2549
+ variableName: variable.name,
2550
+ relationModel: deepest.model,
2551
+ nullableFields: new Set(),
2552
+ unknownFilters: [],
2553
+ };
2554
+ lambdaScopes.push(scope);
2555
+ let lambdaCondition: PrismaFilter;
2556
+ try {
2557
+ lambdaCondition = buildPrismaFilterFromCerbosExpression(
2558
+ lambdaConditionOperand, // Use the condition part of the lambda
2559
+ scopedMapper
2560
+ );
2561
+ } finally {
2562
+ lambdaScopes.pop();
2563
+ }
2564
+
968
2565
  let filterValue = lambdaCondition;
969
2566
 
970
- // If the lambda condition already has a relation structure, merge it
971
- if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
2567
+ if (restRelations.length > 0) {
2568
+ // Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
2569
+ // of the chain, so the element predicate must join through every intermediate hop.
2570
+ filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
2571
+ } else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
2572
+ // If the lambda condition already has a logical structure, use it as-is
972
2573
  filterValue = lambdaCondition;
973
2574
  } else {
974
2575
  const lambdaKeys = Object.keys(lambdaCondition);
@@ -980,40 +2581,209 @@ function handleCollectionOperator(
980
2581
  if (lambdaFieldValue === undefined) {
981
2582
  throw new Error("Lambda condition field value cannot be undefined");
982
2583
  }
983
- const filterField = relation.field || defaultKey;
2584
+ const filterField = head.field || defaultKey;
984
2585
  filterValue = {
985
2586
  [filterField]: lambdaFieldValue,
986
2587
  };
987
2588
  }
988
2589
 
2590
+ return {
2591
+ head,
2592
+ restRelations,
2593
+ filterValue,
2594
+ nullableFields: scope.nullableFields,
2595
+ unknownFilters: scope.unknownFilters,
2596
+ };
2597
+ }
2598
+
2599
+ /** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
2600
+ function buildNullWitnessFilter(nullableFields: Set<string>): PrismaFilter {
2601
+ const checks = [...nullableFields].map((field) => ({ [field]: null }));
2602
+ if (checks.length === 1) {
2603
+ return checks[0]!;
2604
+ }
2605
+ return { OR: checks };
2606
+ }
2607
+
2608
+ function buildUnknownElementFilter(
2609
+ parts: CollectionLambdaParts
2610
+ ): PrismaFilter | undefined {
2611
+ const filters = [...parts.unknownFilters];
2612
+ if (parts.nullableFields.size > 0) {
2613
+ filters.push(buildNullWitnessFilter(parts.nullableFields));
2614
+ }
2615
+ if (filters.length === 0) {
2616
+ return undefined;
2617
+ }
2618
+ if (filters.length === 1) {
2619
+ return filters[0];
2620
+ }
2621
+ return { OR: filters };
2622
+ }
2623
+
2624
+ function recordNestedCollectionUnknownFilter(filter: PrismaFilter): void {
2625
+ const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
2626
+ enclosingScope?.unknownFilters.push(filter);
2627
+ }
2628
+
2629
+ /** Wraps an element-level filter through the full relation chain (head + rest). */
2630
+ function wrapCollectionElementFilter(
2631
+ parts: CollectionLambdaParts,
2632
+ elementFilter: PrismaFilter
2633
+ ): PrismaFilter {
2634
+ const inner =
2635
+ parts.restRelations.length > 0
2636
+ ? buildNestedRelationFilter(parts.restRelations, elementFilter)
2637
+ : elementFilter;
2638
+ return { [parts.head.name]: { some: inner } };
2639
+ }
2640
+
2641
+ function throwUnsupportedCollectionOperator(operator: string): never {
2642
+ if (operator === "exists_one") {
2643
+ throw new Error(
2644
+ "exists_one requires counting matching elements, which Prisma where-filters cannot express"
2645
+ );
2646
+ }
2647
+ throw new Error(
2648
+ "The filter() collection operator returns a list, not a boolean. " +
2649
+ "It cannot be used as a standalone condition. " +
2650
+ "Use exists() or combine filter() with size() instead."
2651
+ );
2652
+ }
2653
+
2654
+ /**
2655
+ * Helper function to handle collection operators (exists, all, except, filter)
2656
+ */
2657
+ function handleCollectionOperator(
2658
+ operator: string,
2659
+ operands: PlanExpressionOperand[],
2660
+ mapper: Mapper
2661
+ ): PrismaFilter {
2662
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(
2663
+ operator,
2664
+ operands,
2665
+ mapper,
2666
+ false
2667
+ );
2668
+ if (knownValueFilter !== undefined) {
2669
+ return knownValueFilter;
2670
+ }
2671
+
2672
+ if (operator === "exists_one" || operator === "filter") {
2673
+ throwUnsupportedCollectionOperator(operator);
2674
+ }
2675
+
2676
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
2677
+ const { head, filterValue, nullableFields } = parts;
2678
+
989
2679
  switch (operator) {
990
- case "exists":
991
- return { [relation.name]: { some: filterValue } };
992
- case "filter":
993
- throw new Error(
994
- "The filter() collection operator returns a list, not a boolean. " +
995
- "It cannot be used as a standalone condition. " +
996
- "Use exists() or combine filter() with size() instead."
997
- );
2680
+ case "exists": {
2681
+ // A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
2682
+ // false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
2683
+ const filter = { [head.name]: { some: filterValue } };
2684
+ const unknownElement = buildUnknownElementFilter(parts);
2685
+ if (unknownElement !== undefined) {
2686
+ recordNestedCollectionUnknownFilter({
2687
+ AND: [
2688
+ { NOT: filter },
2689
+ wrapCollectionElementFilter(parts, unknownElement),
2690
+ ],
2691
+ });
2692
+ }
2693
+ return filter;
2694
+ }
998
2695
  case "except":
999
- return { [relation.name]: { some: { NOT: filterValue } } };
1000
- case "exists_one":
2696
+ return { [head.name]: { some: { NOT: filterValue } } };
2697
+ case "all": {
2698
+ if (parts.restRelations.length > 0) {
2699
+ throw new Error(
2700
+ "all() over a multi-hop relation chain is not supported"
2701
+ );
2702
+ }
2703
+ const base = { [head.name]: { every: filterValue } };
2704
+ if (nullableFields.size === 0) {
2705
+ return base;
2706
+ }
2707
+ // CEL all() errors when any element evaluation errors without a false witness; SQL
2708
+ // `every` would treat those elements as vacuously passing. Exclude rows holding any
2709
+ // element whose referenced nullable column is NULL. (`every` already rejects rows
2710
+ // with a definitive false witness, matching error absorption.)
2711
+ return {
2712
+ AND: [
2713
+ base,
2714
+ { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
2715
+ ],
2716
+ };
2717
+ }
2718
+ default:
2719
+ throw new Error(`Unexpected operator: ${operator}`);
2720
+ }
2721
+ }
2722
+
2723
+ /**
2724
+ * Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
2725
+ * buildNegatedFilter).
2726
+ */
2727
+ function buildNegatedCollectionFilter(
2728
+ operator: string,
2729
+ operands: PlanExpressionOperand[],
2730
+ mapper: Mapper
2731
+ ): PrismaFilter {
2732
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(
2733
+ operator,
2734
+ operands,
2735
+ mapper,
2736
+ true
2737
+ );
2738
+ if (knownValueFilter !== undefined) {
2739
+ return knownValueFilter;
2740
+ }
2741
+
2742
+ if (operator === "exists_one" || operator === "filter") {
2743
+ throwUnsupportedCollectionOperator(operator);
2744
+ }
2745
+
2746
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
2747
+ const { head, filterValue, nullableFields } = parts;
2748
+
2749
+ switch (operator) {
2750
+ case "exists": {
2751
+ const base = { NOT: { [head.name]: { some: filterValue } } };
2752
+ const unknownElement = buildUnknownElementFilter(parts);
2753
+ if (unknownElement === undefined) {
2754
+ return base;
2755
+ }
2756
+ // !exists is TRUE only when every element is definitively false: no P-match AND no
2757
+ // element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
1001
2758
  return {
1002
- [relation.name]: {
1003
- some: filterValue,
1004
- },
1005
2759
  AND: [
2760
+ base,
1006
2761
  {
1007
- [relation.name]: {
1008
- every: {
1009
- OR: [filterValue, { NOT: filterValue }],
1010
- },
1011
- },
2762
+ NOT: wrapCollectionElementFilter(
2763
+ parts,
2764
+ unknownElement
2765
+ ),
1012
2766
  },
1013
2767
  ],
1014
2768
  };
2769
+ }
1015
2770
  case "all":
1016
- return { [relation.name]: { every: filterValue } };
2771
+ // !all is TRUE only with a definitive false witness, which also absorbs error
2772
+ // elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
2773
+ return { [head.name]: { some: { NOT: filterValue } } };
2774
+ case "except": {
2775
+ // !except(c,P) = every element definitively matches P.
2776
+ const base = { [head.name]: { none: { NOT: filterValue } } };
2777
+ if (nullableFields.size === 0) {
2778
+ return base;
2779
+ }
2780
+ return {
2781
+ AND: [
2782
+ base,
2783
+ { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
2784
+ ],
2785
+ };
2786
+ }
1017
2787
  default:
1018
2788
  throw new Error(`Unexpected operator: ${operator}`);
1019
2789
  }
@@ -1091,83 +2861,198 @@ function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
1091
2861
  return buildFieldFilter(fieldRef, "in", []);
1092
2862
  }
1093
2863
 
1094
- function handleAddComparison(
2864
+ const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
2865
+
2866
+ /**
2867
+ * Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
2868
+ * column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
2869
+ * one constant side always rewrites to a plain comparison. Multiplying/dividing by a
2870
+ * negative constant mirrors directional operators. The rewrite happens in IEEE double space,
2871
+ * matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
2872
+ * (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
2873
+ * exactly as CEL evaluates it).
2874
+ */
2875
+ function handleArithmeticComparison(
1095
2876
  operator: string,
1096
- addExpr: OperatorOperand,
2877
+ arithExpr: OperatorOperand,
1097
2878
  otherOperand: PlanExpressionOperand,
2879
+ arithIsLeft: boolean,
1098
2880
  mapper: Mapper
1099
2881
  ): PrismaFilter {
1100
- const addLeftOp = assertDefined(
1101
- addExpr.operands[0],
1102
- "add operator requires a left operand"
2882
+ const arithOp = arithExpr.operator;
2883
+ const arithLeftOp = assertDefined(
2884
+ arithExpr.operands[0],
2885
+ `${arithOp} operator requires a left operand`
1103
2886
  );
1104
- const addRightOp = assertDefined(
1105
- addExpr.operands[1],
1106
- "add operator requires a right operand"
2887
+ const arithRightOp = assertDefined(
2888
+ arithExpr.operands[1],
2889
+ `${arithOp} operator requires a right operand`
1107
2890
  );
1108
2891
 
1109
- const addLeft = resolveOperand(addLeftOp, mapper);
1110
- const addRight = resolveOperand(addRightOp, mapper);
1111
- const other = resolveOperand(otherOperand, mapper);
2892
+ if (
2893
+ isOperatorOperand(otherOperand) &&
2894
+ ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
2895
+ tryFoldValueExpression(otherOperand, mapper) === null
2896
+ ) {
2897
+ throw new Error(
2898
+ "Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter"
2899
+ );
2900
+ }
1112
2901
 
1113
- if (isResolvedValue(addLeft) && isResolvedValue(addRight)) {
1114
- const folded = foldAdd(addLeft.value, addRight.value);
1115
- const prismaOp = CERBOS_TO_PRISMA_OPERATOR[operator];
1116
- if (!prismaOp) throw new Error(`Unsupported operator: ${operator}`);
2902
+ const arithLeft = resolveOperand(arithLeftOp, mapper);
2903
+ const arithRight = resolveOperand(arithRightOp, mapper);
2904
+ const other = resolveOperand(otherOperand, mapper);
1117
2905
 
1118
- if (isResolvedFieldReference(other)) {
1119
- return buildFieldFilter(other, prismaOp, folded);
2906
+ // Fully constant arithmetic: fold and compare the other side against the result. The
2907
+ // arithmetic side keeps its source position, so when the folded constant was written
2908
+ // FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
2909
+ if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
2910
+ const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
2911
+ if (!isResolvedFieldReference(other)) {
2912
+ throw new Error(
2913
+ `${arithOp} with two values requires a field reference on the other side`
2914
+ );
1120
2915
  }
1121
- throw new Error("add with two values requires a field reference on the other side");
2916
+ const effectiveOperator = arithIsLeft
2917
+ ? (MIRRORED_OPERATOR[operator] ?? operator)
2918
+ : operator;
2919
+ return buildComparisonFilter(other, effectiveOperator, folded);
1122
2920
  }
1123
2921
 
1124
2922
  if (!isResolvedValue(other)) {
1125
2923
  throw new Error(
1126
- "add operator with field references requires a value on the other side of the comparison"
2924
+ `${arithOp} operator with field references requires a value on the other side of the comparison`
1127
2925
  );
1128
2926
  }
1129
2927
 
1130
2928
  let fieldRef: ResolvedFieldReference;
1131
- let addValue: Value;
2929
+ let constant: Value;
1132
2930
  let fieldIsLeft: boolean;
1133
2931
 
1134
- if (isResolvedFieldReference(addLeft) && isResolvedValue(addRight)) {
1135
- fieldRef = addLeft;
1136
- addValue = addRight.value;
2932
+ if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
2933
+ fieldRef = arithLeft;
2934
+ constant = arithRight.value;
1137
2935
  fieldIsLeft = true;
1138
- } else if (isResolvedValue(addLeft) && isResolvedFieldReference(addRight)) {
1139
- fieldRef = addRight;
1140
- addValue = addLeft.value;
2936
+ } else if (
2937
+ isResolvedValue(arithLeft) &&
2938
+ isResolvedFieldReference(arithRight)
2939
+ ) {
2940
+ fieldRef = arithRight;
2941
+ constant = arithLeft.value;
1141
2942
  fieldIsLeft = false;
1142
2943
  } else {
1143
2944
  throw new Error(
1144
- "add operator requires exactly one field reference and one value, or two values"
2945
+ `${arithOp} operator requires exactly one field reference and one value, or two values`
1145
2946
  );
1146
2947
  }
1147
2948
 
1148
- if (operator !== "eq" && operator !== "ne") {
2949
+ // The comparison operator was normalized arithmetic-side-first by the caller; if the
2950
+ // arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
2951
+ let effectiveOperator = arithIsLeft
2952
+ ? operator
2953
+ : (MIRRORED_OPERATOR[operator] ?? operator);
2954
+
2955
+ // String concatenation solving (eq/ne only).
2956
+ if (arithOp === "add" && typeof constant === "string") {
2957
+ if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
2958
+ throw new Error(
2959
+ `Operator ${effectiveOperator} is not supported with string concatenation`
2960
+ );
2961
+ }
2962
+ const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
2963
+ if (solvedValue === null) {
2964
+ if (effectiveOperator === "eq") return buildImpossibleFilter(fieldRef);
2965
+ return {};
2966
+ }
2967
+ return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
2968
+ }
2969
+
2970
+ if (typeof constant !== "number" || typeof other.value !== "number") {
2971
+ throw new Error(`${arithOp} comparison requires numeric operands`);
2972
+ }
2973
+
2974
+ if (
2975
+ arithOp === "add" &&
2976
+ (effectiveOperator === "eq" || effectiveOperator === "ne") &&
2977
+ (!Number.isInteger(constant) || !Number.isInteger(other.value))
2978
+ ) {
1149
2979
  throw new Error(
1150
- `Operator ${operator} is not supported with add and field references`
2980
+ "Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
1151
2981
  );
1152
2982
  }
1153
2983
 
1154
- const solvedValue = solveAdd(other.value, addValue, fieldIsLeft);
1155
- if (solvedValue === null) {
1156
- if (operator === "eq") return buildImpossibleFilter(fieldRef);
1157
- return {};
2984
+ let solved: number;
2985
+ switch (arithOp) {
2986
+ case "add":
2987
+ solved = other.value - constant;
2988
+ break;
2989
+ case "sub":
2990
+ if (fieldIsLeft) {
2991
+ // f - c CMP v ⇔ f CMP v + c
2992
+ solved = other.value + constant;
2993
+ } else {
2994
+ // c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
2995
+ solved = constant - other.value;
2996
+ effectiveOperator =
2997
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2998
+ }
2999
+ break;
3000
+ case "mult":
3001
+ if (constant === 0) {
3002
+ throw new Error(
3003
+ "Multiplication by a constant zero must be folded by the Cerbos planner"
3004
+ );
3005
+ }
3006
+ solved = other.value / constant;
3007
+ if (constant < 0) {
3008
+ effectiveOperator =
3009
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
3010
+ }
3011
+ break;
3012
+ case "div":
3013
+ if (!fieldIsLeft) {
3014
+ throw new Error(
3015
+ "Division by a column is not supported: the comparison cannot be solved to a plain column filter"
3016
+ );
3017
+ }
3018
+ if (constant === 0) {
3019
+ throw new Error("Division by a constant zero is not supported");
3020
+ }
3021
+ solved = other.value * constant;
3022
+ if (constant < 0) {
3023
+ effectiveOperator =
3024
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
3025
+ }
3026
+ break;
3027
+ default:
3028
+ throw new Error(`Unsupported operator: ${arithOp}`);
1158
3029
  }
1159
3030
 
1160
- return buildFieldFilter(fieldRef, CERBOS_TO_PRISMA_OPERATOR[operator]!, solvedValue);
3031
+ return buildComparisonFilter(fieldRef, effectiveOperator, solved);
1161
3032
  }
1162
3033
 
1163
- function foldAdd(left: Value, right: Value): Value {
1164
- if (typeof left === "string" || typeof right === "string") {
3034
+ function foldArithmetic(operator: string, left: Value, right: Value): Value {
3035
+ if (
3036
+ operator === "add" &&
3037
+ (typeof left === "string" || typeof right === "string")
3038
+ ) {
1165
3039
  return String(left) + String(right);
1166
3040
  }
1167
- if (typeof left === "number" && typeof right === "number") {
1168
- return left + right;
3041
+ if (typeof left !== "number" || typeof right !== "number") {
3042
+ throw new Error(`${operator} operator requires string or number operands`);
3043
+ }
3044
+ switch (operator) {
3045
+ case "add":
3046
+ return left + right;
3047
+ case "sub":
3048
+ return left - right;
3049
+ case "mult":
3050
+ return left * right;
3051
+ case "div":
3052
+ return left / right;
3053
+ default:
3054
+ throw new Error(`Unsupported operator: ${operator}`);
1169
3055
  }
1170
- throw new Error("add operator requires string or number operands");
1171
3056
  }
1172
3057
 
1173
3058
  function solveAdd(
@@ -1377,6 +3262,24 @@ function handleOverlapsOperator(
1377
3262
  return validConditions[0]!;
1378
3263
  }
1379
3264
 
3265
+ /**
3266
+ * Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
3267
+ *
3268
+ * `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
3269
+ * match as wildcards instead of literally and widen the filter past what the PDP allows.
3270
+ * `[` is unsafe for a separate reason: SQL Server opens a character class on `[` even when
3271
+ * an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly rather
3272
+ * than emit a filter that admits denied rows.
3273
+ */
3274
+ function assertLikeSafePrefix(prefix: string): void {
3275
+ if (/[%_\[]/.test(prefix)) {
3276
+ throw new Error(
3277
+ "Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
3278
+ "Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one"
3279
+ );
3280
+ }
3281
+ }
3282
+
1380
3283
  function handleFieldOverlaps(
1381
3284
  left: ResolvedHierarchy,
1382
3285
  right: ResolvedHierarchy
@@ -1401,7 +3304,9 @@ function handleFieldOverlaps(
1401
3304
  conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
1402
3305
  }
1403
3306
  conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
1404
- conditions.push(buildFieldFilter(field.fieldRef, "startsWith", otherRaw + delimiter));
3307
+ const prefix = otherRaw + delimiter;
3308
+ assertLikeSafePrefix(prefix);
3309
+ conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
1405
3310
 
1406
3311
  if (conditions.length === 1) return conditions[0]!;
1407
3312
  return { OR: conditions };
@@ -1457,7 +3362,9 @@ function handleAncestorDescendantOperator(
1457
3362
  const descendant = direction === "ancestor" ? right : left;
1458
3363
 
1459
3364
  if (ancestor.type === "constant" && descendant.type === "field") {
1460
- const prefix = ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
3365
+ const prefix =
3366
+ ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
3367
+ assertLikeSafePrefix(prefix);
1461
3368
  return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
1462
3369
  }
1463
3370