@cerbos/orm-prisma 3.2.1 → 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 (45) hide show
  1. package/README.md +91 -3
  2. package/lib/index.d.ts +29 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1792 -150
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -11
  7. package/src/adversarial.test.ts +586 -0
  8. package/src/generated/prisma/client.ts +3 -1
  9. package/src/generated/prisma/commonInputTypes.ts +26 -0
  10. package/src/generated/prisma/internal/class.ts +22 -10
  11. package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
  12. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
  13. package/src/generated/prisma/models/Category.ts +6 -1
  14. package/src/generated/prisma/models/Label.ts +6 -1
  15. package/src/generated/prisma/models/NestedResource.ts +6 -1
  16. package/src/generated/prisma/models/NextLevelNestedResource.ts +6 -1
  17. package/src/generated/prisma/models/Resource.ts +67 -2
  18. package/src/generated/prisma/models/SubCategory.ts +6 -1
  19. package/src/generated/prisma/models/Tag.ts +6 -1
  20. package/src/generated/prisma/models/User.ts +6 -1
  21. package/src/generated/prisma-adversarial/browser.ts +44 -0
  22. package/src/generated/prisma-adversarial/client.ts +66 -0
  23. package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
  24. package/src/generated/prisma-adversarial/enums.ts +15 -0
  25. package/src/generated/prisma-adversarial/internal/class.ts +244 -0
  26. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
  27. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
  28. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  29. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  30. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
  31. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  32. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  33. package/src/generated/prisma-adversarial/models.ts +16 -0
  34. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  35. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  36. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  37. package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
  38. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  39. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  40. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  41. package/src/generated/prisma-v6/index.d.ts +90 -3
  42. package/src/index.test.ts +3611 -241
  43. package/src/index.ts +2544 -174
  44. package/src/test-setup.adversarial.v6.ts +5 -0
  45. package/src/test-setup.adversarial.v7.ts +12 -0
package/src/index.ts CHANGED
@@ -1,20 +1,138 @@
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
 
8
8
  export { PlanKind };
9
9
 
10
+ const CERBOS_TO_PRISMA_OPERATOR: Record<string, string> = {
11
+ eq: "equals",
12
+ ne: "not",
13
+ lt: "lt",
14
+ le: "lte",
15
+ gt: "gt",
16
+ ge: "gte",
17
+ };
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
+
10
108
  // Type Definitions
11
109
  export type PrismaFilter = Record<string, any>;
12
110
 
