@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/lib/index.js CHANGED
@@ -4,6 +4,79 @@ exports.PlanKind = void 0;
4
4
  exports.queryPlanToPrisma = queryPlanToPrisma;
5
5
  const core_1 = require("@cerbos/core");
6
6
  Object.defineProperty(exports, "PlanKind", { enumerable: true, get: function () { return core_1.PlanKind; } });
7
+ const CERBOS_TO_PRISMA_OPERATOR = {
8
+ eq: "equals",
9
+ ne: "not",
10
+ lt: "lt",
11
+ le: "lte",
12
+ gt: "gt",
13
+ ge: "gte",
14
+ };
15
+ // Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
16
+ const MIRRORED_OPERATOR = {
17
+ lt: "gt",
18
+ gt: "lt",
19
+ le: "ge",
20
+ ge: "le",
21
+ };
22
+ const RFC3339_MILLISECOND_TIMESTAMP = /^((?!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)$/;
23
+ const MIN_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse("0001-01-01T00:00:00.000Z");
24
+ const MAX_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse("9999-12-31T23:59:59.999Z");
25
+ function normalizeRfc3339Milliseconds(value) {
26
+ const match = RFC3339_MILLISECOND_TIMESTAMP.exec(value);
27
+ const yearText = match?.[1];
28
+ const monthText = match?.[2];
29
+ const dayText = match?.[3];
30
+ const fraction = match?.[4] ?? "";
31
+ if (!yearText || !monthText || !dayText) {
32
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
33
+ }
34
+ if ([...fraction.slice(3)].some((digit) => digit !== "0")) {
35
+ throw new Error(`Timestamp value exceeds millisecond precision: ${value}`);
36
+ }
37
+ const year = Number(yearText);
38
+ const month = Number(monthText);
39
+ const day = Number(dayText);
40
+ const calendarDate = new Date(0);
41
+ calendarDate.setUTCHours(0, 0, 0, 0);
42
+ calendarDate.setUTCFullYear(year, month - 1, day);
43
+ if (calendarDate.getUTCFullYear() !== year ||
44
+ calendarDate.getUTCMonth() !== month - 1 ||
45
+ calendarDate.getUTCDate() !== day) {
46
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
47
+ }
48
+ const milliseconds = Date.parse(value);
49
+ if (Number.isNaN(milliseconds)) {
50
+ throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
51
+ }
52
+ if (milliseconds < MIN_RFC3339_TIMESTAMP_MILLISECONDS ||
53
+ milliseconds > MAX_RFC3339_TIMESTAMP_MILLISECONDS) {
54
+ throw new Error(`Timestamp value is outside CEL's supported instant range: ${value}`);
55
+ }
56
+ return new Date(milliseconds).toISOString();
57
+ }
58
+ /**
59
+ * Normalize a binary comparison to field-side-first. The planner preserves policy source
60
+ * order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
61
+ * `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
62
+ * handlers can assume the field/expression side is first. Without this, value-first
63
+ * comparisons are silently inverted (#256).
64
+ */
65
+ function normalizeBinaryOperands(operator, operands) {
66
+ const first = operands[0];
67
+ const second = operands[1];
68
+ if (operands.length === 2 &&
69
+ first !== undefined &&
70
+ second !== undefined &&
71
+ isValueOperand(first) &&
72
+ !isValueOperand(second)) {
73
+ return {
74
+ operator: MIRRORED_OPERATOR[operator] ?? operator,
75
+ operands: [second, first],
76
+ };
77
+ }
78
+ return { operator, operands };
79
+ }
7
80
  function isNamedOperand(operand) {
8
81
  return "name" in operand && typeof operand.name === "string";
9
82
  }
@@ -22,6 +95,137 @@ function assertDefined(value, message) {
22
95
  }
23
96
  return value;
24
97
  }