13
111
  export type MapperConfig = {
14
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;
15
127
  relation?: {
16
128
  name: string;
17
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;
18
136
  field?: string;
19
137
  fields?: Record<string, MapperConfig>;
20
138
  };
@@ -27,6 +145,12 @@ export type Mapper =
27
145
  export interface QueryPlanToPrismaArgs {
28
146
  queryPlan: PlanResourcesResponse;
29
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;
30
154
  }
31
155
 
32
156
  export type QueryPlanToPrismaResult =
@@ -82,6 +206,203 @@ function assertDefined<T>(value: T | undefined, message: string): T {
82
206
  return value;
83
207
  }
84
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
+
85
406
  function getLeafField(path: string[]): string {
86
407
  const fieldName = path[path.length - 1];
87
408
  if (!fieldName) {
@@ -102,6 +423,7 @@ function getFilterEntry(filter: Record<string, unknown>): [string, unknown] {
102
423
  type RelationConfig = {
103
424
  name: string;
104
425
  type: "one" | "many";
426
+ model?: string;
105
427
  field?: string;
106
428
  nestedMapper?: Record<string, MapperConfig>;
107
429
  };
@@ -109,6 +431,7 @@ type RelationConfig = {
109
431
  type ResolvedFieldReference = {
110
432
  path: string[];
111
433
  relations?: RelationConfig[];
434
+ valueType?: "dateTime";
112
435
  };
113
436
 
114
437
  type ResolvedValue = {
@@ -127,31 +450,529 @@ function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue {
127
450
  return "value" in operand;
128
451
  }
129
452
 
453
+ function getNamedOperand(
454
+ operands: PlanExpressionOperand[],
455
+ message: string
456
+ ): NamedOperand {
457
+ const operand = operands.find(isNamedOperand);
458
+ if (!operand) {
459
+ throw new Error(message);
460
+ }
461
+ return operand;
462
+ }
463
+
464
+ function getValueOperand(
465
+ operands: PlanExpressionOperand[],
466
+ message: string
467
+ ): ValueOperand {
468
+ const operand = operands.find(isValueOperand);
469
+ if (!operand) {
470
+ throw new Error(message);
471
+ }
472
+ return operand;
473
+ }
474
+
475
+ function requireResolvedFieldReference(
476
+ operand: ResolvedOperand,
477
+ message: string
478
+ ): ResolvedFieldReference {
479
+ if (!isResolvedFieldReference(operand)) {
480
+ throw new Error(message);
481
+ }
482
+ return operand;
483
+ }
484
+
485
+ function requireResolvedValue(
486
+ operand: ResolvedOperand,
487
+ message: string
488
+ ): ResolvedValue {
489
+ if (!isResolvedValue(operand)) {
490
+ throw new Error(message);
491
+ }
492
+ return operand;
493
+ }
494
+
495
+ function wrapRelations(
496
+ relations: RelationConfig[] | undefined,
497
+ filter: PrismaFilter
498
+ ): PrismaFilter {
499
+ if (!relations || relations.length === 0) {
500
+ return filter;
501
+ }
502
+ return buildNestedRelationFilter(relations, filter);
503
+ }
504
+
505
+ function buildFieldEqualsFilter(
506
+ fieldRef: ResolvedFieldReference,
507
+ value: Value
508
+ ): PrismaFilter {
509
+ const fieldName = getLeafField(fieldRef.path);
510
+ return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
511
+ }
512
+
513
+ function buildFieldDirectOrInFilter(
514
+ fieldRef: ResolvedFieldReference,
515
+ values: Value[]
516
+ ): PrismaFilter {
517
+ const fieldName = getLeafField(fieldRef.path);
518
+ const baseFilter =
519
+ values.length === 1
520
+ ? { [fieldName]: values[0] }
521
+ : { [fieldName]: { in: values } };
522
+ return wrapRelations(fieldRef.relations, baseFilter);
523
+ }
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
+
130
567
  /**
131
568
  * Converts a Cerbos query plan to a Prisma filter.
132
569
  */
133
570
  export function queryPlanToPrisma({
134
571
  queryPlan,
135
572
  mapper = {},
573
+ model,
136
574
  }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
137
575
  switch (queryPlan.kind) {
138
576
  case PlanKind.ALWAYS_ALLOWED:
139
577
  return { kind: PlanKind.ALWAYS_ALLOWED };
140
578
  case PlanKind.ALWAYS_DENIED:
141
579
  return { kind: PlanKind.ALWAYS_DENIED };
142
- 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
+ }
143
598
  return {
144
599
  kind: PlanKind.CONDITIONAL,
145
- filters: buildPrismaFilterFromCerbosExpression(
146
- queryPlan.condition,
147
- mapper
148
- ),
600
+ filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
149
601
  };
602
+ }
150
603
  default:
151
604
  throw Error(`Invalid query plan.`);
152
605
  }
153
606
  }
154
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
+
155
976
  /**
156
977
  * Resolves a field reference considering relations and nested fields.
157
978
  */
@@ -196,6 +1017,7 @@ function resolveFieldReference(
196
1017
  {
197
1018
  name,
198
1019
  type,
1020
+ model: activeConfig.relation.model,
199
1021
  field: activeConfig.relation.field,
200
1022
  nestedMapper: fields,
201
1023
  },
@@ -221,6 +1043,7 @@ function resolveFieldReference(
221
1043
  relations.push({
222
1044
  name: nextConfig.relation.name,
223
1045
  type: nextConfig.relation.type,
1046
+ model: nextConfig.relation.model,
224
1047
  field: nextConfig.relation.field,
225
1048
  nestedMapper: nextConfig.relation.fields,
226
1049
  });
@@ -237,11 +1060,18 @@ function resolveFieldReference(
237
1060
  }
238
1061
  }
239
1062
 
240
- return { path: field ? [field] : remainingParts, relations };
1063
+ return {
1064
+ path: field ? [field] : remainingParts,
1065
+ relations,
1066
+ valueType: activeConfig.valueType,
1067
+ };
241
1068
  }
242
1069
 
243
1070
  // Simple field mapping
244
- return { path: [activeConfig?.field || reference] };
1071
+ return {
1072
+ path: [activeConfig?.field || reference],
1073
+ valueType: activeConfig?.valueType,
1074
+ };
245
1075
  }
246
1076
 
247
1077
  /**
@@ -302,12 +1132,81 @@ function resolveOperand(
302
1132
  } else if (isValueOperand(operand)) {
303
1133
  return { value: operand.value };
304
1134
  } else if (isOperatorOperand(operand)) {
1135
+ if (operand.operator === "timestamp") {
1136
+ return resolveTimestampOperand(operand, mapper);
1137
+ }
1138
+ const folded = tryFoldValueExpression(operand, mapper);
1139
+ if (folded !== null) return { value: folded };
305
1140
  const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
306
1141
  return { value: nestedResult };
307
1142
  }
308
1143
  throw new Error("Operand must have name, value, or be an expression");
309
1144
  }
310
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
+
1174
+ function tryFoldValueExpression(
1175
+ expr: OperatorOperand,
1176
+ mapper: Mapper
1177
+ ): Value | null {
1178
+ if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
1179
+ const leftOp = expr.operands[0];
1180
+ const rightOp = expr.operands[1];
1181
+ if (!leftOp || !rightOp) return null;
1182
+
1183
+ const left = resolveOperand(leftOp, mapper);
1184
+ if (!isResolvedValue(left)) return null;
1185
+ const right = resolveOperand(rightOp, mapper);
1186
+ if (!isResolvedValue(right)) return null;
1187
+
1188
+ try {
1189
+ return foldArithmetic(expr.operator, left.value, right.value);
1190
+ } catch {
1191
+ return null;
1192
+ }
1193
+ }
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
+
311
1210
  /**
312
1211
  * Creates a scoped mapper for collection operations
313
1212
  */
@@ -359,7 +1258,9 @@ function createScopedMapper(
359
1258
  }
360
1259
 
361
1260
  // Return the field config if it exists, otherwise create a default one
362
- return currentConfig[field] || { field };
1261
+ const fieldConfig = currentConfig[field] || { field };
1262
+ recordNullableElementField(fieldConfig, field);
1263
+ return fieldConfig;
363
1264
  }
364
1265
  return { field: strippedKey };
365
1266
  }
@@ -381,13 +1282,8 @@ function buildPrismaFilterFromCerbosExpression(
381
1282
  ): PrismaFilter {
382
1283
  // A bare named operand represents a boolean field reference (e.g. `R.attr.booleanAttr`)
383
1284
  if (isNamedOperand(expression)) {
384
- const { path, relations } = resolveFieldReference(expression.name, mapper);
385
- const fieldName = getLeafField(path);
386
- const fieldFilter = { [fieldName]: { equals: true } };
387
- if (relations && relations.length > 0) {
388
- return buildNestedRelationFilter(relations, fieldFilter);
389
- }
390
- return fieldFilter;
1285
+ const fieldRef = resolveFieldReference(expression.name, mapper);
1286
+ return buildFieldEqualsFilter(fieldRef, true);
391
1287
  }
392
1288
 
393
1289
  if (!isOperatorOperand(expression)) {
@@ -417,22 +1313,14 @@ function buildPrismaFilterFromCerbosExpression(
417
1313
  if (!operand) {
418
1314
  throw new Error("not operator requires an operand");
419
1315
  }
420
- if (isNamedOperand(operand)) {
421
- const { path, relations } = resolveFieldReference(
422
- operand.name,
423
- mapper
424
- );
425
- if (!relations || relations.length === 0) {
426
- const fieldName = getLeafField(path);
427
- return { [fieldName]: { equals: false } };
428
- }
429
- }
430
- return {
431
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
432
- };
1316
+ return buildNegatedFilter(operand, mapper);
433
1317
  }
434
1318
 
435
- case "eq":
1319
+ case "if": {
1320
+ return handleBooleanTernaryOperator(operands, mapper);
1321
+ }
1322
+
1323
+ case "eq":
436
1324
  case "ne":
437
1325
  case "lt":
438
1326
  case "le":
@@ -475,11 +1363,484 @@ function buildPrismaFilterFromCerbosExpression(
475
1363
  return handleMapOperator(operands, mapper);
476
1364
  }
477
1365
 
1366
+ case "overlaps": {
1367
+ return handleOverlapsOperator(operands, mapper);
1368
+ }
1369
+
1370
+ case "ancestorOf": {
1371
+ return handleAncestorDescendantOperator(operands, mapper, "ancestor");
1372
+ }
1373
+
1374
+ case "descendentOf": {
1375
+ return handleAncestorDescendantOperator(operands, mapper, "descendant");
1376
+ }
1377
+
478
1378
  default:
479
1379
  throw new Error(`Unsupported operator: ${operator}`);
480
1380
  }
481
1381
  }
482
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
+
483
1844
  function handleSizeComparison(
484
1845
  operator: string,
485
1846
  sizeOperand: OperatorOperand,
@@ -542,14 +1903,17 @@ function handleRelationalOperator(
542
1903
  operands: PlanExpressionOperand[],
543
1904
  mapper: Mapper
544
1905
  ): PrismaFilter {
545
- const prismaOperator = {
546
- eq: "equals",
547
- ne: "not",
548
- lt: "lt",
549
- le: "lte",
550
- gt: "gt",
551
- ge: "gte",
552
- }[operator];
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));
1916
+ const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
553
1917
 
554
1918
  if (!prismaOperator) {
555
1919
  throw new Error(`Unsupported operator: ${operator}`);
@@ -567,152 +1931,379 @@ function handleRelationalOperator(
567
1931
  return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
568
1932
  }
569
1933
 
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
+
570
1961
  const left = resolveOperand(leftOperand, mapper);
571
1962
  const right = resolveOperand(rightOperand, mapper);
572
1963
 
573
- if (isResolvedFieldReference(left)) {
574
- const { path, relations } = left;
575
-
576
- if (!isResolvedValue(right)) {
577
- throw new Error("Right operand must be a value");
578
- }
1964
+ if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
1965
+ return buildFieldToFieldFilter(
1966
+ operator,
1967
+ leftOperand,
1968
+ left,
1969
+ rightOperand,
1970
+ right
1971
+ );
1972
+ }
579
1973
 
580
- const filterValue = { [prismaOperator]: right.value };
581
- const fieldName = getLeafField(path);
582
- const fieldFilter = { [fieldName]: filterValue };
1974
+ if (isResolvedValue(left) && isResolvedFieldReference(right)) {
1975
+ return buildComparisonFilter(
1976
+ right,
1977
+ MIRRORED_OPERATOR[operator] ?? operator,
1978
+ left.value
1979
+ );
1980
+ }
583
1981
 
584
- if (relations && relations.length > 0) {
585
- return buildNestedRelationFilter(relations, fieldFilter);
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();
586
1990
  }
587
-
588
- return fieldFilter;
1991
+ throw new Error(
1992
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
1993
+ );
589
1994
  }
590
1995
 
591
- if (!isResolvedValue(right)) {
592
- throw new Error("Right operand must be a value");
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);
593
2003
  }