98
+ // Operators whose second operand is a lambda that binds an iteration variable.
99
+ const LAMBDA_BINDING_OPERATORS = new Set([
100
+ "exists",
101
+ "exists_one",
102
+ "all",
103
+ "filter",
104
+ "map",
105
+ "except",
106
+ ]);
107
+ /**
108
+ * Substitute a lambda iteration variable with a concrete collection element
109
+ * inside a lambda body. A bare reference to the variable becomes the element
110
+ * itself; a `variable.path.to.field` reference drills into the element. A
111
+ * nested collection macro whose lambda rebinds the same variable name shadows
112
+ * the outer variable, so substitution only descends into its collection
113
+ * operand.
114
+ */
115
+ function substituteLambdaVariable(operand, variableName, element) {
116
+ if (isNamedOperand(operand)) {
117
+ if (operand.name === variableName) {
118
+ return { value: element };
119
+ }
120
+ if (operand.name.startsWith(`${variableName}.`)) {
121
+ let current = element;
122
+ for (const segment of operand.name
123
+ .slice(variableName.length + 1)
124
+ .split(".")) {
125
+ if (current === null ||
126
+ typeof current !== "object" ||
127
+ Array.isArray(current) ||
128
+ !(segment in current) ||
129
+ current[segment] === undefined) {
130
+ throw new Error(`Cannot resolve "${operand.name}": collection element has no field "${segment}"`);
131
+ }
132
+ current = current[segment];
133
+ }
134
+ return { value: current };
135
+ }
136
+ return operand;
137
+ }
138
+ if (isOperatorOperand(operand)) {
139
+ if (LAMBDA_BINDING_OPERATORS.has(operand.operator) &&
140
+ operand.operands.length === 2) {
141
+ const [nestedCollection, nestedLambda] = operand.operands;
142
+ if (nestedCollection !== undefined &&
143
+ nestedLambda !== undefined &&
144
+ isOperatorOperand(nestedLambda) &&
145
+ nestedLambda.operator === "lambda") {
146
+ const nestedVariable = nestedLambda.operands[1];
147
+ if (nestedVariable !== undefined &&
148
+ isNamedOperand(nestedVariable) &&
149
+ nestedVariable.name === variableName) {
150
+ // The nested lambda shadows our variable: substitute only in the
151
+ // collection operand.
152
+ return {
153
+ operator: operand.operator,
154
+ operands: [
155
+ substituteLambdaVariable(nestedCollection, variableName, element),
156
+ nestedLambda,
157
+ ],
158
+ };
159
+ }
160
+ }
161
+ }
162
+ return {
163
+ operator: operand.operator,
164
+ operands: operand.operands.map((o) => substituteLambdaVariable(o, variableName, element)),
165
+ };
166
+ }
167
+ return operand;
168
+ }
169
+ /**
170
+ * Fold a collection macro whose collection operand is a literal value list.
171
+ *
172
+ * The planner emits this shape when a known-value collection (typically a
173
+ * folded principal attribute) has more than 10 elements — at 10 or fewer it
174
+ * unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
175
+ * cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
176
+ * the same fold here so the translated filter does not depend on which side of
177
+ * that threshold the collection lands: substitute each element into the lambda
178
+ * body and combine the per-element filters with OR (`exists`) or AND (`all`).
179
+ *
180
+ * An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
181
+ * match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
182
+ * over an empty collection.
183
+ */
184
+ function handleKnownValueCollectionOperator(operator, collection, lambda, mapper, negated) {
185
+ if (operator !== "exists" && operator !== "all") {
186
+ throw new Error(`${operator} over a literal collection value is not supported. ` +
187
+ "Only exists() and all() can be folded into a flat filter.");
188
+ }
189
+ const elements = collection.value;
190
+ if (!Array.isArray(elements)) {
191
+ throw new Error(`${operator} over a literal collection requires a list value`);
192
+ }
193
+ if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
194
+ throw new Error(`Second operand of ${operator} must be a lambda expression`);
195
+ }
196
+ if (lambda.operands.length !== 2) {
197
+ throw new Error(`${operator} over a literal collection supports single-variable lambdas only`);
198
+ }
199
+ const body = assertDefined(lambda.operands[0], "Lambda expression must provide a condition");
200
+ const variable = assertDefined(lambda.operands[1], "Lambda variable must have a name");
201
+ if (!isNamedOperand(variable)) {
202
+ throw new Error("Lambda variable must have a name");
203
+ }
204
+ const filters = elements.map((element) => {
205
+ const substituted = substituteLambdaVariable(body, variable.name, element);
206
+ return negated
207
+ ? buildNegatedFilter(substituted, mapper)
208
+ : buildPrismaFilterFromCerbosExpression(substituted, mapper);
209
+ });
210
+ const combinesWithOr = operator === "exists" ? !negated : negated;
211
+ return combinesWithOr ? { OR: filters } : { AND: filters };
212
+ }
213
+ function tryHandleKnownValueCollectionOperator(operator, operands, mapper, negated) {
214
+ if (operands.length !== 2) {
215
+ return undefined;
216
+ }
217
+ const collection = operands[0];
218
+ const lambda = operands[1];
219
+ if (collection === undefined ||
220
+ lambda === undefined ||
221
+ !isValueOperand(collection)) {
222
+ return undefined;
223
+ }
224
+ // A literal value list arrives when the planner could not unroll a macro
225
+ // over a known collection (more than 10 elements). Fold it before attempting
226
+ // relation resolution because there is no relation mapping for a literal.
227
+ return handleKnownValueCollectionOperator(operator, collection, lambda, mapper, negated);
228
+ }
25
229
  function getLeafField(path) {
26
230
  const fieldName = path[path.length - 1];
27
231
  if (!fieldName) {
@@ -42,24 +246,401 @@ function isResolvedFieldReference(operand) {
42
246
  function isResolvedValue(operand) {
43
247
  return "value" in operand;
44
248
  }
249
+ function getNamedOperand(operands, message) {
250
+ const operand = operands.find(isNamedOperand);
251
+ if (!operand) {
252
+ throw new Error(message);
253
+ }
254
+ return operand;
255
+ }
256
+ function getValueOperand(operands, message) {
257
+ const operand = operands.find(isValueOperand);
258
+ if (!operand) {
259
+ throw new Error(message);
260
+ }
261
+ return operand;
262
+ }
263
+ function requireResolvedFieldReference(operand, message) {
264
+ if (!isResolvedFieldReference(operand)) {
265
+ throw new Error(message);
266
+ }
267
+ return operand;
268
+ }
269
+ function requireResolvedValue(operand, message) {
270
+ if (!isResolvedValue(operand)) {
271
+ throw new Error(message);
272
+ }
273
+ return operand;
274
+ }
275
+ function wrapRelations(relations, filter) {
276
+ if (!relations || relations.length === 0) {
277
+ return filter;
278
+ }
279
+ return buildNestedRelationFilter(relations, filter);
280
+ }
281
+ function buildFieldEqualsFilter(fieldRef, value) {
282
+ const fieldName = getLeafField(fieldRef.path);
283
+ return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
284
+ }
285
+ function buildFieldDirectOrInFilter(fieldRef, values) {
286
+ const fieldName = getLeafField(fieldRef.path);
287
+ const baseFilter = values.length === 1
288
+ ? { [fieldName]: values[0] }
289
+ : { [fieldName]: { in: values } };
290
+ return wrapRelations(fieldRef.relations, baseFilter);
291
+ }
292
+ function buildMembershipFilter(fieldRef, values) {
293
+ const nonNullValues = values.filter((value) => value !== null);
294
+ const filters = [];
295
+ if (nonNullValues.length > 0 || values.length === 0) {
296
+ filters.push(buildFieldDirectOrInFilter(fieldRef, nonNullValues));
297
+ }
298
+ if (nonNullValues.length !== values.length) {
299
+ filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
300
+ }
301
+ if (filters.length === 1) {
302
+ return assertDefined(filters[0], "Membership filter is missing");
303
+ }
304
+ return { OR: filters };
305
+ }
306
+ function buildAlwaysTrueFilter() {
307
+ return {};
308
+ }
309
+ let lambdaScopes = [];
310
+ let rootModelName;
45
311
  /**
46
312
  * Converts a Cerbos query plan to a Prisma filter.
47
313
  */
48
- function queryPlanToPrisma({ queryPlan, mapper = {}, }) {
314
+ function queryPlanToPrisma({ queryPlan, mapper = {}, model, }) {
49
315
  switch (queryPlan.kind) {
50
316
  case core_1.PlanKind.ALWAYS_ALLOWED:
51
317
  return { kind: core_1.PlanKind.ALWAYS_ALLOWED };
52
318
  case core_1.PlanKind.ALWAYS_DENIED:
53
319
  return { kind: core_1.PlanKind.ALWAYS_DENIED };
54
- case core_1.PlanKind.CONDITIONAL:
320
+ case core_1.PlanKind.CONDITIONAL: {
321
+ lambdaScopes = [];
322
+ rootModelName = model;
323
+ const condition = constantFoldExpression(hoistOuterScopeReferences(queryPlan.condition, []));
324
+ if (isValueOperand(condition)) {
325
+ // Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
326
+ // they reach the adapter; this can only surface with hand-crafted plans.
327
+ if (condition.value === true) {
328
+ return { kind: core_1.PlanKind.CONDITIONAL, filters: {} };
329
+ }
330
+ // Prisma has no model-agnostic always-false `where` shape: empty logical arrays
331
+ // are ignored.
332
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
333
+ }
55
334
  return {
56
335
  kind: core_1.PlanKind.CONDITIONAL,
57
- filters: buildPrismaFilterFromCerbosExpression(queryPlan.condition, mapper),
336
+ filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
58
337
  };
338
+ }
59
339
  default:
60
340
  throw Error(`Invalid query plan.`);
61
341
  }
62
342
  }
343
+ const COLLECTION_OPERATORS = new Set([
344
+ "exists",
345
+ "exists_one",
346
+ "all",
347
+ "except",
348
+ "filter",
349
+ ]);
350
+ function mentionsVariable(expr, variableName) {
351
+ if (isNamedOperand(expr)) {
352
+ return (expr.name === variableName || expr.name.startsWith(variableName + "."));
353
+ }
354
+ if (isOperatorOperand(expr)) {
355
+ return expr.operands.some((operand) => mentionsVariable(operand, variableName));
356
+ }
357
+ return false;
358
+ }
359
+ function isOuterScopeName(name, enclosingVariables) {
360
+ return !enclosingVariables.some((variable) => name === variable || name.startsWith(variable + "."));
361
+ }
362
+ /** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
363
+ function substituteNamedOperand(expr, name, value) {
364
+ if (isNamedOperand(expr)) {
365
+ return expr.name === name ? { value } : expr;
366
+ }
367
+ if (isOperatorOperand(expr)) {
368
+ return {
369
+ operator: expr.operator,
370
+ operands: expr.operands.map((operand) => substituteNamedOperand(operand, name, value)),
371
+ };
372
+ }
373
+ return expr;
374
+ }
375
+ /**
376
+ * Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
377
+ * boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
378
+ * the body itself.
379
+ */
380
+ function findOuterBooleanReference(expr, enclosingVariables) {
381
+ if (isNamedOperand(expr)) {
382
+ return isOuterScopeName(expr.name, enclosingVariables)
383
+ ? expr.name
384
+ : undefined;
385
+ }
386
+ if (!isOperatorOperand(expr)) {
387
+ return undefined;
388
+ }
389
+ const booleanPositions = [];
390
+ switch (expr.operator) {
391
+ case "and":
392
+ case "or":
393
+ case "not":
394
+ booleanPositions.push(...expr.operands);
395
+ break;
396
+ case "if":
397
+ booleanPositions.push(...expr.operands);
398
+ break;
399
+ default:
400
+ // Comparisons wrapping a ternary keep the outer reference in the ternary condition.
401
+ booleanPositions.push(...expr.operands.filter((operand) => isOperatorOperand(operand) &&
402
+ (operand.operator === "if" ||
403
+ operand.operator === "and" ||
404
+ operand.operator === "or" ||
405
+ operand.operator === "not")));
406
+ break;
407
+ }
408
+ for (const operand of booleanPositions) {
409
+ const found = findOuterBooleanReference(operand, enclosingVariables);
410
+ if (found !== undefined) {
411
+ return found;
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+ /**
417
+ * Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
418
+ * outer lambda's element) so that every filter lands on the model it belongs to. Without this,
419
+ * an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
420
+ * tag model. Two sound transforms, applied bottom-up:
421
+ *
422
+ * - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
423
+ * `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
424
+ * OR), including the empty-collection case (both sides are FALSE).
425
+ * - Case split (any collection op): a bare outer boolean reference Q in a boolean position
426
+ * becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
427
+ * keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
428
+ * guarded-ternary encoding.
429
+ */
430
+ function hoistOuterScopeReferences(expr, enclosingVariables) {
431
+ if (!isOperatorOperand(expr)) {
432
+ return expr;
433
+ }
434
+ if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
435
+ const [collection, lambda] = expr.operands;
436
+ if (collection !== undefined &&
437
+ lambda !== undefined &&
438
+ isOperatorOperand(lambda) &&
439
+ lambda.operator === "lambda" &&
440
+ lambda.operands.length === 2 &&
441
+ lambda.operands[1] !== undefined &&
442
+ isNamedOperand(lambda.operands[1])) {
443
+ const variable = lambda.operands[1];
444
+ const body = hoistOuterScopeReferences(assertDefined(lambda.operands[0], "Lambda requires a condition"), [...enclosingVariables, variable.name]);
445
+ const rebuild = (newBody) => ({
446
+ operator: expr.operator,
447
+ operands: [
448
+ collection,
449
+ { operator: "lambda", operands: [newBody, variable] },
450
+ ],
451
+ });
452
+ // Conjunct hoisting out of exists().
453
+ if (expr.operator === "exists" &&
454
+ isOperatorOperand(body) &&
455
+ body.operator === "and") {
456
+ const elementConjuncts = body.operands.filter((operand) => mentionsVariable(operand, variable.name));
457
+ const outerConjuncts = body.operands.filter((operand) => !mentionsVariable(operand, variable.name));
458
+ if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
459
+ const innerBody = elementConjuncts.length === 1
460
+ ? elementConjuncts[0]
461
+ : { operator: "and", operands: elementConjuncts };
462
+ return {
463
+ operator: "and",
464
+ operands: [rebuild(innerBody), ...outerConjuncts],
465
+ };
466
+ }
467
+ }
468
+ // Case split on a bare outer boolean reference.
469
+ const outerRef = findOuterBooleanReference(body, [
470
+ ...enclosingVariables,
471
+ variable.name,
472
+ ]);
473
+ if (outerRef !== undefined) {
474
+ const q = { name: outerRef };
475
+ const notQ = { operator: "not", operands: [q] };
476
+ return {
477
+ operator: "or",
478
+ operands: [
479
+ {
480
+ operator: "and",
481
+ operands: [
482
+ q,
483
+ rebuild(substituteNamedOperand(body, outerRef, true)),
484
+ ],
485
+ },
486
+ {
487
+ operator: "and",
488
+ operands: [
489
+ notQ,
490
+ rebuild(substituteNamedOperand(body, outerRef, false)),
491
+ ],
492
+ },
493
+ // Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
494
+ { operator: "and", operands: [q, notQ] },
495
+ ],
496
+ };
497
+ }
498
+ return rebuild(body);
499
+ }
500
+ }
501
+ return {
502
+ operator: expr.operator,
503
+ operands: expr.operands.map((operand) => hoistOuterScopeReferences(operand, enclosingVariables)),
504
+ };
505
+ }
506
+ const FOLDABLE_COMPARISON_OPERATORS = new Set([
507
+ "eq",
508
+ "ne",
509
+ "lt",
510
+ "le",
511
+ "gt",
512
+ "ge",
513
+ ]);
514
+ /**
515
+ * Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
516
+ * produce need folding (constant ternary conditions, comparisons between two constants,
517
+ * logical operators with constant operands, collection macros with constant bodies); real
518
+ * planner output arrives pre-folded.
519
+ */
520
+ function constantFoldExpression(expr) {
521
+ if (!isOperatorOperand(expr)) {
522
+ return expr;
523
+ }
524
+ const operands = expr.operands.map(constantFoldExpression);
525
+ const folded = { operator: expr.operator, operands };
526
+ switch (expr.operator) {
527
+ case "if": {
528
+ const [condition, thenBranch, elseBranch] = operands;
529
+ if (condition !== undefined && isValueOperand(condition)) {
530
+ if (typeof condition.value !== "boolean") {
531
+ throw new Error("if (ternary) condition must be a boolean expression");
532
+ }
533
+ return assertDefined(condition.value ? thenBranch : elseBranch, "if (ternary) requires branch operands");
534
+ }
535
+ return folded;
536
+ }
537
+ case "and": {
538
+ if (operands.some((o) => isValueOperand(o) && o.value === false)) {
539
+ return { value: false };
540
+ }
541
+ const remaining = operands.filter((o) => !isValueOperand(o));
542
+ if (remaining.length === 0)
543
+ return { value: true };
544
+ if (remaining.length === 1)
545
+ return remaining[0];
546
+ return { operator: "and", operands: remaining };
547
+ }
548
+ case "or": {
549
+ if (operands.some((o) => isValueOperand(o) && o.value === true)) {
550
+ return { value: true };
551
+ }
552
+ const remaining = operands.filter((o) => !isValueOperand(o));
553
+ if (remaining.length === 0)
554
+ return { value: false };
555
+ if (remaining.length === 1)
556
+ return remaining[0];
557
+ return { operator: "or", operands: remaining };
558
+ }
559
+ case "not": {
560
+ const operand = operands[0];
561
+ if (operand !== undefined && isValueOperand(operand)) {
562
+ return { value: operand.value !== true };
563
+ }
564
+ return folded;
565
+ }
566
+ case "add":
567
+ case "sub":
568
+ case "mult":
569
+ case "div": {
570
+ const [left, right] = operands;
571
+ if (left !== undefined &&
572
+ right !== undefined &&
573
+ isValueOperand(left) &&
574
+ isValueOperand(right)) {
575
+ return {
576
+ value: foldArithmetic(expr.operator, left.value, right.value),
577
+ };
578
+ }
579
+ return folded;
580
+ }
581
+ case "contains":
582
+ case "startsWith":
583
+ case "endsWith": {
584
+ const [receiver, needle] = operands;
585
+ if (receiver !== undefined &&
586
+ needle !== undefined &&
587
+ isValueOperand(receiver) &&
588
+ isValueOperand(needle) &&
589
+ typeof receiver.value === "string" &&
590
+ typeof needle.value === "string") {
591
+ const r = receiver.value;
592
+ const n = needle.value;
593
+ return {
594
+ value: expr.operator === "contains"
595
+ ? r.includes(n)
596
+ : expr.operator === "startsWith"
597
+ ? r.startsWith(n)
598
+ : r.endsWith(n),
599
+ };
600
+ }
601
+ return folded;
602
+ }
603
+ case "exists":
604
+ case "all": {
605
+ const [collection, lambda] = operands;
606
+ if (collection !== undefined &&
607
+ lambda !== undefined &&
608
+ isOperatorOperand(lambda) &&
609
+ lambda.operator === "lambda" &&
610
+ lambda.operands[0] !== undefined &&
611
+ isValueOperand(lambda.operands[0])) {
612
+ const bodyValue = lambda.operands[0].value === true;
613
+ if (expr.operator === "exists" && !bodyValue)
614
+ return { value: false };
615
+ if (expr.operator === "all" && bodyValue)
616
+ return { value: true };
617
+ const sizeExpr = {
618
+ operator: "size",
619
+ operands: [collection],
620
+ };
621
+ // exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
622
+ return expr.operator === "exists"
623
+ ? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
624
+ : { operator: "eq", operands: [sizeExpr, { value: 0 }] };
625
+ }
626
+ return folded;
627
+ }
628
+ default: {
629
+ if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
630
+ const [left, right] = operands;
631
+ if (left !== undefined &&
632
+ right !== undefined &&
633
+ isValueOperand(left) &&
634
+ isValueOperand(right)) {
635
+ return {
636
+ value: evaluateConstantComparison(expr.operator, left.value, right.value),
637
+ };
638
+ }
639
+ }
640
+ return folded;
641
+ }
642
+ }
643
+ }
63
644
  /**
64
645
  * Resolves a field reference considering relations and nested fields.
65
646
  */
@@ -93,6 +674,7 @@ function resolveFieldReference(reference, mapper) {
93
674
  {
94
675
  name,
95
676
  type,
677
+ model: activeConfig.relation.model,
96
678
  field: activeConfig.relation.field,
97
679
  nestedMapper: fields,
98
680
  },
@@ -114,6 +696,7 @@ function resolveFieldReference(reference, mapper) {
114
696
  relations.push({
115
697
  name: nextConfig.relation.name,
116
698
  type: nextConfig.relation.type,
699
+ model: nextConfig.relation.model,
117
700
  field: nextConfig.relation.field,
118
701
  nestedMapper: nextConfig.relation.fields,
119
702
  });
@@ -130,10 +713,17 @@ function resolveFieldReference(reference, mapper) {
130
713
  }
131
714
  }
132
715
  }
133
- return { path: field ? [field] : remainingParts, relations };
716
+ return {
717
+ path: field ? [field] : remainingParts,
718
+ relations,
719
+ valueType: activeConfig.valueType,
720
+ };
134
721
  }
135
722
  // Simple field mapping
136
- return { path: [activeConfig?.field || reference] };
723
+ return {
724
+ path: [activeConfig?.field || reference],
725
+ valueType: activeConfig?.valueType,
726
+ };
137
727
  }
138
728
  /**
139
729
  * Determines the appropriate Prisma operator based on relation type.
@@ -180,11 +770,65 @@ function resolveOperand(operand, mapper) {
180
770
  return { value: operand.value };
181
771
  }
182
772
  else if (isOperatorOperand(operand)) {
773
+ if (operand.operator === "timestamp") {
774
+ return resolveTimestampOperand(operand, mapper);
775
+ }
776
+ const folded = tryFoldValueExpression(operand, mapper);
777
+ if (folded !== null)
778
+ return { value: folded };
183
779
  const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
184
780
  return { value: nestedResult };
185
781
  }
186
782
  throw new Error("Operand must have name, value, or be an expression");
187
783
  }
784
+ function resolveTimestampOperand(expression, mapper) {
785
+ if (expression.operands.length !== 1) {
786
+ throw new Error("timestamp() requires exactly one operand");
787
+ }
788
+ const operand = assertDefined(expression.operands[0], "timestamp() requires an operand");
789
+ if (isNamedOperand(operand)) {
790
+ const fieldRef = resolveFieldReference(operand.name, mapper);
791
+ if (fieldRef.valueType !== "dateTime") {
792
+ throw new Error(`timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`);
793
+ }
794
+ return fieldRef;
795
+ }
796
+ if (!isValueOperand(operand) || typeof operand.value !== "string") {
797
+ throw new Error("timestamp() requires a field reference or RFC 3339 string");
798
+ }
799
+ return { value: normalizeRfc3339Milliseconds(operand.value) };
800
+ }
801
+ function tryFoldValueExpression(expr, mapper) {
802
+ if (!ARITHMETIC_OPERATORS.has(expr.operator))
803
+ return null;
804
+ const leftOp = expr.operands[0];
805
+ const rightOp = expr.operands[1];
806
+ if (!leftOp || !rightOp)
807
+ return null;
808
+ const left = resolveOperand(leftOp, mapper);
809
+ if (!isResolvedValue(left))
810
+ return null;
811
+ const right = resolveOperand(rightOp, mapper);
812
+ if (!isResolvedValue(right))
813
+ return null;
814
+ try {
815
+ return foldArithmetic(expr.operator, left.value, right.value);
816
+ }
817
+ catch {
818
+ return null;
819
+ }
820
+ }
821
+ /**
822
+ * Records that the lambda body being built touches a nullable element column, so the
823
+ * enclosing collection operator can add its three-valued-logic guard.
824
+ */
825
+ function recordNullableElementField(config, defaultField) {
826
+ if (!config.nullable) {
827
+ return;
828
+ }
829
+ const scope = lambdaScopes[lambdaScopes.length - 1];
830
+ scope?.nullableFields.add(config.field || defaultField);
831
+ }
188
832
  /**
189
833
  * Creates a scoped mapper for collection operations
190
834
  */
@@ -225,7 +869,9 @@ function createScopedMapper(collectionPath, variableName, fullMapper) {
225
869
  field = strippedKey;
226
870
  }
227
871
  // Return the field config if it exists, otherwise create a default one
228
- return currentConfig[field] || { field };
872
+ const fieldConfig = currentConfig[field] || { field };
873
+ recordNullableElementField(fieldConfig, field);
874
+ return fieldConfig;
229
875
  }
230
876
  return { field: strippedKey };
231
877
  }
@@ -242,13 +888,8 @@ function createScopedMapper(collectionPath, variableName, fullMapper) {
242
888
  function buildPrismaFilterFromCerbosExpression(expression, mapper) {
243
889
  // A bare named operand represents a boolean field reference (e.g. `R.attr.booleanAttr`)
244
890
  if (isNamedOperand(expression)) {
245
- const { path, relations } = resolveFieldReference(expression.name, mapper);
246
- const fieldName = getLeafField(path);
247
- const fieldFilter = { [fieldName]: { equals: true } };
248
- if (relations && relations.length > 0) {
249
- return buildNestedRelationFilter(relations, fieldFilter);
250
- }
251
- return fieldFilter;
891
+ const fieldRef = resolveFieldReference(expression.name, mapper);
892
+ return buildFieldEqualsFilter(fieldRef, true);
252
893
  }
253
894
  if (!isOperatorOperand(expression)) {
254
895
  throw new Error("Invalid Cerbos expression structure");
@@ -269,16 +910,10 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
269
910
  if (!operand) {
270
911
  throw new Error("not operator requires an operand");
271
912
  }
272
- if (isNamedOperand(operand)) {
273
- const { path, relations } = resolveFieldReference(operand.name, mapper);
274
- if (!relations || relations.length === 0) {
275
- const fieldName = getLeafField(path);
276
- return { [fieldName]: { equals: false } };
277
- }
278
- }
279
- return {
280
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
281
- };
913
+ return buildNegatedFilter(operand, mapper);
914
+ }
915
+ case "if": {
916
+ return handleBooleanTernaryOperator(operands, mapper);
282
917
  }
283
918
  case "eq":
284
919
  case "ne":
@@ -315,10 +950,319 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
315
950
  case "map": {
316
951
  return handleMapOperator(operands, mapper);
317
952
  }
953
+ case "overlaps": {
954
+ return handleOverlapsOperator(operands, mapper);
955
+ }
956
+ case "ancestorOf": {
957
+ return handleAncestorDescendantOperator(operands, mapper, "ancestor");
958
+ }
959
+ case "descendentOf": {
960
+ return handleAncestorDescendantOperator(operands, mapper, "descendant");
961
+ }
318
962
  default:
319
963
  throw new Error(`Unsupported operator: ${operator}`);
320
964
  }
321
965
  }