594
2004
 
595
- return { [prismaOperator]: right.value };
2005
+ return { [prismaOperator]: rightValue.value };
596
2006
  }
597
2007
 
598
2008
  /**
599
- * Helper function to handle "in" operator
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).
600
2015
  */
601
- function handleInOperator(
602
- operands: PlanExpressionOperand[],
603
- mapper: Mapper
2016
+ function buildComparisonFilter(
2017
+ fieldRef: ResolvedFieldReference,
2018
+ operator: string,
2019
+ value: Value
604
2020
  ): PrismaFilter {
605
- const nameOperand = operands.find(isNamedOperand);
606
- if (!nameOperand) throw new Error("Name operand is undefined");
607
-
608
- const valueOperand = operands.find(isValueOperand);
609
- if (!valueOperand) throw new Error("Value operand is undefined");
2021
+ const prismaOperator = assertDefined(
2022
+ CERBOS_TO_PRISMA_OPERATOR[operator],
2023
+ `Unsupported operator: ${operator}`
2024
+ );
610
2025
 
611
- const resolved = resolveOperand(nameOperand, mapper);
612
- if (!isResolvedFieldReference(resolved)) {
613
- throw new Error("Name operand must resolve to a field reference");
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
+ }
614
2069
  }
615
2070
 
616
- const { path, relations } = resolved;
617
- const resolvedValue = resolveOperand(valueOperand, mapper);
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}`
2090
+ );
618
2091
 
619
- if (!isResolvedValue(resolvedValue)) {
620
- throw new Error("Value operand must resolve to a value");
2092
+ if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
2093
+ throw new Error("Field-to-field comparison requires two named operands");
621
2094
  }
622
2095
 
623
- const { value } = resolvedValue;
624
- const values = Array.isArray(value) ? value : [value];
625
- const fieldName = getLeafField(path);
2096
+ const scope = lambdaScopes[lambdaScopes.length - 1];
2097
+ let container: string | undefined;
626
2098
 
627
- if (relations && relations.length > 0) {
628
- const fieldFilter =
629
- values.length === 1
630
- ? { [fieldName]: values[0] }
631
- : { [fieldName]: { in: values } };
632
- return buildNestedRelationFilter(relations, fieldFilter);
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
+ }
633
2134
  }
634
2135
 
635
- return values.length === 1
636
- ? { [fieldName]: values[0] }
637
- : { [fieldName]: { in: values } };
2136
+ const leftField = getLeafField(left.path);
2137
+ const rightField = getLeafField(right.path);
2138
+ return {
2139
+ [leftField]: {
2140
+ [prismaOperator]: { _ref: rightField, _container: container },
2141
+ },
2142
+ };
638
2143
  }
639
2144
 
640
2145
  /**
641
- * Helper function to handle string operators (contains, startsWith, endsWith)
2146
+ * Helper function to handle "in" operator
642
2147
  */
643
- function handleStringOperator(
644
- operator: string,
2148
+ function handleInOperator(
645
2149
  operands: PlanExpressionOperand[],
646
2150
  mapper: Mapper
647
2151
  ): PrismaFilter {
648
- const nameOperand = operands.find(isNamedOperand);
649
- if (!nameOperand) throw new Error("Name operand is undefined");
650
-
651
- const resolved = resolveOperand(nameOperand, mapper);
652
- if (!isResolvedFieldReference(resolved)) {
653
- throw new Error("Name operand must resolve to a field reference");
2152
+ if (operands.length !== 2) {
2153
+ throw new Error("in requires exactly two operands");
654
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"
2159
+ );
655
2160
 
656
- const { path, relations } = resolved;
657
-
658
- const valueOperand = operands.find(isValueOperand);
659
- if (!valueOperand) throw new Error("Value operand is undefined");
660
-
661
- const resolvedValue = resolveOperand(valueOperand, mapper);
662
- if (!isResolvedValue(resolvedValue)) {
663
- throw new Error("Value operand must resolve to a value");
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);
664
2167
  }
665
2168
 
666
- const { value } = resolvedValue;
667
- if (typeof value !== "string") {
668
- throw new Error(`${operator} operator requires string value`);
2169
+ if (isValueOperand(member) && isNamedOperand(collection)) {
2170
+ return buildMembershipFilter(
2171
+ resolveFieldReference(collection.name, mapper),
2172
+ [member.value]
2173
+ );
669
2174
  }
670
2175
 
671
- const fieldName = getLeafField(path);
672
- const fieldFilter = { [fieldName]: { [operator]: value } };
673
-
674
- if (relations && relations.length > 0) {
675
- return buildNestedRelationFilter(relations, fieldFilter);
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
+ );
676
2180
  }
677
2181
 
678
- return fieldFilter;
2182
+ throw new Error(
2183
+ "in requires a field member and literal list, or a literal member and field collection"
2184
+ );
679
2185
  }
680
2186
 
2187
+ // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
2188
+ const MAX_ENUMERATED_NEEDLES = 1000;
2189
+
681
2190
  /**
682
- * Helper function to handle "isSet" operator
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.
683
2196
  */
684
- function handleIsSetOperator(
2197
+ function handleStringOperator(
2198
+ operator: string,
685
2199
  operands: PlanExpressionOperand[],
686
2200
  mapper: Mapper
687
2201
  ): PrismaFilter {
688
- const nameOperand = operands.find(isNamedOperand);
689
- if (!nameOperand) throw new Error("Name operand is undefined");
690
-
691
- const resolved = resolveOperand(nameOperand, mapper);
692
- if (!isResolvedFieldReference(resolved)) {
693
- throw new Error("Name operand must resolve to a field reference");
2202
+ if (operands.length !== 2) {
2203
+ throw new Error(`${operator} requires exactly two operands`);
694
2204
  }
2205
+ const receiver = resolveOperand(
2206
+ assertDefined(operands[0], `${operator} requires a receiver operand`),
2207
+ mapper
2208
+ );
2209
+ const needle = resolveOperand(
2210
+ assertDefined(operands[1], `${operator} requires a needle operand`),
2211
+ mapper
2212
+ );
695
2213
 
696
- const { path, relations } = resolved;
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
+ });
2233
+ }
697
2234
 
698
- const valueOperand = operands.find(isValueOperand);
699
- if (!valueOperand) throw new Error("Value operand is undefined");
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
+ }
700
2271
 
701
- const resolvedValue = resolveOperand(valueOperand, mapper);
702
- if (!isResolvedValue(resolvedValue)) {
703
- throw new Error("Value operand must resolve to a value");
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
+ );
704
2278
  }
705
2279
 
706
- const fieldName = getLeafField(path);
707
- const fieldFilter = {
708
- [fieldName]: resolvedValue.value ? { not: null } : { equals: null },
709
- };
2280
+ throw new Error(
2281
+ `${operator} between two constants must be folded by the Cerbos planner`
2282
+ );
2283
+ }
710
2284
 
711
- if (relations && relations.length > 0) {
712
- return buildNestedRelationFilter(relations, fieldFilter);
713
- }
2285
+ /**
2286
+ * Helper function to handle "isSet" operator
2287
+ */
2288
+ function handleIsSetOperator(
2289
+ operands: PlanExpressionOperand[],
2290
+ mapper: Mapper
2291
+ ): PrismaFilter {
2292
+ const nameOperand = getNamedOperand(operands, "Name operand is undefined");
2293
+ const valueOperand = getValueOperand(operands, "Value operand is undefined");
2294
+ const fieldRef = requireResolvedFieldReference(
2295
+ resolveOperand(nameOperand, mapper),
2296
+ "Name operand must resolve to a field reference"
2297
+ );
2298
+ const resolvedValue = requireResolvedValue(
2299
+ resolveOperand(valueOperand, mapper),
2300
+ "Value operand must resolve to a value"
2301
+ );
714
2302
 
715
- return fieldFilter;
2303
+ const fieldName = getLeafField(fieldRef.path);
2304
+ return wrapRelations(fieldRef.relations, {
2305
+ [fieldName]: resolvedValue.value ? { not: null } : { equals: null },
2306
+ });
716
2307
  }
717
2308
 
718
2309
  /**
@@ -726,6 +2317,11 @@ function handleHasIntersectionOperator(
726
2317
  throw new Error("hasIntersection requires exactly two operands");
727
2318
  }
728
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
+
729
2325
  const leftOperand = assertDefined(
730
2326
  operands[0],
731
2327
  "hasIntersection requires a left operand"
@@ -787,13 +2383,42 @@ function handleHasIntersectionOperator(
787
2383
  throw new Error("Invalid map lambda expression structure");
788
2384
  }
789
2385
 
790
- // Use scoped mapper for resolving the projection
791
- 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
+ }
792
2400
  const fieldName = getLeafField(resolved.path);
793
2401
 
794
- return buildNestedRelationFilter(relations, {
2402
+ const base = buildNestedRelationFilter(relations, {
795
2403
  [fieldName]: { in: rightOperand.value },
796
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
+ };
797
2422
  }
798
2423
 
799
2424
  // Handle regular field reference
@@ -814,11 +2439,7 @@ function handleHasIntersectionOperator(
814
2439
  }
815
2440
 
816
2441
  if (relations && relations.length > 0) {
817
- const fieldName = getLeafField(path);
818
- const fieldFilter = {
819
- [fieldName]: { in: rightOperand.value },
820
- };
821
- return buildNestedRelationFilter(relations, fieldFilter);
2442
+ return buildMembershipFilter({ path, relations }, rightOperand.value);
822
2443
  }
823
2444
 
824
2445
  const fieldName = getLeafField(path);
@@ -847,14 +2468,22 @@ function handleLambdaOperator(operands: PlanExpressionOperand[]): PrismaFilter {
847
2468
  }));
848
2469
  }
849
2470
 
850
- /**
851
- * Helper function to handle collection operators (exists, all, except, filter)
852
- */
853
- 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(
854
2483
  operator: string,
855
2484
  operands: PlanExpressionOperand[],
856
2485
  mapper: Mapper
857
- ): PrismaFilter {
2486
+ ): CollectionLambdaParts {
858
2487
  if (operands.length !== 2) {
859
2488
  throw new Error(`${operator} requires exactly two operands`);
860
2489
  }
@@ -901,23 +2530,46 @@ function handleCollectionOperator(
901
2530
  throw new Error(`${operator} operator requires a relation mapping`);
902
2531
  }
903
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
+
904
2543
  const lambdaConditionOperand = assertDefined(
905
2544
  lambda.operands[0],
906
2545
  "Lambda expression must provide a condition"
907
2546
  );
908
- const lambdaCondition = buildPrismaFilterFromCerbosExpression(
909
- lambdaConditionOperand, // Use the condition part of the lambda
910
- scopedMapper
911
- );
912
2547
 
913
- const relation = assertDefined(
914
- relations[0],
915
- `${operator} operator requires a relation mapping`
916
- );
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
+
917
2565
  let filterValue = lambdaCondition;
918
2566
 
919
- // If the lambda condition already has a relation structure, merge it
920
- 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
921
2573
  filterValue = lambdaCondition;
922
2574
  } else {
923
2575
  const lambdaKeys = Object.keys(lambdaCondition);
@@ -929,40 +2581,209 @@ function handleCollectionOperator(
929
2581
  if (lambdaFieldValue === undefined) {
930
2582
  throw new Error("Lambda condition field value cannot be undefined");
931
2583
  }
932
- const filterField = relation.field || defaultKey;
2584
+ const filterField = head.field || defaultKey;
933
2585
  filterValue = {
934
2586
  [filterField]: lambdaFieldValue,
935
2587
  };
936
2588
  }
937
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
+
938
2679
  switch (operator) {
939
- case "exists":
940
- return { [relation.name]: { some: filterValue } };
941
- case "filter":
942
- throw new Error(
943
- "The filter() collection operator returns a list, not a boolean. " +
944
- "It cannot be used as a standalone condition. " +
945
- "Use exists() or combine filter() with size() instead."
946
- );
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
+ }
947
2695
  case "except":
948
- return { [relation.name]: { some: { NOT: filterValue } } };
949
- 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.)
950
2711
  return {
951
- [relation.name]: {
952
- some: filterValue,
953
- },
954
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).
2758
+ return {
2759
+ AND: [
2760
+ base,
955
2761
  {
956
- [relation.name]: {
957
- every: {
958
- OR: [filterValue, { NOT: filterValue }],
959
- },
960
- },
2762
+ NOT: wrapCollectionElementFilter(
2763
+ parts,
2764
+ unknownElement
2765
+ ),
961
2766
  },
962
2767
  ],
963
2768
  };
2769
+ }
964
2770
  case "all":
965
- 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
+ }
966
2787
  default:
967
2788
  throw new Error(`Unexpected operator: ${operator}`);
968
2789
  }
@@ -1035,3 +2856,552 @@ function handleMapOperator(
1035
2856
  },
1036
2857
  });
1037
2858
  }
2859
+
2860
+ function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
2861
+ return buildFieldFilter(fieldRef, "in", []);
2862
+ }
2863
+
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(
2876
+ operator: string,
2877
+ arithExpr: OperatorOperand,
2878
+ otherOperand: PlanExpressionOperand,
2879
+ arithIsLeft: boolean,
2880
+ mapper: Mapper
2881
+ ): PrismaFilter {
2882
+ const arithOp = arithExpr.operator;
2883
+ const arithLeftOp = assertDefined(
2884
+ arithExpr.operands[0],
2885
+ `${arithOp} operator requires a left operand`
2886
+ );
2887
+ const arithRightOp = assertDefined(
2888
+ arithExpr.operands[1],
2889
+ `${arithOp} operator requires a right operand`
2890
+ );
2891
+
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
+ }
2901
+
2902
+ const arithLeft = resolveOperand(arithLeftOp, mapper);
2903
+ const arithRight = resolveOperand(arithRightOp, mapper);
2904
+ const other = resolveOperand(otherOperand, mapper);
2905
+
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
+ );
2915
+ }
2916
+ const effectiveOperator = arithIsLeft
2917
+ ? (MIRRORED_OPERATOR[operator] ?? operator)
2918
+ : operator;
2919
+ return buildComparisonFilter(other, effectiveOperator, folded);
2920
+ }
2921
+
2922
+ if (!isResolvedValue(other)) {
2923
+ throw new Error(
2924
+ `${arithOp} operator with field references requires a value on the other side of the comparison`
2925
+ );
2926
+ }
2927
+
2928
+ let fieldRef: ResolvedFieldReference;
2929
+ let constant: Value;
2930
+ let fieldIsLeft: boolean;
2931
+
2932
+ if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
2933
+ fieldRef = arithLeft;
2934
+ constant = arithRight.value;
2935
+ fieldIsLeft = true;
2936
+ } else if (
2937
+ isResolvedValue(arithLeft) &&
2938
+ isResolvedFieldReference(arithRight)
2939
+ ) {
2940
+ fieldRef = arithRight;
2941
+ constant = arithLeft.value;
2942
+ fieldIsLeft = false;
2943
+ } else {
2944
+ throw new Error(
2945
+ `${arithOp} operator requires exactly one field reference and one value, or two values`
2946
+ );
2947
+ }
2948
+
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
+ ) {
2979
+ throw new Error(
2980
+ "Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
2981
+ );
2982
+ }
2983
+
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}`);
3029
+ }
3030
+
3031
+ return buildComparisonFilter(fieldRef, effectiveOperator, solved);
3032
+ }
3033
+
3034
+ function foldArithmetic(operator: string, left: Value, right: Value): Value {
3035
+ if (
3036
+ operator === "add" &&
3037
+ (typeof left === "string" || typeof right === "string")
3038
+ ) {
3039
+ return String(left) + String(right);
3040
+ }
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}`);
3055
+ }
3056
+ }
3057
+
3058
+ function solveAdd(
3059
+ comparisonValue: Value,
3060
+ addConstant: Value,
3061
+ fieldIsLeft: boolean
3062
+ ): Value | null {
3063
+ if (typeof comparisonValue === "string" && typeof addConstant === "string") {
3064
+ if (fieldIsLeft) {
3065
+ if (!comparisonValue.endsWith(addConstant)) return null;
3066
+ return comparisonValue.slice(
3067
+ 0,
3068
+ comparisonValue.length - addConstant.length
3069
+ );
3070
+ }
3071
+ if (!comparisonValue.startsWith(addConstant)) return null;
3072
+ return comparisonValue.slice(addConstant.length);
3073
+ }
3074
+ if (typeof comparisonValue === "number" && typeof addConstant === "number") {
3075
+ return comparisonValue - addConstant;
3076
+ }
3077
+ throw new Error("Type mismatch in add comparison");
3078
+ }
3079
+
3080
+ type ConstantSegment = { type: "constant"; value: string };
3081
+ type FieldSegment = { type: "field"; fieldRef: ResolvedFieldReference };
3082
+ type HierarchySegment = ConstantSegment | FieldSegment;
3083
+
3084
+ type ConstantHierarchy = {
3085
+ type: "constant";
3086
+ segments: string[];
3087
+ raw: string;
3088
+ delimiter: string;
3089
+ };
3090
+
3091
+ type FieldHierarchy = {
3092
+ type: "field";
3093
+ fieldRef: ResolvedFieldReference;
3094
+ delimiter: string;
3095
+ };
3096
+
3097
+ type SegmentedHierarchy = {
3098
+ type: "segmented";
3099
+ segments: HierarchySegment[];
3100
+ };
3101
+
3102
+ type ResolvedHierarchy = ConstantHierarchy | FieldHierarchy | SegmentedHierarchy;
3103
+
3104
+ function resolveHierarchy(
3105
+ expr: OperatorOperand,
3106
+ mapper: Mapper
3107
+ ): ResolvedHierarchy {
3108
+ const operands = expr.operands;
3109
+
3110
+ if (operands.length === 2) {
3111
+ const strOperand = assertDefined(operands[0], "hierarchy requires operands");
3112
+ const delimOperand = assertDefined(
3113
+ operands[1],
3114
+ "hierarchy requires a delimiter"
3115
+ );
3116
+ if (!isValueOperand(delimOperand)) {
3117
+ throw new Error("hierarchy delimiter must be a value");
3118
+ }
3119
+ const delimiter = String(delimOperand.value);
3120
+
3121
+ if (isValueOperand(strOperand)) {
3122
+ const raw = String(strOperand.value);
3123
+ return { type: "constant", segments: raw.split(delimiter), raw, delimiter };
3124
+ }
3125
+ if (isNamedOperand(strOperand)) {
3126
+ return {
3127
+ type: "field",
3128
+ fieldRef: resolveFieldReference(strOperand.name, mapper),
3129
+ delimiter,
3130
+ };
3131
+ }
3132
+ throw new Error("hierarchy(string, delimiter) requires a value or field operand");
3133
+ }
3134
+
3135
+ if (operands.length === 1) {
3136
+ const inner = assertDefined(operands[0], "hierarchy requires an operand");
3137
+
3138
+ if (isValueOperand(inner)) {
3139
+ const raw = String(inner.value);
3140
+ return { type: "constant", segments: raw.split("."), raw, delimiter: "." };
3141
+ }
3142
+
3143
+ if (isNamedOperand(inner)) {
3144
+ return {
3145
+ type: "field",
3146
+ fieldRef: resolveFieldReference(inner.name, mapper),
3147
+ delimiter: ".",
3148
+ };
3149
+ }
3150
+
3151
+ if (isOperatorOperand(inner) && inner.operator === "list") {
3152
+ const segments = inner.operands.map((op): HierarchySegment => {
3153
+ const resolved = resolveOperand(op, mapper);
3154
+ if (isResolvedValue(resolved)) {
3155
+ return { type: "constant", value: String(resolved.value) };
3156
+ }
3157
+ return { type: "field", fieldRef: resolved };
3158
+ });
3159
+ return { type: "segmented", segments };
3160
+ }
3161
+
3162
+ throw new Error("hierarchy requires a value, field, or list operand");
3163
+ }
3164
+
3165
+ throw new Error("hierarchy requires 1 or 2 operands");
3166
+ }
3167
+
3168
+ function toSegments(resolved: ResolvedHierarchy): HierarchySegment[] {
3169
+ switch (resolved.type) {
3170
+ case "constant":
3171
+ return resolved.segments.map((s) => ({ type: "constant" as const, value: s }));
3172
+ case "segmented":
3173
+ return resolved.segments;
3174
+ case "field":
3175
+ throw new Error(
3176
+ "Cannot get segments from a field-reference hierarchy"
3177
+ );
3178
+ }
3179
+ }
3180
+
3181
+ function normalizeHierarchy(
3182
+ h: ResolvedHierarchy,
3183
+ defaultDelimiter = "."
3184
+ ): ResolvedHierarchy {
3185
+ if (h.type !== "segmented") return h;
3186
+ const allConstant = h.segments.every(
3187
+ (s): s is ConstantSegment => s.type === "constant"
3188
+ );
3189
+ if (!allConstant) return h;
3190
+ const segments = (h.segments as ConstantSegment[]).map((s) => s.value);
3191
+ return {
3192
+ type: "constant",
3193
+ segments,
3194
+ raw: segments.join(defaultDelimiter),
3195
+ delimiter: defaultDelimiter,
3196
+ };
3197
+ }
3198
+
3199
+ function checkPrefixConditions(
3200
+ shorter: HierarchySegment[],
3201
+ longer: HierarchySegment[]
3202
+ ): PrismaFilter | null {
3203
+ if (shorter.length > longer.length) return null;
3204
+
3205
+ const conditions: PrismaFilter[] = [];
3206
+
3207
+ for (let i = 0; i < shorter.length; i++) {
3208
+ const s = shorter[i]!;
3209
+ const l = longer[i]!;
3210
+
3211
+ if (s.type === "constant" && l.type === "constant") {
3212
+ if (s.value !== l.value) return null;
3213
+ } else if (s.type === "field" && l.type === "constant") {
3214
+ conditions.push(buildFieldFilter(s.fieldRef, "equals", l.value));
3215
+ } else if (s.type === "constant" && l.type === "field") {
3216
+ conditions.push(buildFieldFilter(l.fieldRef, "equals", s.value));
3217
+ } else {
3218
+ throw new Error(
3219
+ "Cannot compare two field references in hierarchy overlap"
3220
+ );
3221
+ }
3222
+ }
3223
+
3224
+ if (conditions.length === 0) return {};
3225
+ if (conditions.length === 1) return conditions[0]!;
3226
+ return { AND: conditions };
3227
+ }
3228
+
3229
+ function handleOverlapsOperator(
3230
+ operands: PlanExpressionOperand[],
3231
+ mapper: Mapper
3232
+ ): PrismaFilter {
3233
+ const [left, right] = extractHierarchyOperands("overlaps", operands, mapper);
3234
+
3235
+ if (left.type === "field" || right.type === "field") {
3236
+ return handleFieldOverlaps(left, right);
3237
+ }
3238
+
3239
+ const leftSegs = toSegments(left);
3240
+ const rightSegs = toSegments(right);
3241
+
3242
+ const leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs);
3243
+ const rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs);
3244
+
3245
+ const validConditions = [leftPrefixOfRight, rightPrefixOfLeft].filter(
3246
+ (c): c is PrismaFilter => c !== null
3247
+ );
3248
+
3249
+ if (validConditions.length === 0) {
3250
+ const allSegs = [...leftSegs, ...rightSegs];
3251
+ const fieldSeg = allSegs.find(
3252
+ (s): s is FieldSegment => s.type === "field"
3253
+ );
3254
+ if (fieldSeg) return buildImpossibleFilter(fieldSeg.fieldRef);
3255
+ throw new Error("Cannot determine overlap: no field references found");
3256
+ }
3257
+
3258
+ if (validConditions.some((c) => Object.keys(c).length === 0)) return {};
3259
+
3260
+ // When both directions are valid (equal-length hierarchies), they produce
3261
+ // identical conditions since the same segment pairs are compared in both.
3262
+ return validConditions[0]!;
3263
+ }
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
+
3283
+ function handleFieldOverlaps(
3284
+ left: ResolvedHierarchy,
3285
+ right: ResolvedHierarchy
3286
+ ): PrismaFilter {
3287
+ if (left.type === "field" && right.type === "field") {
3288
+ throw new Error("overlaps: cannot compare two field-reference hierarchies");
3289
+ }
3290
+
3291
+ const field = (left.type === "field" ? left : right) as FieldHierarchy;
3292
+ const other = left.type === "field" ? right : left;
3293
+
3294
+ if (other.type !== "constant") {
3295
+ throw new Error("overlaps: segmented hierarchies with field hierarchies are not supported");
3296
+ }
3297
+
3298
+ const delimiter = field.delimiter;
3299
+ const otherRaw = other.segments.join(delimiter);
3300
+ const strictPrefixes = getStrictPrefixes(other.segments, delimiter);
3301
+
3302
+ const conditions: PrismaFilter[] = [];
3303
+ if (strictPrefixes.length > 0) {
3304
+ conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
3305
+ }
3306
+ conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
3307
+ const prefix = otherRaw + delimiter;
3308
+ assertLikeSafePrefix(prefix);
3309
+ conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
3310
+
3311
+ if (conditions.length === 1) return conditions[0]!;
3312
+ return { OR: conditions };
3313
+ }
3314
+
3315
+ function extractHierarchyOperands(
3316
+ operatorName: string,
3317
+ operands: PlanExpressionOperand[],
3318
+ mapper: Mapper
3319
+ ): [ResolvedHierarchy, ResolvedHierarchy] {
3320
+ if (operands.length !== 2) {
3321
+ throw new Error(`${operatorName} requires exactly two operands`);
3322
+ }
3323
+ const leftOp = assertDefined(operands[0], `${operatorName} requires a left operand`);
3324
+ const rightOp = assertDefined(operands[1], `${operatorName} requires a right operand`);
3325
+
3326
+ if (
3327
+ !isOperatorOperand(leftOp) || leftOp.operator !== "hierarchy" ||
3328
+ !isOperatorOperand(rightOp) || rightOp.operator !== "hierarchy"
3329
+ ) {
3330
+ throw new Error(`${operatorName} requires two hierarchy operands`);
3331
+ }
3332
+
3333
+ return [
3334
+ normalizeHierarchy(resolveHierarchy(leftOp, mapper)),
3335
+ normalizeHierarchy(resolveHierarchy(rightOp, mapper)),
3336
+ ];
3337
+ }
3338
+
3339
+ function getStrictPrefixes(segments: string[], delimiter: string): string[] {
3340
+ if (segments.length <= 1) return [];
3341
+ const prefixes: string[] = [];
3342
+ let current = segments[0]!;
3343
+ prefixes.push(current);
3344
+ for (let i = 1; i < segments.length - 1; i++) {
3345
+ current = current + delimiter + segments[i]!;
3346
+ prefixes.push(current);
3347
+ }
3348
+ return prefixes;
3349
+ }
3350
+
3351
+ function handleAncestorDescendantOperator(
3352
+ operands: PlanExpressionOperand[],
3353
+ mapper: Mapper,
3354
+ direction: "ancestor" | "descendant"
3355
+ ): PrismaFilter {
3356
+ const operatorName = direction === "ancestor" ? "ancestorOf" : "descendentOf";
3357
+ const [left, right] = extractHierarchyOperands(operatorName, operands, mapper);
3358
+
3359
+ // ancestorOf(A, B) = A is strict prefix of B
3360
+ // descendentOf(A, B) = B is strict prefix of A
3361
+ const ancestor = direction === "ancestor" ? left : right;
3362
+ const descendant = direction === "ancestor" ? right : left;
3363
+
3364
+ if (ancestor.type === "constant" && descendant.type === "field") {
3365
+ const prefix =
3366
+ ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
3367
+ assertLikeSafePrefix(prefix);
3368
+ return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
3369
+ }
3370
+
3371
+ if (ancestor.type === "field" && descendant.type === "constant") {
3372
+ const delimiter = ancestor.delimiter;
3373
+ const prefixes = getStrictPrefixes(descendant.segments, delimiter);
3374
+ if (prefixes.length === 0) {
3375
+ return buildImpossibleFilter(ancestor.fieldRef);
3376
+ }
3377
+ if (prefixes.length === 1) {
3378
+ return buildFieldFilter(ancestor.fieldRef, "equals", prefixes[0]!);
3379
+ }
3380
+ return buildFieldFilter(ancestor.fieldRef, "in", prefixes);
3381
+ }
3382
+
3383
+ if (ancestor.type === "constant" && descendant.type === "constant") {
3384
+ const ancestorSegs = ancestor.segments;
3385
+ const descendantSegs = descendant.segments;
3386
+ if (
3387
+ descendantSegs.length > ancestorSegs.length &&
3388
+ ancestorSegs.every((seg, i) => seg === descendantSegs[i])
3389
+ ) {
3390
+ return {};
3391
+ }
3392
+ throw new Error(`${operatorName}: constants do not satisfy ${direction} relationship`);
3393
+ }
3394
+
3395
+ throw new Error(`${operatorName}: unsupported hierarchy type combination`);
3396
+ }
3397
+
3398
+ function buildFieldFilter(
3399
+ fieldRef: ResolvedFieldReference,
3400
+ prismaOp: string,
3401
+ value: Value
3402
+ ): PrismaFilter {
3403
+ const fieldName = getLeafField(fieldRef.path);
3404
+ return wrapRelations(fieldRef.relations, {
3405
+ [fieldName]: { [prismaOp]: value },
3406
+ });
3407
+ }