966
+ function containsCollectionOperator(expr) {
967
+ if (!isOperatorOperand(expr)) {
968
+ return false;
969
+ }
970
+ if (COLLECTION_OPERATORS.has(expr.operator)) {
971
+ return true;
972
+ }
973
+ return expr.operands.some(containsCollectionOperator);
974
+ }
975
+ /**
976
+ * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
977
+ * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
978
+ * subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
979
+ * wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
980
+ * attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
981
+ * therefore pushed down to the collection operators, which encode CEL's exact error
982
+ * semantics:
983
+ *
984
+ * - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
985
+ * and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
986
+ * UNKNOWN-element (the nullable-field guard).
987
+ * - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
988
+ * elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
989
+ */
990
+ function buildNegatedFilter(operand, mapper) {
991
+ if (isNamedOperand(operand)) {
992
+ const { relations, ...fieldRef } = resolveFieldReference(operand.name, mapper);
993
+ if (!relations || relations.length === 0) {
994
+ return buildFieldEqualsFilter(fieldRef, false);
995
+ }
996
+ return {
997
+ NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
998
+ };
999
+ }
1000
+ if (isOperatorOperand(operand) && containsCollectionOperator(operand)) {
1001
+ switch (operand.operator) {
1002
+ case "and":
1003
+ return {
1004
+ OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1005
+ };
1006
+ case "or":
1007
+ return {
1008
+ AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1009
+ };
1010
+ case "not":
1011
+ return buildPrismaFilterFromCerbosExpression(assertDefined(operand.operands[0], "not operator requires an operand"), mapper);
1012
+ case "exists":
1013
+ case "all":
1014
+ case "except":
1015
+ case "exists_one":
1016
+ case "filter":
1017
+ return buildNegatedCollectionFilter(operand.operator, operand.operands, mapper);
1018
+ }
1019
+ }
1020
+ return {
1021
+ NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
1022
+ };
1023
+ }
1024
+ function getTernaryOperands(operands) {
1025
+ if (operands.length !== 3) {
1026
+ throw new Error(`if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`);
1027
+ }
1028
+ return {
1029
+ condition: assertDefined(operands[0], "if (ternary) requires a condition operand"),
1030
+ thenBranch: assertDefined(operands[1], "if (ternary) requires a then operand"),
1031
+ elseBranch: assertDefined(operands[2], "if (ternary) requires an else operand"),
1032
+ };
1033
+ }
1034
+ function getConstantBooleanCondition(condition) {
1035
+ if (!isValueOperand(condition)) {
1036
+ return undefined;
1037
+ }
1038
+ if (typeof condition.value !== "boolean") {
1039
+ throw new Error("if (ternary) condition must be a boolean expression");
1040
+ }
1041
+ return condition.value;
1042
+ }
1043
+ function buildBooleanBranchFilter(branch, mapper) {
1044
+ if (!isValueOperand(branch)) {
1045
+ return {
1046
+ kind: "filter",
1047
+ filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
1048
+ };
1049
+ }
1050
+ if (typeof branch.value !== "boolean") {
1051
+ throw new Error("if (ternary) branch in boolean position must be a boolean");
1052
+ }
1053
+ return { kind: "constant", value: branch.value };
1054
+ }
1055
+ function buildFalseConditionFilter(condition, mapper) {
1056
+ if (isValueOperand(condition)) {
1057
+ throw new Error("Constant ternary conditions must be folded before use");
1058
+ }
1059
+ if (isNamedOperand(condition)) {
1060
+ const fieldRef = resolveFieldReference(condition.name, mapper);
1061
+ if (!fieldRef.relations || fieldRef.relations.length === 0) {
1062
+ return buildFieldEqualsFilter(fieldRef, false);
1063
+ }
1064
+ return {
1065
+ NOT: buildFieldEqualsFilter(fieldRef, true),
1066
+ };
1067
+ }
1068
+ if (isOperatorOperand(condition) && condition.operator === "not") {
1069
+ return buildPrismaFilterFromCerbosExpression(assertDefined(condition.operands[0], "not operator requires an operand"), mapper);
1070
+ }
1071
+ return {
1072
+ NOT: buildPrismaFilterFromCerbosExpression(condition, mapper),
1073
+ };
1074
+ }
1075
+ function buildGuardedTernaryFilter({ condition, thenFilter, elseFilter, mapper, }) {
1076
+ const conditionTrue = buildPrismaFilterFromCerbosExpression(condition, mapper);
1077
+ const conditionFalse = buildFalseConditionFilter(condition, mapper);
1078
+ const guardedBranches = [];
1079
+ if (thenFilter.kind === "filter") {
1080
+ guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
1081
+ }
1082
+ else if (thenFilter.value) {
1083
+ guardedBranches.push(conditionTrue);
1084
+ }
1085
+ if (elseFilter.kind === "filter") {
1086
+ guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
1087
+ }
1088
+ else if (elseFilter.value) {
1089
+ guardedBranches.push(conditionFalse);
1090
+ }
1091
+ // For a known condition this contradiction is false. If the condition is
1092
+ // NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
1093
+ // ternary UNKNOWN under an outer NOT instead of authorizing the row.
1094
+ guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
1095
+ return {
1096
+ OR: guardedBranches,
1097
+ };
1098
+ }
1099
+ function finalizeTernaryBranchPredicate(predicate) {
1100
+ if (predicate.kind === "filter") {
1101
+ return predicate.filter;
1102
+ }
1103
+ if (predicate.value) {
1104
+ return buildAlwaysTrueFilter();
1105
+ }
1106
+ // Prisma has no model-agnostic always-false `where` shape: empty logical
1107
+ // arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
1108
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
1109
+ }
1110
+ function handleBooleanTernaryOperator(operands, mapper) {
1111
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
1112
+ const constantCondition = getConstantBooleanCondition(condition);
1113
+ if (constantCondition !== undefined) {
1114
+ return finalizeTernaryBranchPredicate(buildBooleanBranchFilter(constantCondition ? thenBranch : elseBranch, mapper));
1115
+ }
1116
+ return buildGuardedTernaryFilter({
1117
+ condition,
1118
+ thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
1119
+ elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
1120
+ mapper,
1121
+ });
1122
+ }
1123
+ function buildTernaryComparisonBranch({ operator, operands, ternaryIndex, branch, mapper, }) {
1124
+ const substitutedOperands = operands.map((operand, index) => index === ternaryIndex ? branch : operand);
1125
+ assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
1126
+ assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
1127
+ const normalized = normalizeBinaryOperands(operator, substitutedOperands);
1128
+ const normalizedFirst = normalized.operands[0];
1129
+ const normalizedSecond = normalized.operands[1];
1130
+ if (normalized.operands.length === 2 &&
1131
+ normalizedFirst !== undefined &&
1132
+ normalizedSecond !== undefined &&
1133
+ isValueOperand(normalizedFirst) &&
1134
+ isValueOperand(normalizedSecond)) {
1135
+ return {
1136
+ kind: "constant",
1137
+ value: evaluateConstantComparison(normalized.operator, normalizedFirst.value, normalizedSecond.value),
1138
+ };
1139
+ }
1140
+ return {
1141
+ kind: "filter",
1142
+ filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
1143
+ };
1144
+ }
1145
+ function tryHandleTernaryComparison(operator, operands, mapper) {
1146
+ if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
1147
+ return null;
1148
+ }
1149
+ const ternaryIndex = operands.findIndex((operand) => isOperatorOperand(operand) && operand.operator === "if");
1150
+ if (ternaryIndex === -1) {
1151
+ return null;
1152
+ }
1153
+ if (operands.length !== 2) {
1154
+ throw new Error(`${operator} with a ternary requires exactly 2 operands, got ${operands.length}`);
1155
+ }
1156
+ const ternary = assertDefined(operands[ternaryIndex], "Ternary comparison operand is missing");
1157
+ if (!isOperatorOperand(ternary)) {
1158
+ throw new Error("Ternary comparison operand must be an expression");
1159
+ }
1160
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(ternary.operands);
1161
+ const constantCondition = getConstantBooleanCondition(condition);
1162
+ if (constantCondition !== undefined) {
1163
+ return finalizeTernaryBranchPredicate(buildTernaryComparisonBranch({
1164
+ operator,
1165
+ operands,
1166
+ ternaryIndex,
1167
+ branch: constantCondition ? thenBranch : elseBranch,
1168
+ mapper,
1169
+ }));
1170
+ }
1171
+ return buildGuardedTernaryFilter({
1172
+ condition,
1173
+ thenFilter: buildTernaryComparisonBranch({
1174
+ operator,
1175
+ operands,
1176
+ ternaryIndex,
1177
+ branch: thenBranch,
1178
+ mapper,
1179
+ }),
1180
+ elseFilter: buildTernaryComparisonBranch({
1181
+ operator,
1182
+ operands,
1183
+ ternaryIndex,
1184
+ branch: elseBranch,
1185
+ mapper,
1186
+ }),
1187
+ mapper,
1188
+ });
1189
+ }
1190
+ function evaluateConstantComparison(operator, left, right) {
1191
+ switch (operator) {
1192
+ case "eq":
1193
+ return areValuesEqual(left, right);
1194
+ case "ne":
1195
+ return !areValuesEqual(left, right);
1196
+ case "lt":
1197
+ case "le":
1198
+ case "gt":
1199
+ case "ge": {
1200
+ if (typeof left === "number" &&
1201
+ typeof right === "number" &&
1202
+ (Number.isNaN(left) || Number.isNaN(right))) {
1203
+ return false;
1204
+ }
1205
+ if (!((typeof left === "number" && typeof right === "number") ||
1206
+ (typeof left === "string" && typeof right === "string"))) {
1207
+ throw new Error(`${operator} constant comparison requires two numbers or two strings`);
1208
+ }
1209
+ switch (operator) {
1210
+ case "lt":
1211
+ return compareConstantValues(left, right) < 0;
1212
+ case "le":
1213
+ return compareConstantValues(left, right) <= 0;
1214
+ case "gt":
1215
+ return compareConstantValues(left, right) > 0;
1216
+ case "ge":
1217
+ return compareConstantValues(left, right) >= 0;
1218
+ }
1219
+ }
1220
+ }
1221
+ throw new Error(`Unsupported constant comparison operator: ${operator}`);
1222
+ }
1223
+ function compareConstantValues(left, right) {
1224
+ if (typeof left === "number" && typeof right === "number") {
1225
+ return left === right ? 0 : left < right ? -1 : 1;
1226
+ }
1227
+ if (typeof left !== "string" || typeof right !== "string") {
1228
+ throw new Error("Cannot order constant values of different types");
1229
+ }
1230
+ const leftCodePoints = Array.from(left, (character) => character.codePointAt(0));
1231
+ const rightCodePoints = Array.from(right, (character) => character.codePointAt(0));
1232
+ const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
1233
+ for (let index = 0; index < sharedLength; index++) {
1234
+ const leftCodePoint = leftCodePoints[index];
1235
+ const rightCodePoint = rightCodePoints[index];
1236
+ if (leftCodePoint !== rightCodePoint) {
1237
+ return leftCodePoint < rightCodePoint ? -1 : 1;
1238
+ }
1239
+ }
1240
+ return leftCodePoints.length === rightCodePoints.length
1241
+ ? 0
1242
+ : leftCodePoints.length < rightCodePoints.length
1243
+ ? -1
1244
+ : 1;
1245
+ }
1246
+ function areValuesEqual(left, right) {
1247
+ if (Array.isArray(left)) {
1248
+ return (Array.isArray(right) &&
1249
+ left.length === right.length &&
1250
+ left.every((value, index) => areValuesEqual(value, right[index])));
1251
+ }
1252
+ if (Array.isArray(right)) {
1253
+ return false;
1254
+ }
1255
+ if (typeof left === "object" && left !== null) {
1256
+ if (typeof right !== "object" || right === null) {
1257
+ return false;
1258
+ }
1259
+ const leftKeys = Object.keys(left);
1260
+ const rightKeys = Object.keys(right);
1261
+ return (leftKeys.length === rightKeys.length &&
1262
+ leftKeys.every((key) => key in right && areValuesEqual(left[key], right[key])));
1263
+ }
1264
+ return left === right;
1265
+ }
322
1266
  function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
323
1267
  const collectionOperand = sizeOperand.operands[0];
324
1268
  if (!collectionOperand || !isNamedOperand(collectionOperand)) {
@@ -357,14 +1301,12 @@ function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
357
1301
  * Helper function to process relational operators (eq, ne, lt, etc.)
358
1302
  */
359
1303
  function handleRelationalOperator(operator, operands, mapper) {
360
- const prismaOperator = {
361
- eq: "equals",
362
- ne: "not",
363
- lt: "lt",
364
- le: "lte",
365
- gt: "gt",
366
- ge: "gte",
367
- }[operator];
1304
+ const ternaryFilter = tryHandleTernaryComparison(operator, operands, mapper);
1305
+ if (ternaryFilter) {
1306
+ return ternaryFilter;
1307
+ }
1308
+ ({ operator, operands } = normalizeBinaryOperands(operator, operands));
1309
+ const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
368
1310
  if (!prismaOperator) {
369
1311
  throw new Error(`Unsupported operator: ${operator}`);
370
1312
  }
@@ -377,115 +1319,251 @@ function handleRelationalOperator(operator, operands, mapper) {
377
1319
  if (isOperatorOperand(leftOperand) && leftOperand.operator === "size") {
378
1320
  return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
379
1321
  }
1322
+ const arithOperand = [leftOperand, rightOperand].find((o) => isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator));
1323
+ if (arithOperand) {
1324
+ const otherOperand = arithOperand === leftOperand ? rightOperand : leftOperand;
1325
+ return handleArithmeticComparison(operator, arithOperand, otherOperand, arithOperand === leftOperand, mapper);
1326
+ }
1327
+ const mapOperand = [leftOperand, rightOperand].find((o) => isOperatorOperand(o) && o.operator === "map");
1328
+ if (mapOperand) {
1329
+ throw new Error(`Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
1330
+ `Wrap the map() expression in hasIntersection(map(...), [...]) instead.`);
1331
+ }
380
1332
  const left = resolveOperand(leftOperand, mapper);
381
1333
  const right = resolveOperand(rightOperand, mapper);
1334
+ if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
1335
+ return buildFieldToFieldFilter(operator, leftOperand, left, rightOperand, right);
1336
+ }
1337
+ if (isResolvedValue(left) && isResolvedFieldReference(right)) {
1338
+ return buildComparisonFilter(right, MIRRORED_OPERATOR[operator] ?? operator, left.value);
1339
+ }
1340
+ if (isResolvedValue(left) && isResolvedValue(right)) {
1341
+ const matches = evaluateConstantComparison(operator, left.value, right.value);
1342
+ if (matches) {
1343
+ return buildAlwaysTrueFilter();
1344
+ }
1345
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
1346
+ }
1347
+ const rightValue = requireResolvedValue(right, "Right operand must be a value");
382
1348
  if (isResolvedFieldReference(left)) {
383
- const { path, relations } = left;
384
- if (!isResolvedValue(right)) {
385
- throw new Error("Right operand must be a value");
1349
+ return buildComparisonFilter(left, operator, rightValue.value);
1350
+ }
1351
+ return { [prismaOperator]: rightValue.value };
1352
+ }
1353
+ /**
1354
+ * Builds a field-vs-constant comparison. Fractional constants against ordering (and
1355
+ * equality) operators are emitted as a two-clause bracket because Prisma coerces filter
1356
+ * values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
1357
+ * inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
1358
+ * neighbor so the combination is correct BOTH for Float columns (the extra clause is
1359
+ * redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
1360
+ */
1361
+ function buildComparisonFilter(fieldRef, operator, value) {
1362
+ const prismaOperator = assertDefined(CERBOS_TO_PRISMA_OPERATOR[operator], `Unsupported operator: ${operator}`);
1363
+ if (typeof value === "number" && !Number.isInteger(value)) {
1364
+ const fieldName = getLeafField(fieldRef.path);
1365
+ const lower = Math.floor(value);
1366
+ const upper = Math.ceil(value);
1367
+ const equalsBracket = {
1368
+ AND: [
1369
+ { [fieldName]: { equals: value } },
1370
+ { [fieldName]: { gt: lower } },
1371
+ { [fieldName]: { lt: upper } },
1372
+ ],
1373
+ };
1374
+ let bracket;
1375
+ switch (operator) {
1376
+ case "gt":
1377
+ bracket = {
1378
+ OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
1379
+ };
1380
+ break;
1381
+ case "ge":
1382
+ bracket = {
1383
+ AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
1384
+ };
1385
+ break;
1386
+ case "lt":
1387
+ bracket = {
1388
+ OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
1389
+ };
1390
+ break;
1391
+ case "le":
1392
+ bracket = {
1393
+ AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
1394
+ };
1395
+ break;
1396
+ case "eq":
1397
+ bracket = equalsBracket;
1398
+ break;
1399
+ case "ne":
1400
+ bracket = { NOT: equalsBracket };
1401
+ break;
386
1402
  }
387
- const filterValue = { [prismaOperator]: right.value };
388
- const fieldName = getLeafField(path);
389
- const fieldFilter = { [fieldName]: filterValue };
390
- if (relations && relations.length > 0) {
391
- return buildNestedRelationFilter(relations, fieldFilter);
1403
+ if (bracket) {
1404
+ return wrapRelations(fieldRef.relations, bracket);
392
1405
  }
393
- return fieldFilter;
394
1406
  }
395
- if (!isResolvedValue(right)) {
396
- throw new Error("Right operand must be a value");
1407
+ return buildFieldFilter(fieldRef, prismaOperator, value);
1408
+ }
1409
+ /**
1410
+ * Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
1411
+ * references between fields of the SAME model, so the container is the root model for
1412
+ * root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
1413
+ * (an element column against an outer column) are cross-model and must fail loudly.
1414
+ */
1415
+ function buildFieldToFieldFilter(operator, leftOperand, left, rightOperand, right) {
1416
+ const prismaOperator = assertDefined(CERBOS_TO_PRISMA_OPERATOR[operator], `Unsupported operator: ${operator}`);
1417
+ if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
1418
+ throw new Error("Field-to-field comparison requires two named operands");
1419
+ }
1420
+ const scope = lambdaScopes[lambdaScopes.length - 1];
1421
+ let container;
1422
+ if (scope) {
1423
+ const prefix = scope.variableName + ".";
1424
+ const leftInScope = leftOperand.name.startsWith(prefix);
1425
+ const rightInScope = rightOperand.name.startsWith(prefix);
1426
+ if (leftInScope !== rightInScope) {
1427
+ throw new Error(`Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
1428
+ "Prisma field references only work between fields of the same model");
1429
+ }
1430
+ if (leftInScope) {
1431
+ container = scope.relationModel;
1432
+ if (!container) {
1433
+ throw new Error("Field-to-field comparison inside a collection requires `relation.model` in the mapper");
1434
+ }
1435
+ }
1436
+ }
1437
+ if (container === undefined) {
1438
+ if ((left.relations && left.relations.length > 0) ||
1439
+ (right.relations && right.relations.length > 0)) {
1440
+ throw new Error("Cannot compare columns across relations: Prisma field references only work between fields of the same model");
1441
+ }
1442
+ container = rootModelName;
1443
+ if (!container) {
1444
+ throw new Error("Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference");
1445
+ }
397
1446
  }
398
- return { [prismaOperator]: right.value };
1447
+ const leftField = getLeafField(left.path);
1448
+ const rightField = getLeafField(right.path);
1449
+ return {
1450
+ [leftField]: {
1451
+ [prismaOperator]: { _ref: rightField, _container: container },
1452
+ },
1453
+ };
399
1454
  }
400
1455
  /**
401
1456
  * Helper function to handle "in" operator
402
1457
  */
403
1458
  function handleInOperator(operands, mapper) {
404
- const nameOperand = operands.find(isNamedOperand);
405
- if (!nameOperand)
406
- throw new Error("Name operand is undefined");
407
- const valueOperand = operands.find(isValueOperand);
408
- if (!valueOperand)
409
- throw new Error("Value operand is undefined");
410
- const resolved = resolveOperand(nameOperand, mapper);
411
- if (!isResolvedFieldReference(resolved)) {
412
- throw new Error("Name operand must resolve to a field reference");
413
- }
414
- const { path, relations } = resolved;
415
- const resolvedValue = resolveOperand(valueOperand, mapper);
416
- if (!isResolvedValue(resolvedValue)) {
417
- throw new Error("Value operand must resolve to a value");
418
- }
419
- const { value } = resolvedValue;
420
- const values = Array.isArray(value) ? value : [value];
421
- const fieldName = getLeafField(path);
422
- if (relations && relations.length > 0) {
423
- const fieldFilter = values.length === 1
424
- ? { [fieldName]: values[0] }
425
- : { [fieldName]: { in: values } };
426
- return buildNestedRelationFilter(relations, fieldFilter);
1459
+ if (operands.length !== 2) {
1460
+ throw new Error("in requires exactly two operands");
427
1461
  }
428
- return values.length === 1
429
- ? { [fieldName]: values[0] }
430
- : { [fieldName]: { in: values } };
1462
+ const member = assertDefined(operands[0], "in requires a member operand");
1463
+ const collection = assertDefined(operands[1], "in requires a collection operand");
1464
+ if (isNamedOperand(member) && isValueOperand(collection)) {
1465
+ const fieldRef = resolveFieldReference(member.name, mapper);
1466
+ const values = Array.isArray(collection.value)
1467
+ ? collection.value
1468
+ : [collection.value];
1469
+ return buildMembershipFilter(fieldRef, values);
1470
+ }
1471
+ if (isValueOperand(member) && isNamedOperand(collection)) {
1472
+ return buildMembershipFilter(resolveFieldReference(collection.name, mapper), [member.value]);
1473
+ }
1474
+ if (isNamedOperand(member) && isNamedOperand(collection)) {
1475
+ throw new Error("Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column");
1476
+ }
1477
+ throw new Error("in requires a field member and literal list, or a literal member and field collection");
431
1478
  }
1479
+ // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
1480
+ const MAX_ENUMERATED_NEEDLES = 1000;
432
1481
  /**
433
- * Helper function to handle string operators (contains, startsWith, endsWith)
1482
+ * Helper function to handle string operators (contains, startsWith, endsWith).
1483
+ *
1484
+ * These operators are receiver-sensitive and the planner preserves policy source order:
1485
+ * operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
1486
+ * inverts the match direction, so unlike symmetric comparisons they are never normalized.
434
1487
  */
435
1488
  function handleStringOperator(operator, operands, mapper) {
436
- const nameOperand = operands.find(isNamedOperand);
437
- if (!nameOperand)
438
- throw new Error("Name operand is undefined");
439
- const resolved = resolveOperand(nameOperand, mapper);
440
- if (!isResolvedFieldReference(resolved)) {
441
- throw new Error("Name operand must resolve to a field reference");
442
- }
443
- const { path, relations } = resolved;
444
- const valueOperand = operands.find(isValueOperand);
445
- if (!valueOperand)
446
- throw new Error("Value operand is undefined");
447
- const resolvedValue = resolveOperand(valueOperand, mapper);
448
- if (!isResolvedValue(resolvedValue)) {
449
- throw new Error("Value operand must resolve to a value");
450
- }
451
- const { value } = resolvedValue;
452
- if (typeof value !== "string") {
453
- throw new Error(`${operator} operator requires string value`);
1489
+ if (operands.length !== 2) {
1490
+ throw new Error(`${operator} requires exactly two operands`);
454
1491
  }
455
- const fieldName = getLeafField(path);
456
- const fieldFilter = { [fieldName]: { [operator]: value } };
457
- if (relations && relations.length > 0) {
458
- return buildNestedRelationFilter(relations, fieldFilter);
1492
+ const receiver = resolveOperand(assertDefined(operands[0], `${operator} requires a receiver operand`), mapper);
1493
+ const needle = resolveOperand(assertDefined(operands[1], `${operator} requires a needle operand`), mapper);
1494
+ // Column receiver, constant needle: Prisma's LIKE-based filter.
1495
+ if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
1496
+ const { value } = needle;
1497
+ if (typeof value !== "string") {
1498
+ throw new Error(`${operator} operator requires string value`);
1499
+ }
1500
+ // Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
1501
+ // so a needle containing % or _ would match as a pattern instead of literally (e.g.
1502
+ // startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
1503
+ if (/[%_]/.test(value)) {
1504
+ throw new Error(`Cannot translate ${operator} with a needle containing LIKE metacharacters (% or _): ` +
1505
+ "Prisma does not escape wildcards in string filters");
1506
+ }
1507
+ const fieldName = getLeafField(receiver.path);
1508
+ return wrapRelations(receiver.relations, {
1509
+ [fieldName]: { [operator]: value },
1510
+ });
459
1511
  }
460
- return fieldFilter;
1512
+ // Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
1513
+ // candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
1514
+ // substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
1515
+ // NULL needle column stays excluded (CEL missing-attribute deny).
1516
+ if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
1517
+ if (typeof receiver.value !== "string") {
1518
+ throw new Error(`${operator} operator requires a string receiver`);
1519
+ }
1520
+ const haystack = receiver.value;
1521
+ const candidates = new Set([""]);
1522
+ if (operator === "startsWith") {
1523
+ for (let i = 1; i <= haystack.length; i++) {
1524
+ candidates.add(haystack.slice(0, i));
1525
+ }
1526
+ }
1527
+ else if (operator === "endsWith") {
1528
+ for (let i = 0; i < haystack.length; i++) {
1529
+ candidates.add(haystack.slice(i));
1530
+ }
1531
+ }
1532
+ else {
1533
+ for (let start = 0; start < haystack.length; start++) {
1534
+ for (let end = start + 1; end <= haystack.length; end++) {
1535
+ candidates.add(haystack.slice(start, end));
1536
+ }
1537
+ }
1538
+ }
1539
+ if (candidates.size > MAX_ENUMERATED_NEEDLES) {
1540
+ throw new Error(`Cannot translate ${operator} with a constant receiver of this length: ` +
1541
+ `enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`);
1542
+ }
1543
+ const fieldName = getLeafField(needle.path);
1544
+ return wrapRelations(needle.relations, {
1545
+ [fieldName]: { in: [...candidates] },
1546
+ });
1547
+ }
1548
+ if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
1549
+ // A column-valued needle would need its LIKE metacharacters escaped per row, which
1550
+ // Prisma's filters cannot do (and Prisma leaks wildcards from field references).
1551
+ throw new Error(`Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`);
1552
+ }
1553
+ throw new Error(`${operator} between two constants must be folded by the Cerbos planner`);
461
1554
  }
462
1555
  /**
463
1556
  * Helper function to handle "isSet" operator
464
1557
  */
465
1558
  function handleIsSetOperator(operands, mapper) {
466
- const nameOperand = operands.find(isNamedOperand);
467
- if (!nameOperand)
468
- throw new Error("Name operand is undefined");
469
- const resolved = resolveOperand(nameOperand, mapper);
470
- if (!isResolvedFieldReference(resolved)) {
471
- throw new Error("Name operand must resolve to a field reference");
472
- }
473
- const { path, relations } = resolved;
474
- const valueOperand = operands.find(isValueOperand);
475
- if (!valueOperand)
476
- throw new Error("Value operand is undefined");
477
- const resolvedValue = resolveOperand(valueOperand, mapper);
478
- if (!isResolvedValue(resolvedValue)) {
479
- throw new Error("Value operand must resolve to a value");
480
- }
481
- const fieldName = getLeafField(path);
482
- const fieldFilter = {
1559
+ const nameOperand = getNamedOperand(operands, "Name operand is undefined");
1560
+ const valueOperand = getValueOperand(operands, "Value operand is undefined");
1561
+ const fieldRef = requireResolvedFieldReference(resolveOperand(nameOperand, mapper), "Name operand must resolve to a field reference");
1562
+ const resolvedValue = requireResolvedValue(resolveOperand(valueOperand, mapper), "Value operand must resolve to a value");
1563
+ const fieldName = getLeafField(fieldRef.path);
1564
+ return wrapRelations(fieldRef.relations, {
483
1565
  [fieldName]: resolvedValue.value ? { not: null } : { equals: null },
484
- };
485
- if (relations && relations.length > 0) {
486
- return buildNestedRelationFilter(relations, fieldFilter);
487
- }
488
- return fieldFilter;
1566
+ });
489
1567
  }
490
1568
  /**
491
1569
  * Helper function to handle "hasIntersection" operator
@@ -494,6 +1572,10 @@ function handleHasIntersectionOperator(operands, mapper) {
494
1572
  if (operands.length !== 2) {
495
1573
  throw new Error("hasIntersection requires exactly two operands");
496
1574
  }
1575
+ // Intersection is symmetric, and the planner preserves policy source order — e.g.
1576
+ // `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
1577
+ // field/map-first (see #256).
1578
+ ({ operands } = normalizeBinaryOperands("hasIntersection", operands));
497
1579
  const leftOperand = assertDefined(operands[0], "hasIntersection requires a left operand");
498
1580
  const rightOperand = assertDefined(operands[1], "hasIntersection requires a right operand");
499
1581
  // Check if left operand is a map operation
@@ -524,12 +1606,39 @@ function handleHasIntersectionOperator(operands, mapper) {
524
1606
  if (!isNamedOperand(projection)) {
525
1607
  throw new Error("Invalid map lambda expression structure");
526
1608
  }
527
- // Use scoped mapper for resolving the projection
528
- const resolved = resolveFieldReference(projection.name, scopedMapper);
1609
+ // Use scoped mapper for resolving the projection, collecting nullable element columns.
1610
+ const scope = {
1611
+ variableName: variable.name,
1612
+ relationModel: relations[relations.length - 1]?.model,
1613
+ nullableFields: new Set(),
1614
+ unknownFilters: [],
1615
+ };
1616
+ lambdaScopes.push(scope);
1617
+ let resolved;
1618
+ try {
1619
+ resolved = resolveFieldReference(projection.name, scopedMapper);
1620
+ }
1621
+ finally {
1622
+ lambdaScopes.pop();
1623
+ }
529
1624
  const fieldName = getLeafField(resolved.path);
530
- return buildNestedRelationFilter(relations, {
1625
+ const base = buildNestedRelationFilter(relations, {
531
1626
  [fieldName]: { in: rightOperand.value },
532
1627
  });
1628
+ if (scope.nullableFields.size === 0) {
1629
+ return base;
1630
+ }
1631
+ // CEL map() errors if ANY element is missing the projected attribute — even alongside a
1632
+ // matching element — and Cerbos treats that error as deny. Exclude rows holding an
1633
+ // element whose projected (nullable) column is NULL.
1634
+ return {
1635
+ AND: [
1636
+ base,
1637
+ {
1638
+ NOT: buildNestedRelationFilter(relations, buildNullWitnessFilter(scope.nullableFields)),
1639
+ },
1640
+ ],
1641
+ };
533
1642
  }
534
1643
  // Handle regular field reference
535
1644
  if (!isNamedOperand(leftOperand)) {
@@ -543,11 +1652,7 @@ function handleHasIntersectionOperator(operands, mapper) {
543
1652
  throw new Error("hasIntersection requires an array value");
544
1653
  }
545
1654
  if (relations && relations.length > 0) {
546
- const fieldName = getLeafField(path);
547
- const fieldFilter = {
548
- [fieldName]: { in: rightOperand.value },
549
- };
550
- return buildNestedRelationFilter(relations, fieldFilter);
1655
+ return buildMembershipFilter({ path, relations }, rightOperand.value);
551
1656
  }
552
1657
  const fieldName = getLeafField(path);
553
1658
  return { [fieldName]: { some: rightOperand.value } };
@@ -565,10 +1670,7 @@ function handleLambdaOperator(operands) {
565
1670
  field: key.replace(`${variable.name}.`, ""),
566
1671
  }));
567
1672
  }
568
- /**
569
- * Helper function to handle collection operators (exists, all, except, filter)
570
- */
571
- function handleCollectionOperator(operator, operands, mapper) {
1673
+ function buildCollectionLambdaParts(operator, operands, mapper) {
572
1674
  if (operands.length !== 2) {
573
1675
  throw new Error(`${operator} requires exactly two operands`);
574
1676
  }
@@ -591,13 +1693,33 @@ function handleCollectionOperator(operator, operands, mapper) {
591
1693
  if (!relations || relations.length === 0) {
592
1694
  throw new Error(`${operator} operator requires a relation mapping`);
593
1695
  }
1696
+ const head = assertDefined(relations[0], `${operator} operator requires a relation mapping`);
1697
+ const restRelations = relations.slice(1);
1698
+ const deepest = assertDefined(relations[relations.length - 1], `${operator} operator requires a relation mapping`);
594
1699
  const lambdaConditionOperand = assertDefined(lambda.operands[0], "Lambda expression must provide a condition");
595
- const lambdaCondition = buildPrismaFilterFromCerbosExpression(lambdaConditionOperand, // Use the condition part of the lambda
596
- scopedMapper);
597
- const relation = assertDefined(relations[0], `${operator} operator requires a relation mapping`);
1700
+ const scope = {
1701
+ variableName: variable.name,
1702
+ relationModel: deepest.model,
1703
+ nullableFields: new Set(),
1704
+ unknownFilters: [],
1705
+ };
1706
+ lambdaScopes.push(scope);
1707
+ let lambdaCondition;
1708
+ try {
1709
+ lambdaCondition = buildPrismaFilterFromCerbosExpression(lambdaConditionOperand, // Use the condition part of the lambda
1710
+ scopedMapper);
1711
+ }
1712
+ finally {
1713
+ lambdaScopes.pop();
1714
+ }
598
1715
  let filterValue = lambdaCondition;
599
- // If the lambda condition already has a relation structure, merge it
600
- if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
1716
+ if (restRelations.length > 0) {
1717
+ // Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
1718
+ // of the chain, so the element predicate must join through every intermediate hop.
1719
+ filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
1720
+ }
1721
+ else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
1722
+ // If the lambda condition already has a logical structure, use it as-is
601
1723
  filterValue = lambdaCondition;
602
1724
  }
603
1725
  else {
@@ -610,37 +1732,162 @@ function handleCollectionOperator(operator, operands, mapper) {
610
1732
  if (lambdaFieldValue === undefined) {
611
1733
  throw new Error("Lambda condition field value cannot be undefined");
612
1734
  }
613
- const filterField = relation.field || defaultKey;
1735
+ const filterField = head.field || defaultKey;
614
1736
  filterValue = {
615
1737
  [filterField]: lambdaFieldValue,
616
1738
  };
617
1739
  }
1740
+ return {
1741
+ head,
1742
+ restRelations,
1743
+ filterValue,
1744
+ nullableFields: scope.nullableFields,
1745
+ unknownFilters: scope.unknownFilters,
1746
+ };
1747
+ }
1748
+ /** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
1749
+ function buildNullWitnessFilter(nullableFields) {
1750
+ const checks = [...nullableFields].map((field) => ({ [field]: null }));
1751
+ if (checks.length === 1) {
1752
+ return checks[0];
1753
+ }
1754
+ return { OR: checks };
1755
+ }
1756
+ function buildUnknownElementFilter(parts) {
1757
+ const filters = [...parts.unknownFilters];
1758
+ if (parts.nullableFields.size > 0) {
1759
+ filters.push(buildNullWitnessFilter(parts.nullableFields));
1760
+ }
1761
+ if (filters.length === 0) {
1762
+ return undefined;
1763
+ }
1764
+ if (filters.length === 1) {
1765
+ return filters[0];
1766
+ }
1767
+ return { OR: filters };
1768
+ }
1769
+ function recordNestedCollectionUnknownFilter(filter) {
1770
+ const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
1771
+ enclosingScope?.unknownFilters.push(filter);
1772
+ }
1773
+ /** Wraps an element-level filter through the full relation chain (head + rest). */
1774
+ function wrapCollectionElementFilter(parts, elementFilter) {
1775
+ const inner = parts.restRelations.length > 0
1776
+ ? buildNestedRelationFilter(parts.restRelations, elementFilter)
1777
+ : elementFilter;
1778
+ return { [parts.head.name]: { some: inner } };
1779
+ }
1780
+ function throwUnsupportedCollectionOperator(operator) {
1781
+ if (operator === "exists_one") {
1782
+ throw new Error("exists_one requires counting matching elements, which Prisma where-filters cannot express");
1783
+ }
1784
+ throw new Error("The filter() collection operator returns a list, not a boolean. " +
1785
+ "It cannot be used as a standalone condition. " +
1786
+ "Use exists() or combine filter() with size() instead.");
1787
+ }
1788
+ /**
1789
+ * Helper function to handle collection operators (exists, all, except, filter)
1790
+ */
1791
+ function handleCollectionOperator(operator, operands, mapper) {
1792
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(operator, operands, mapper, false);
1793
+ if (knownValueFilter !== undefined) {
1794
+ return knownValueFilter;
1795
+ }
1796
+ if (operator === "exists_one" || operator === "filter") {
1797
+ throwUnsupportedCollectionOperator(operator);
1798
+ }
1799
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
1800
+ const { head, filterValue, nullableFields } = parts;
618
1801
  switch (operator) {
619
- case "exists":
620
- return { [relation.name]: { some: filterValue } };
621
- case "filter":
622
- throw new Error("The filter() collection operator returns a list, not a boolean. " +
623
- "It cannot be used as a standalone condition. " +
624
- "Use exists() or combine filter() with size() instead.");
1802
+ case "exists": {
1803
+ // A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
1804
+ // false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
1805
+ const filter = { [head.name]: { some: filterValue } };
1806
+ const unknownElement = buildUnknownElementFilter(parts);
1807
+ if (unknownElement !== undefined) {
1808
+ recordNestedCollectionUnknownFilter({
1809
+ AND: [
1810
+ { NOT: filter },
1811
+ wrapCollectionElementFilter(parts, unknownElement),
1812
+ ],
1813
+ });
1814
+ }
1815
+ return filter;
1816
+ }
625
1817
  case "except":
626
- return { [relation.name]: { some: { NOT: filterValue } } };
627
- case "exists_one":
1818
+ return { [head.name]: { some: { NOT: filterValue } } };
1819
+ case "all": {
1820
+ if (parts.restRelations.length > 0) {
1821
+ throw new Error("all() over a multi-hop relation chain is not supported");
1822
+ }
1823
+ const base = { [head.name]: { every: filterValue } };
1824
+ if (nullableFields.size === 0) {
1825
+ return base;
1826
+ }
1827
+ // CEL all() errors when any element evaluation errors without a false witness; SQL
1828
+ // `every` would treat those elements as vacuously passing. Exclude rows holding any
1829
+ // element whose referenced nullable column is NULL. (`every` already rejects rows
1830
+ // with a definitive false witness, matching error absorption.)
1831
+ return {
1832
+ AND: [
1833
+ base,
1834
+ { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
1835
+ ],
1836
+ };
1837
+ }
1838
+ default:
1839
+ throw new Error(`Unexpected operator: ${operator}`);
1840
+ }
1841
+ }
1842
+ /**
1843
+ * Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
1844
+ * buildNegatedFilter).
1845
+ */
1846
+ function buildNegatedCollectionFilter(operator, operands, mapper) {
1847
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(operator, operands, mapper, true);
1848
+ if (knownValueFilter !== undefined) {
1849
+ return knownValueFilter;
1850
+ }
1851
+ if (operator === "exists_one" || operator === "filter") {
1852
+ throwUnsupportedCollectionOperator(operator);
1853
+ }
1854
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
1855
+ const { head, filterValue, nullableFields } = parts;
1856
+ switch (operator) {
1857
+ case "exists": {
1858
+ const base = { NOT: { [head.name]: { some: filterValue } } };
1859
+ const unknownElement = buildUnknownElementFilter(parts);
1860
+ if (unknownElement === undefined) {
1861
+ return base;
1862
+ }
1863
+ // !exists is TRUE only when every element is definitively false: no P-match AND no
1864
+ // element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
628
1865
  return {
629
- [relation.name]: {
630
- some: filterValue,
631
- },
632
1866
  AND: [
1867
+ base,
633
1868
  {
634
- [relation.name]: {
635
- every: {
636
- OR: [filterValue, { NOT: filterValue }],
637
- },
638
- },
1869
+ NOT: wrapCollectionElementFilter(parts, unknownElement),
639
1870
  },
640
1871
  ],
641
1872
  };
1873
+ }
642
1874
  case "all":
643
- return { [relation.name]: { every: filterValue } };
1875
+ // !all is TRUE only with a definitive false witness, which also absorbs error
1876
+ // elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
1877
+ return { [head.name]: { some: { NOT: filterValue } } };
1878
+ case "except": {
1879
+ // !except(c,P) = every element definitively matches P.
1880
+ const base = { [head.name]: { none: { NOT: filterValue } } };
1881
+ if (nullableFields.size === 0) {
1882
+ return base;
1883
+ }
1884
+ return {
1885
+ AND: [
1886
+ base,
1887
+ { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
1888
+ ],
1889
+ };
1890
+ }
644
1891
  default:
645
1892
  throw new Error(`Unexpected operator: ${operator}`);
646
1893
  }
@@ -682,4 +1929,399 @@ function handleMapOperator(operands, mapper) {
682
1929
  },
683
1930
  });
684
1931
  }
1932
+ function buildImpossibleFilter(fieldRef) {
1933
+ return buildFieldFilter(fieldRef, "in", []);
1934
+ }
1935
+ const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
1936
+ /**
1937
+ * Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
1938
+ * column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
1939
+ * one constant side always rewrites to a plain comparison. Multiplying/dividing by a
1940
+ * negative constant mirrors directional operators. The rewrite happens in IEEE double space,
1941
+ * matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
1942
+ * (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
1943
+ * exactly as CEL evaluates it).
1944
+ */
1945
+ function handleArithmeticComparison(operator, arithExpr, otherOperand, arithIsLeft, mapper) {
1946
+ const arithOp = arithExpr.operator;
1947
+ const arithLeftOp = assertDefined(arithExpr.operands[0], `${arithOp} operator requires a left operand`);
1948
+ const arithRightOp = assertDefined(arithExpr.operands[1], `${arithOp} operator requires a right operand`);
1949
+ if (isOperatorOperand(otherOperand) &&
1950
+ ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
1951
+ tryFoldValueExpression(otherOperand, mapper) === null) {
1952
+ throw new Error("Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter");
1953
+ }
1954
+ const arithLeft = resolveOperand(arithLeftOp, mapper);
1955
+ const arithRight = resolveOperand(arithRightOp, mapper);
1956
+ const other = resolveOperand(otherOperand, mapper);
1957
+ // Fully constant arithmetic: fold and compare the other side against the result. The
1958
+ // arithmetic side keeps its source position, so when the folded constant was written
1959
+ // FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
1960
+ if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
1961
+ const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
1962
+ if (!isResolvedFieldReference(other)) {
1963
+ throw new Error(`${arithOp} with two values requires a field reference on the other side`);
1964
+ }
1965
+ const effectiveOperator = arithIsLeft
1966
+ ? (MIRRORED_OPERATOR[operator] ?? operator)
1967
+ : operator;
1968
+ return buildComparisonFilter(other, effectiveOperator, folded);
1969
+ }
1970
+ if (!isResolvedValue(other)) {
1971
+ throw new Error(`${arithOp} operator with field references requires a value on the other side of the comparison`);
1972
+ }
1973
+ let fieldRef;
1974
+ let constant;
1975
+ let fieldIsLeft;
1976
+ if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
1977
+ fieldRef = arithLeft;
1978
+ constant = arithRight.value;
1979
+ fieldIsLeft = true;
1980
+ }
1981
+ else if (isResolvedValue(arithLeft) &&
1982
+ isResolvedFieldReference(arithRight)) {
1983
+ fieldRef = arithRight;
1984
+ constant = arithLeft.value;
1985
+ fieldIsLeft = false;
1986
+ }
1987
+ else {
1988
+ throw new Error(`${arithOp} operator requires exactly one field reference and one value, or two values`);
1989
+ }
1990
+ // The comparison operator was normalized arithmetic-side-first by the caller; if the
1991
+ // arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
1992
+ let effectiveOperator = arithIsLeft
1993
+ ? operator
1994
+ : (MIRRORED_OPERATOR[operator] ?? operator);
1995
+ // String concatenation solving (eq/ne only).
1996
+ if (arithOp === "add" && typeof constant === "string") {
1997
+ if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
1998
+ throw new Error(`Operator ${effectiveOperator} is not supported with string concatenation`);
1999
+ }
2000
+ const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
2001
+ if (solvedValue === null) {
2002
+ if (effectiveOperator === "eq")
2003
+ return buildImpossibleFilter(fieldRef);
2004
+ return {};
2005
+ }
2006
+ return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
2007
+ }
2008
+ if (typeof constant !== "number" || typeof other.value !== "number") {
2009
+ throw new Error(`${arithOp} comparison requires numeric operands`);
2010
+ }
2011
+ if (arithOp === "add" &&
2012
+ (effectiveOperator === "eq" || effectiveOperator === "ne") &&
2013
+ (!Number.isInteger(constant) || !Number.isInteger(other.value))) {
2014
+ throw new Error("Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible");
2015
+ }
2016
+ let solved;
2017
+ switch (arithOp) {
2018
+ case "add":
2019
+ solved = other.value - constant;
2020
+ break;
2021
+ case "sub":
2022
+ if (fieldIsLeft) {
2023
+ // f - c CMP v ⇔ f CMP v + c
2024
+ solved = other.value + constant;
2025
+ }
2026
+ else {
2027
+ // c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
2028
+ solved = constant - other.value;
2029
+ effectiveOperator =
2030
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2031
+ }
2032
+ break;
2033
+ case "mult":
2034
+ if (constant === 0) {
2035
+ throw new Error("Multiplication by a constant zero must be folded by the Cerbos planner");
2036
+ }
2037
+ solved = other.value / constant;
2038
+ if (constant < 0) {
2039
+ effectiveOperator =
2040
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2041
+ }
2042
+ break;
2043
+ case "div":
2044
+ if (!fieldIsLeft) {
2045
+ throw new Error("Division by a column is not supported: the comparison cannot be solved to a plain column filter");
2046
+ }
2047
+ if (constant === 0) {
2048
+ throw new Error("Division by a constant zero is not supported");
2049
+ }
2050
+ solved = other.value * constant;
2051
+ if (constant < 0) {
2052
+ effectiveOperator =
2053
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2054
+ }
2055
+ break;
2056
+ default:
2057
+ throw new Error(`Unsupported operator: ${arithOp}`);
2058
+ }
2059
+ return buildComparisonFilter(fieldRef, effectiveOperator, solved);
2060
+ }
2061
+ function foldArithmetic(operator, left, right) {
2062
+ if (operator === "add" &&
2063
+ (typeof left === "string" || typeof right === "string")) {
2064
+ return String(left) + String(right);
2065
+ }
2066
+ if (typeof left !== "number" || typeof right !== "number") {
2067
+ throw new Error(`${operator} operator requires string or number operands`);
2068
+ }
2069
+ switch (operator) {
2070
+ case "add":
2071
+ return left + right;
2072
+ case "sub":
2073
+ return left - right;
2074
+ case "mult":
2075
+ return left * right;
2076
+ case "div":
2077
+ return left / right;
2078
+ default:
2079
+ throw new Error(`Unsupported operator: ${operator}`);
2080
+ }
2081
+ }
2082
+ function solveAdd(comparisonValue, addConstant, fieldIsLeft) {
2083
+ if (typeof comparisonValue === "string" && typeof addConstant === "string") {
2084
+ if (fieldIsLeft) {
2085
+ if (!comparisonValue.endsWith(addConstant))
2086
+ return null;
2087
+ return comparisonValue.slice(0, comparisonValue.length - addConstant.length);
2088
+ }
2089
+ if (!comparisonValue.startsWith(addConstant))
2090
+ return null;
2091
+ return comparisonValue.slice(addConstant.length);
2092
+ }
2093
+ if (typeof comparisonValue === "number" && typeof addConstant === "number") {
2094
+ return comparisonValue - addConstant;
2095
+ }
2096
+ throw new Error("Type mismatch in add comparison");
2097
+ }
2098
+ function resolveHierarchy(expr, mapper) {
2099
+ const operands = expr.operands;
2100
+ if (operands.length === 2) {
2101
+ const strOperand = assertDefined(operands[0], "hierarchy requires operands");
2102
+ const delimOperand = assertDefined(operands[1], "hierarchy requires a delimiter");
2103
+ if (!isValueOperand(delimOperand)) {
2104
+ throw new Error("hierarchy delimiter must be a value");
2105
+ }
2106
+ const delimiter = String(delimOperand.value);
2107
+ if (isValueOperand(strOperand)) {
2108
+ const raw = String(strOperand.value);
2109
+ return { type: "constant", segments: raw.split(delimiter), raw, delimiter };
2110
+ }
2111
+ if (isNamedOperand(strOperand)) {
2112
+ return {
2113
+ type: "field",
2114
+ fieldRef: resolveFieldReference(strOperand.name, mapper),
2115
+ delimiter,
2116
+ };
2117
+ }
2118
+ throw new Error("hierarchy(string, delimiter) requires a value or field operand");
2119
+ }
2120
+ if (operands.length === 1) {
2121
+ const inner = assertDefined(operands[0], "hierarchy requires an operand");
2122
+ if (isValueOperand(inner)) {
2123
+ const raw = String(inner.value);
2124
+ return { type: "constant", segments: raw.split("."), raw, delimiter: "." };
2125
+ }
2126
+ if (isNamedOperand(inner)) {
2127
+ return {
2128
+ type: "field",
2129
+ fieldRef: resolveFieldReference(inner.name, mapper),
2130
+ delimiter: ".",
2131
+ };
2132
+ }
2133
+ if (isOperatorOperand(inner) && inner.operator === "list") {
2134
+ const segments = inner.operands.map((op) => {
2135
+ const resolved = resolveOperand(op, mapper);
2136
+ if (isResolvedValue(resolved)) {
2137
+ return { type: "constant", value: String(resolved.value) };
2138
+ }
2139
+ return { type: "field", fieldRef: resolved };
2140
+ });
2141
+ return { type: "segmented", segments };
2142
+ }
2143
+ throw new Error("hierarchy requires a value, field, or list operand");
2144
+ }
2145
+ throw new Error("hierarchy requires 1 or 2 operands");
2146
+ }
2147
+ function toSegments(resolved) {
2148
+ switch (resolved.type) {
2149
+ case "constant":
2150
+ return resolved.segments.map((s) => ({ type: "constant", value: s }));
2151
+ case "segmented":
2152
+ return resolved.segments;
2153
+ case "field":
2154
+ throw new Error("Cannot get segments from a field-reference hierarchy");
2155
+ }
2156
+ }
2157
+ function normalizeHierarchy(h, defaultDelimiter = ".") {
2158
+ if (h.type !== "segmented")
2159
+ return h;
2160
+ const allConstant = h.segments.every((s) => s.type === "constant");
2161
+ if (!allConstant)
2162
+ return h;
2163
+ const segments = h.segments.map((s) => s.value);
2164
+ return {
2165
+ type: "constant",
2166
+ segments,
2167
+ raw: segments.join(defaultDelimiter),
2168
+ delimiter: defaultDelimiter,
2169
+ };
2170
+ }
2171
+ function checkPrefixConditions(shorter, longer) {
2172
+ if (shorter.length > longer.length)
2173
+ return null;
2174
+ const conditions = [];
2175
+ for (let i = 0; i < shorter.length; i++) {
2176
+ const s = shorter[i];
2177
+ const l = longer[i];
2178
+ if (s.type === "constant" && l.type === "constant") {
2179
+ if (s.value !== l.value)
2180
+ return null;
2181
+ }
2182
+ else if (s.type === "field" && l.type === "constant") {
2183
+ conditions.push(buildFieldFilter(s.fieldRef, "equals", l.value));
2184
+ }
2185
+ else if (s.type === "constant" && l.type === "field") {
2186
+ conditions.push(buildFieldFilter(l.fieldRef, "equals", s.value));
2187
+ }
2188
+ else {
2189
+ throw new Error("Cannot compare two field references in hierarchy overlap");
2190
+ }
2191
+ }
2192
+ if (conditions.length === 0)
2193
+ return {};
2194
+ if (conditions.length === 1)
2195
+ return conditions[0];
2196
+ return { AND: conditions };
2197
+ }
2198
+ function handleOverlapsOperator(operands, mapper) {
2199
+ const [left, right] = extractHierarchyOperands("overlaps", operands, mapper);
2200
+ if (left.type === "field" || right.type === "field") {
2201
+ return handleFieldOverlaps(left, right);
2202
+ }
2203
+ const leftSegs = toSegments(left);
2204
+ const rightSegs = toSegments(right);
2205
+ const leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs);
2206
+ const rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs);
2207
+ const validConditions = [leftPrefixOfRight, rightPrefixOfLeft].filter((c) => c !== null);
2208
+ if (validConditions.length === 0) {
2209
+ const allSegs = [...leftSegs, ...rightSegs];
2210
+ const fieldSeg = allSegs.find((s) => s.type === "field");
2211
+ if (fieldSeg)
2212
+ return buildImpossibleFilter(fieldSeg.fieldRef);
2213
+ throw new Error("Cannot determine overlap: no field references found");
2214
+ }
2215
+ if (validConditions.some((c) => Object.keys(c).length === 0))
2216
+ return {};
2217
+ // When both directions are valid (equal-length hierarchies), they produce
2218
+ // identical conditions since the same segment pairs are compared in both.
2219
+ return validConditions[0];
2220
+ }
2221
+ /**
2222
+ * Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
2223
+ *
2224
+ * `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
2225
+ * match as wildcards instead of literally and widen the filter past what the PDP allows.
2226
+ * `[` is unsafe for a separate reason: SQL Server opens a character class on `[` even when
2227
+ * an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly rather
2228
+ * than emit a filter that admits denied rows.
2229
+ */
2230
+ function assertLikeSafePrefix(prefix) {
2231
+ if (/[%_\[]/.test(prefix)) {
2232
+ throw new Error("Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
2233
+ "Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one");
2234
+ }
2235
+ }
2236
+ function handleFieldOverlaps(left, right) {
2237
+ if (left.type === "field" && right.type === "field") {
2238
+ throw new Error("overlaps: cannot compare two field-reference hierarchies");
2239
+ }
2240
+ const field = (left.type === "field" ? left : right);
2241
+ const other = left.type === "field" ? right : left;
2242
+ if (other.type !== "constant") {
2243
+ throw new Error("overlaps: segmented hierarchies with field hierarchies are not supported");
2244
+ }
2245
+ const delimiter = field.delimiter;
2246
+ const otherRaw = other.segments.join(delimiter);
2247
+ const strictPrefixes = getStrictPrefixes(other.segments, delimiter);
2248
+ const conditions = [];
2249
+ if (strictPrefixes.length > 0) {
2250
+ conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
2251
+ }
2252
+ conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
2253
+ const prefix = otherRaw + delimiter;
2254
+ assertLikeSafePrefix(prefix);
2255
+ conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
2256
+ if (conditions.length === 1)
2257
+ return conditions[0];
2258
+ return { OR: conditions };
2259
+ }
2260
+ function extractHierarchyOperands(operatorName, operands, mapper) {
2261
+ if (operands.length !== 2) {
2262
+ throw new Error(`${operatorName} requires exactly two operands`);
2263
+ }
2264
+ const leftOp = assertDefined(operands[0], `${operatorName} requires a left operand`);
2265
+ const rightOp = assertDefined(operands[1], `${operatorName} requires a right operand`);
2266
+ if (!isOperatorOperand(leftOp) || leftOp.operator !== "hierarchy" ||
2267
+ !isOperatorOperand(rightOp) || rightOp.operator !== "hierarchy") {
2268
+ throw new Error(`${operatorName} requires two hierarchy operands`);
2269
+ }
2270
+ return [
2271
+ normalizeHierarchy(resolveHierarchy(leftOp, mapper)),
2272
+ normalizeHierarchy(resolveHierarchy(rightOp, mapper)),
2273
+ ];
2274
+ }
2275
+ function getStrictPrefixes(segments, delimiter) {
2276
+ if (segments.length <= 1)
2277
+ return [];
2278
+ const prefixes = [];
2279
+ let current = segments[0];
2280
+ prefixes.push(current);
2281
+ for (let i = 1; i < segments.length - 1; i++) {
2282
+ current = current + delimiter + segments[i];
2283
+ prefixes.push(current);
2284
+ }
2285
+ return prefixes;
2286
+ }
2287
+ function handleAncestorDescendantOperator(operands, mapper, direction) {
2288
+ const operatorName = direction === "ancestor" ? "ancestorOf" : "descendentOf";
2289
+ const [left, right] = extractHierarchyOperands(operatorName, operands, mapper);
2290
+ // ancestorOf(A, B) = A is strict prefix of B
2291
+ // descendentOf(A, B) = B is strict prefix of A
2292
+ const ancestor = direction === "ancestor" ? left : right;
2293
+ const descendant = direction === "ancestor" ? right : left;
2294
+ if (ancestor.type === "constant" && descendant.type === "field") {
2295
+ const prefix = ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
2296
+ assertLikeSafePrefix(prefix);
2297
+ return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
2298
+ }
2299
+ if (ancestor.type === "field" && descendant.type === "constant") {
2300
+ const delimiter = ancestor.delimiter;
2301
+ const prefixes = getStrictPrefixes(descendant.segments, delimiter);
2302
+ if (prefixes.length === 0) {
2303
+ return buildImpossibleFilter(ancestor.fieldRef);
2304
+ }
2305
+ if (prefixes.length === 1) {
2306
+ return buildFieldFilter(ancestor.fieldRef, "equals", prefixes[0]);
2307
+ }
2308
+ return buildFieldFilter(ancestor.fieldRef, "in", prefixes);
2309
+ }
2310
+ if (ancestor.type === "constant" && descendant.type === "constant") {
2311
+ const ancestorSegs = ancestor.segments;
2312
+ const descendantSegs = descendant.segments;
2313
+ if (descendantSegs.length > ancestorSegs.length &&
2314
+ ancestorSegs.every((seg, i) => seg === descendantSegs[i])) {
2315
+ return {};
2316
+ }
2317
+ throw new Error(`${operatorName}: constants do not satisfy ${direction} relationship`);
2318
+ }
2319
+ throw new Error(`${operatorName}: unsupported hierarchy type combination`);
2320
+ }
2321
+ function buildFieldFilter(fieldRef, prismaOp, value) {
2322
+ const fieldName = getLeafField(fieldRef.path);
2323
+ return wrapRelations(fieldRef.relations, {
2324
+ [fieldName]: { [prismaOp]: value },
2325
+ });
2326
+ }
685
2327
  //# sourceMappingURL=index.js.map