@cerbos/orm-prisma 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +243 -10
  2. package/lib/index.d.ts +102 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1754 -155
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -15
  7. package/src/adversarial.test.ts +1281 -0
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +54 -0
  10. package/src/generated/prisma-adversarial/client.ts +76 -0
  11. package/src/generated/{prisma → prisma-adversarial}/commonInputTypes.ts +131 -27
  12. package/src/generated/prisma-adversarial/enums.ts +15 -0
  13. package/src/generated/prisma-adversarial/internal/class.ts +264 -0
  14. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1332 -0
  15. package/src/generated/{prisma → prisma-adversarial}/internal/prismaNamespaceBrowser.ts +45 -47
  16. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  17. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  18. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  19. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  20. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1789 -0
  21. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  22. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  23. package/src/generated/prisma-adversarial/models.ts +18 -0
  24. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  25. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  26. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +356 -0
  27. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  28. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1371 -0
  29. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +177 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  31. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  32. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  33. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  34. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  35. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  36. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  37. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  38. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  39. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  40. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  41. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  42. package/src/generated/prisma-adversarial-v6/index.d.ts +11769 -0
  43. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  44. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  45. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  46. package/src/index.ts +2515 -219
  47. package/src/test-setup.adversarial-url.ts +18 -0
  48. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  49. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  50. package/src/test-setup.adversarial.v6.ts +5 -0
  51. package/src/test-setup.adversarial.v7.ts +12 -0
  52. package/src/translator.test.ts +1586 -0
  53. package/src/generated/prisma/internal/class.ts +0 -274
  54. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1333
  55. package/src/generated/prisma/models/Category.ts +0 -1412
  56. package/src/generated/prisma/models/Label.ts +0 -1276
  57. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  58. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  59. package/src/generated/prisma/models/Resource.ts +0 -2136
  60. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  61. package/src/generated/prisma/models/Tag.ts +0 -1276
  62. package/src/generated/prisma/models/User.ts +0 -1503
  63. package/src/generated/prisma/models.ts +0 -19
  64. package/src/generated/prisma-v6/index.d.ts +0 -13639
  65. package/src/index.test.ts +0 -6322
  66. package/src/test-setup.v6.ts +0 -5
  67. package/src/test-setup.v7.ts +0 -7
  68. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  69. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  70. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  71. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  72. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  73. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  74. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -0
package/lib/index.js CHANGED
@@ -12,6 +12,71 @@ const CERBOS_TO_PRISMA_OPERATOR = {
12
12
  gt: "gt",
13
13
  ge: "gte",
14
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
+ }
15
80
  function isNamedOperand(operand) {
16
81
  return "name" in operand && typeof operand.name === "string";
17
82
  }
@@ -30,6 +95,137 @@ function assertDefined(value, message) {
30
95
  }
31
96
  return value;
32
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
+ }
33
229
  function getLeafField(path) {
34
230
  const fieldName = path[path.length - 1];
35
231
  if (!fieldName) {
@@ -44,32 +240,16 @@ function getFilterEntry(filter) {
44
240
  }
45
241
  return entry;
46
242
  }
243
+ /** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
244
+ function isExplicitNullReference(fieldRef) {
245
+ return fieldRef.nullAttributeRepresentation === "explicit";
246
+ }
47
247
  function isResolvedFieldReference(operand) {
48
248
  return "path" in operand;
49
249
  }
50
250
  function isResolvedValue(operand) {
51
251
  return "value" in operand;
52
252
  }
53
- function getNamedOperand(operands, message) {
54
- const operand = operands.find(isNamedOperand);
55
- if (!operand) {
56
- throw new Error(message);
57
- }
58
- return operand;
59
- }
60
- function getValueOperand(operands, message) {
61
- const operand = operands.find(isValueOperand);
62
- if (!operand) {
63
- throw new Error(message);
64
- }
65
- return operand;
66
- }
67
- function requireResolvedFieldReference(operand, message) {
68
- if (!isResolvedFieldReference(operand)) {
69
- throw new Error(message);
70
- }
71
- return operand;
72
- }
73
253
  function requireResolvedValue(operand, message) {
74
254
  if (!isResolvedValue(operand)) {
75
255
  throw new Error(message);
@@ -86,31 +266,427 @@ function buildFieldEqualsFilter(fieldRef, value) {
86
266
  const fieldName = getLeafField(fieldRef.path);
87
267
  return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
88
268
  }
89
- function buildFieldDirectOrInFilter(fieldRef, values) {
269
+ function buildDirectOrInLeaf(fieldRef, values) {
90
270
  const fieldName = getLeafField(fieldRef.path);
91
- const baseFilter = values.length === 1
271
+ return values.length === 1
92
272
  ? { [fieldName]: values[0] }
93
273
  : { [fieldName]: { in: values } };
94
- return wrapRelations(fieldRef.relations, baseFilter);
274
+ }
275
+ function buildFieldDirectOrInFilter(fieldRef, values) {
276
+ return wrapRelations(fieldRef.relations, buildDirectOrInLeaf(fieldRef, values));
277
+ }
278
+ /**
279
+ * `field IS NOT NULL AND <leaf>`, so a NULL column makes the whole thing definitely FALSE rather
280
+ * than merely unmatched — which is what lets an enclosing `NOT` include the row.
281
+ *
282
+ * Prisma has no three-valued logic to expose: a record either matches a `where` or it does not.
283
+ * That is exactly why the guard has to be explicit — `{ NOT: { field: { equals: "x" } } }` leans on
284
+ * the generated SQL's UNKNOWN, which excludes the record under both polarities, and an
285
+ * explicit-null attribute needs the negation to include it.
286
+ *
287
+ * The guard is ANDed with the leaf BEFORE the relation wrapping, so both halves are evaluated
288
+ * against the same element rather than against two independently-matched ones.
289
+ */
290
+ function guardedFieldFilter(fieldRef, leaf) {
291
+ const fieldName = getLeafField(fieldRef.path);
292
+ return wrapRelations(fieldRef.relations, {
293
+ AND: [{ [fieldName]: { not: null } }, leaf],
294
+ });
295
+ }
296
+ function buildMembershipFilter(fieldRef, values) {
297
+ const nonNullValues = values.filter((value) => value !== null);
298
+ const carriesNull = nonNullValues.length !== values.length;
299
+ if (carriesNull) {
300
+ assertNullOperandTranslatable("a null element in an `in` list", fieldRef.nullAttributeRepresentation);
301
+ }
302
+ const filters = [];
303
+ if (nonNullValues.length > 0 || values.length === 0) {
304
+ // Without a null element nothing has made the membership definite yet: a NULL column is
305
+ // dropped by `{ in: [...] }` under BOTH polarities, while CEL compares a null VALUE against
306
+ // each element and gets a definite false, so its negation is TRUE. With a null element the
307
+ // `[null]` disjunct below already settles it.
308
+ filters.push(carriesNull || !isExplicitNullReference(fieldRef)
309
+ ? buildFieldDirectOrInFilter(fieldRef, nonNullValues)
310
+ : guardedFieldFilter(fieldRef, buildDirectOrInLeaf(fieldRef, nonNullValues)));
311
+ }
312
+ if (carriesNull) {
313
+ filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
314
+ }
315
+ if (filters.length === 1) {
316
+ return assertDefined(filters[0], "Membership filter is missing");
317
+ }
318
+ return { OR: filters };
319
+ }
320
+ function buildAlwaysTrueFilter() {
321
+ return {};
322
+ }
323
+ let lambdaScopes = [];
324
+ let rootModelName;
325
+ let nullRepresentation = "explicit";
326
+ /**
327
+ * Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
328
+ * operand.
329
+ *
330
+ * Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
331
+ * missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
332
+ * the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
333
+ * null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
334
+ * than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
335
+ * `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
336
+ * under any nesting; narrowing it requires negation-parity tracking.
337
+ */
338
+ function assertNullOperandTranslatable(context, declared) {
339
+ if ((declared ?? nullRepresentation) === "omitted") {
340
+ throw new Error(`Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
341
+ "sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
342
+ "(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
343
+ 'explicit nulls and use "explicit", or keep this shape out of the policy.');
344
+ }
95
345
  }
96
346
  /**
97
347
  * Converts a Cerbos query plan to a Prisma filter.
98
348
  */
99
- function queryPlanToPrisma({ queryPlan, mapper = {}, }) {
349
+ function queryPlanToPrisma({ queryPlan, mapper = {}, model, nullAttributeRepresentation = "explicit", }) {
350
+ nullRepresentation = nullAttributeRepresentation;
100
351
  switch (queryPlan.kind) {
101
352
  case core_1.PlanKind.ALWAYS_ALLOWED:
102
353
  return { kind: core_1.PlanKind.ALWAYS_ALLOWED };
103
354
  case core_1.PlanKind.ALWAYS_DENIED:
104
355
  return { kind: core_1.PlanKind.ALWAYS_DENIED };
105
- case core_1.PlanKind.CONDITIONAL:
356
+ case core_1.PlanKind.CONDITIONAL: {
357
+ lambdaScopes = [];
358
+ rootModelName = model;
359
+ const condition = constantFoldExpression(hoistOuterScopeReferences(queryPlan.condition, []));
360
+ if (isValueOperand(condition)) {
361
+ // Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
362
+ // they reach the adapter; this can only surface with hand-crafted plans.
363
+ if (condition.value === true) {
364
+ return { kind: core_1.PlanKind.CONDITIONAL, filters: {} };
365
+ }
366
+ // Prisma has no model-agnostic always-false `where` shape: empty logical arrays
367
+ // are ignored.
368
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
369
+ }
370
+ // A `map()` at the ROOT of the condition returns a list, not a boolean. Translating it
371
+ // produces a projection filter that is not a valid Prisma `where` clause — the shape
372
+ // must be refused HERE rather than left for findMany to reject at query time, because
373
+ // a projection that happened to coerce would be a silently-wrong filter. (`filter()`
374
+ // as a condition is already rejected inside handleCollectionOperator with its own
375
+ // message; nested inside a comparison or size() both remain translatable.)
376
+ if (isOperatorOperand(condition) && condition.operator === "map") {
377
+ throw new Error("map() returns a list, not a boolean, so it cannot be a condition on its own; " +
378
+ "only comparisons or size() over its result have a boolean meaning");
379
+ }
106
380
  return {
107
381
  kind: core_1.PlanKind.CONDITIONAL,
108
- filters: buildPrismaFilterFromCerbosExpression(queryPlan.condition, mapper),
382
+ filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
109
383
  };
384
+ }
110
385
  default:
111
386
  throw Error(`Invalid query plan.`);
112
387
  }
113
388
  }
389
+ const COLLECTION_OPERATORS = new Set([
390
+ "exists",
391
+ "exists_one",
392
+ "all",
393
+ "except",
394
+ "filter",
395
+ ]);
396
+ function mentionsVariable(expr, variableName) {
397
+ if (isNamedOperand(expr)) {
398
+ return (expr.name === variableName || expr.name.startsWith(variableName + "."));
399
+ }
400
+ if (isOperatorOperand(expr)) {
401
+ return expr.operands.some((operand) => mentionsVariable(operand, variableName));
402
+ }
403
+ return false;
404
+ }
405
+ function isOuterScopeName(name, enclosingVariables) {
406
+ return !enclosingVariables.some((variable) => name === variable || name.startsWith(variable + "."));
407
+ }
408
+ /** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
409
+ function substituteNamedOperand(expr, name, value) {
410
+ if (isNamedOperand(expr)) {
411
+ return expr.name === name ? { value } : expr;
412
+ }
413
+ if (isOperatorOperand(expr)) {
414
+ return {
415
+ operator: expr.operator,
416
+ operands: expr.operands.map((operand) => substituteNamedOperand(operand, name, value)),
417
+ };
418
+ }
419
+ return expr;
420
+ }
421
+ /**
422
+ * Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
423
+ * boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
424
+ * the body itself.
425
+ */
426
+ function findOuterBooleanReference(expr, enclosingVariables) {
427
+ if (isNamedOperand(expr)) {
428
+ return isOuterScopeName(expr.name, enclosingVariables)
429
+ ? expr.name
430
+ : undefined;
431
+ }
432
+ if (!isOperatorOperand(expr)) {
433
+ return undefined;
434
+ }
435
+ const booleanPositions = [];
436
+ switch (expr.operator) {
437
+ case "and":
438
+ case "or":
439
+ case "not":
440
+ booleanPositions.push(...expr.operands);
441
+ break;
442
+ case "if":
443
+ booleanPositions.push(...expr.operands);
444
+ break;
445
+ default:
446
+ // Comparisons wrapping a ternary keep the outer reference in the ternary condition.
447
+ booleanPositions.push(...expr.operands.filter((operand) => isOperatorOperand(operand) &&
448
+ (operand.operator === "if" ||
449
+ operand.operator === "and" ||
450
+ operand.operator === "or" ||
451
+ operand.operator === "not")));
452
+ break;
453
+ }
454
+ for (const operand of booleanPositions) {
455
+ const found = findOuterBooleanReference(operand, enclosingVariables);
456
+ if (found !== undefined) {
457
+ return found;
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ /**
463
+ * Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
464
+ * outer lambda's element) so that every filter lands on the model it belongs to. Without this,
465
+ * an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
466
+ * tag model. Two sound transforms, applied bottom-up:
467
+ *
468
+ * - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
469
+ * `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
470
+ * OR), including the empty-collection case (both sides are FALSE).
471
+ * - Case split (any collection op): a bare outer boolean reference Q in a boolean position
472
+ * becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
473
+ * keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
474
+ * guarded-ternary encoding.
475
+ */
476
+ function hoistOuterScopeReferences(expr, enclosingVariables) {
477
+ if (!isOperatorOperand(expr)) {
478
+ return expr;
479
+ }
480
+ if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
481
+ const [collection, lambda] = expr.operands;
482
+ if (collection !== undefined &&
483
+ lambda !== undefined &&
484
+ isOperatorOperand(lambda) &&
485
+ lambda.operator === "lambda" &&
486
+ lambda.operands.length === 2 &&
487
+ lambda.operands[1] !== undefined &&
488
+ isNamedOperand(lambda.operands[1])) {
489
+ const variable = lambda.operands[1];
490
+ const body = hoistOuterScopeReferences(assertDefined(lambda.operands[0], "Lambda requires a condition"), [...enclosingVariables, variable.name]);
491
+ const rebuild = (newBody) => ({
492
+ operator: expr.operator,
493
+ operands: [
494
+ collection,
495
+ { operator: "lambda", operands: [newBody, variable] },
496
+ ],
497
+ });
498
+ // Conjunct hoisting out of exists().
499
+ if (expr.operator === "exists" &&
500
+ isOperatorOperand(body) &&
501
+ body.operator === "and") {
502
+ const elementConjuncts = body.operands.filter((operand) => mentionsVariable(operand, variable.name));
503
+ const outerConjuncts = body.operands.filter((operand) => !mentionsVariable(operand, variable.name));
504
+ if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
505
+ const innerBody = elementConjuncts.length === 1
506
+ ? elementConjuncts[0]
507
+ : { operator: "and", operands: elementConjuncts };
508
+ return {
509
+ operator: "and",
510
+ operands: [rebuild(innerBody), ...outerConjuncts],
511
+ };
512
+ }
513
+ }
514
+ // Case split on a bare outer boolean reference.
515
+ const outerRef = findOuterBooleanReference(body, [
516
+ ...enclosingVariables,
517
+ variable.name,
518
+ ]);
519
+ if (outerRef !== undefined) {
520
+ const q = { name: outerRef };
521
+ const notQ = { operator: "not", operands: [q] };
522
+ return {
523
+ operator: "or",
524
+ operands: [
525
+ {
526
+ operator: "and",
527
+ operands: [
528
+ q,
529
+ rebuild(substituteNamedOperand(body, outerRef, true)),
530
+ ],
531
+ },
532
+ {
533
+ operator: "and",
534
+ operands: [
535
+ notQ,
536
+ rebuild(substituteNamedOperand(body, outerRef, false)),
537
+ ],
538
+ },
539
+ // Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
540
+ { operator: "and", operands: [q, notQ] },
541
+ ],
542
+ };
543
+ }
544
+ return rebuild(body);
545
+ }
546
+ }
547
+ return {
548
+ operator: expr.operator,
549
+ operands: expr.operands.map((operand) => hoistOuterScopeReferences(operand, enclosingVariables)),
550
+ };
551
+ }
552
+ const FOLDABLE_COMPARISON_OPERATORS = new Set([
553
+ "eq",
554
+ "ne",
555
+ "lt",
556
+ "le",
557
+ "gt",
558
+ "ge",
559
+ ]);
560
+ /**
561
+ * Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
562
+ * produce need folding (constant ternary conditions, comparisons between two constants,
563
+ * logical operators with constant operands, collection macros with constant bodies); real
564
+ * planner output arrives pre-folded.
565
+ */
566
+ function constantFoldExpression(expr) {
567
+ if (!isOperatorOperand(expr)) {
568
+ return expr;
569
+ }
570
+ const operands = expr.operands.map(constantFoldExpression);
571
+ const folded = { operator: expr.operator, operands };
572
+ switch (expr.operator) {
573
+ case "if": {
574
+ const [condition, thenBranch, elseBranch] = operands;
575
+ if (condition !== undefined && isValueOperand(condition)) {
576
+ if (typeof condition.value !== "boolean") {
577
+ throw new Error("if (ternary) condition must be a boolean expression");
578
+ }
579
+ return assertDefined(condition.value ? thenBranch : elseBranch, "if (ternary) requires branch operands");
580
+ }
581
+ return folded;
582
+ }
583
+ case "and": {
584
+ if (operands.some((o) => isValueOperand(o) && o.value === false)) {
585
+ return { value: false };
586
+ }
587
+ const remaining = operands.filter((o) => !isValueOperand(o));
588
+ if (remaining.length === 0)
589
+ return { value: true };
590
+ if (remaining.length === 1)
591
+ return remaining[0];
592
+ return { operator: "and", operands: remaining };
593
+ }
594
+ case "or": {
595
+ if (operands.some((o) => isValueOperand(o) && o.value === true)) {
596
+ return { value: true };
597
+ }
598
+ const remaining = operands.filter((o) => !isValueOperand(o));
599
+ if (remaining.length === 0)
600
+ return { value: false };
601
+ if (remaining.length === 1)
602
+ return remaining[0];
603
+ return { operator: "or", operands: remaining };
604
+ }
605
+ case "not": {
606
+ const operand = operands[0];
607
+ if (operand !== undefined && isValueOperand(operand)) {
608
+ return { value: operand.value !== true };
609
+ }
610
+ return folded;
611
+ }
612
+ case "add":
613
+ case "sub":
614
+ case "mult":
615
+ case "div": {
616
+ const [left, right] = operands;
617
+ if (left !== undefined &&
618
+ right !== undefined &&
619
+ isValueOperand(left) &&
620
+ isValueOperand(right)) {
621
+ return {
622
+ value: foldArithmetic(expr.operator, left.value, right.value),
623
+ };
624
+ }
625
+ return folded;
626
+ }
627
+ case "contains":
628
+ case "startsWith":
629
+ case "endsWith": {
630
+ const [receiver, needle] = operands;
631
+ if (receiver !== undefined &&
632
+ needle !== undefined &&
633
+ isValueOperand(receiver) &&
634
+ isValueOperand(needle) &&
635
+ typeof receiver.value === "string" &&
636
+ typeof needle.value === "string") {
637
+ const r = receiver.value;
638
+ const n = needle.value;
639
+ return {
640
+ value: expr.operator === "contains"
641
+ ? r.includes(n)
642
+ : expr.operator === "startsWith"
643
+ ? r.startsWith(n)
644
+ : r.endsWith(n),
645
+ };
646
+ }
647
+ return folded;
648
+ }
649
+ case "exists":
650
+ case "all": {
651
+ const [collection, lambda] = operands;
652
+ if (collection !== undefined &&
653
+ lambda !== undefined &&
654
+ isOperatorOperand(lambda) &&
655
+ lambda.operator === "lambda" &&
656
+ lambda.operands[0] !== undefined &&
657
+ isValueOperand(lambda.operands[0])) {
658
+ const bodyValue = lambda.operands[0].value === true;
659
+ if (expr.operator === "exists" && !bodyValue)
660
+ return { value: false };
661
+ if (expr.operator === "all" && bodyValue)
662
+ return { value: true };
663
+ const sizeExpr = {
664
+ operator: "size",
665
+ operands: [collection],
666
+ };
667
+ // exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
668
+ return expr.operator === "exists"
669
+ ? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
670
+ : { operator: "eq", operands: [sizeExpr, { value: 0 }] };
671
+ }
672
+ return folded;
673
+ }
674
+ default: {
675
+ if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
676
+ const [left, right] = operands;
677
+ if (left !== undefined &&
678
+ right !== undefined &&
679
+ isValueOperand(left) &&
680
+ isValueOperand(right)) {
681
+ return {
682
+ value: evaluateConstantComparison(expr.operator, left.value, right.value),
683
+ };
684
+ }
685
+ }
686
+ return folded;
687
+ }
688
+ }
689
+ }
114
690
  /**
115
691
  * Resolves a field reference considering relations and nested fields.
116
692
  */
@@ -144,8 +720,10 @@ function resolveFieldReference(reference, mapper) {
144
720
  {
145
721
  name,
146
722
  type,
723
+ model: activeConfig.relation.model,
147
724
  field: activeConfig.relation.field,
148
725
  nestedMapper: fields,
726
+ subqueryFilter: activeConfig.relation.subqueryFilter,
149
727
  },
150
728
  ];
151
729
  // Process nested relations
@@ -165,8 +743,10 @@ function resolveFieldReference(reference, mapper) {
165
743
  relations.push({
166
744
  name: nextConfig.relation.name,
167
745
  type: nextConfig.relation.type,
746
+ model: nextConfig.relation.model,
168
747
  field: nextConfig.relation.field,
169
748
  nestedMapper: nextConfig.relation.fields,
749
+ subqueryFilter: nextConfig.relation.subqueryFilter,
170
750
  });
171
751
  currentMapper = nextConfig.relation.fields || {};
172
752
  currentParts = currentParts.slice(1);
@@ -181,10 +761,19 @@ function resolveFieldReference(reference, mapper) {
181
761
  }
182
762
  }
183
763
  }
184
- return { path: field ? [field] : remainingParts, relations };
764
+ return {
765
+ path: field ? [field] : remainingParts,
766
+ relations,
767
+ valueType: activeConfig.valueType,
768
+ nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
769
+ };
185
770
  }
186
771
  // Simple field mapping
187
- return { path: [activeConfig?.field || reference] };
772
+ return {
773
+ path: [activeConfig?.field || reference],
774
+ valueType: activeConfig?.valueType,
775
+ nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
776
+ };
188
777
  }
189
778
  /**
190
779
  * Determines the appropriate Prisma operator based on relation type.
@@ -192,9 +781,137 @@ function resolveFieldReference(reference, mapper) {
192
781
  function getPrismaRelationOperator(relation) {
193
782
  return relation.type === "one" ? "is" : "some";
194
783
  }
784
+ /**
785
+ * One relation-scoped filter (`{ tags: { some: … } }`), narrowed by the store-side predicate the
786
+ * caller declared on the mapping.
787
+ *
788
+ * Every nested relation filter the adapter emits goes through here, so a declared
789
+ * `subqueryFilter` reaches the collection macros, the membership shapes, the emptiness checks and
790
+ * the hop-existence guard alike. Adding a new relation shape means routing it through here, or
791
+ * the declaration silently stops applying to it.
792
+ *
793
+ * `every` is the one operator that cannot simply absorb the predicate. `{ every: AND(W, P) }`
794
+ * would REQUIRE every record to satisfy W, which is the opposite of "ignore the records W hides",
795
+ * so it is rewritten to `{ none: AND(W, NOT P) }` — no visible record violates P. Both agree with
796
+ * `every` when nothing is declared, and both stay vacuously true over an empty relation.
797
+ */
798
+ function relationFilter(relation, operator, inner) {
799
+ const declared = relation.subqueryFilter;
800
+ if (declared === undefined) {
801
+ return { [relation.name]: { [operator]: inner } };
802
+ }
803
+ const narrow = (filter) => Object.keys(filter).length === 0 ? declared : { AND: [declared, filter] };
804
+ if (operator === "every") {
805
+ return Object.keys(inner).length === 0
806
+ ? { [relation.name]: { every: inner } }
807
+ : { [relation.name]: { none: { AND: [declared, { NOT: inner }] } } };
808
+ }
809
+ return { [relation.name]: { [operator]: narrow(inner) } };
810
+ }
195
811
  /**
196
812
  * Builds a nested relation filter for Prisma queries.
197
813
  */
814
+ /**
815
+ * "Every intermediate hop of a dotted path exists", as a Prisma filter.
816
+ *
817
+ * CEL cannot dot through a list, so each intermediate segment of `a.b.c` is a to-ONE
818
+ * parent: absent, the caller sends no attribute and CEL raises a missing-path error, which
819
+ * denies. `NOT { categories: { some: { subCategories: { some: P } } } }` is TRUE for a row
820
+ * with no category at all, so the negation returns rows the PDP denies — the parent must be
821
+ * required separately (cerbos/query-plan-adapters#309).
822
+ *
823
+ * Returns undefined when the reference has no intermediate hop, so a direct relation keeps
824
+ * its empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
825
+ */
826
+ function buildLeadingHopsExistFilter(head, restRelations) {
827
+ if (restRelations.length === 0) {
828
+ return undefined;
829
+ }
830
+ // The last entry is the collection being iterated; everything before it is a hop.
831
+ return buildHopsExistFilter([head, ...restRelations.slice(0, -1)]);
832
+ }
833
+ /**
834
+ * "Every one of these relations exists", as a nested Prisma filter with an empty innermost
835
+ * predicate — `{ parent: { is: { inner: { is: {} } } } }`.
836
+ */
837
+ function buildHopsExistFilter(hops) {
838
+ if (hops.length === 0) {
839
+ return undefined;
840
+ }
841
+ let inner = {};
842
+ for (let i = hops.length - 1; i >= 0; i--) {
843
+ const relation = assertDefined(hops[i], "Relation mapping is missing");
844
+ inner = relationFilter(relation, getPrismaRelationOperator(relation), inner);
845
+ }
846
+ return inner;
847
+ }
848
+ /**
849
+ * "Every to-one hop this expression dots through exists", as a Prisma filter, or undefined
850
+ * when it dots through none.
851
+ *
852
+ * Prisma has no UNKNOWN: a chained relation filter is exact under the POSITIVE polarity —
853
+ * `categories: { some: { subCategories: { some: P } } }` cannot hold without the category —
854
+ * but `NOT` inverts the hop requirement along with the predicate, so every operator built on
855
+ * a chain readmits the parentless rows once negated. #309 fixed that inside the collection
856
+ * macros, which left the sibling operators reached through the same chain unguarded:
857
+ * membership and `hasIntersection` (cerbos/query-plan-adapters#315), and the negated count
858
+ * spelling `!(size(chain) > 0)` (#316), all of which the macros' guard never saw.
859
+ *
860
+ * Requiring the hops OUTSIDE the negation is what fixes all of them at once, and it is
861
+ * unconditionally faithful to CEL rather than a per-operator patch: a missing hop is a
862
+ * missing-path error, which denies under BOTH polarities, so the requirement never depends
863
+ * on which operator sits above the chain.
864
+ *
865
+ * Lambda bodies are deliberately not walked — their references resolve against a scoped
866
+ * mapper, and a negated macro carries its own guard through buildNegatedCollectionFilter.
867
+ */
868
+ function buildExpressionHopsExistFilter(operand, mapper) {
869
+ const hops = [];
870
+ const seen = new Set();
871
+ const visit = (expr) => {
872
+ if (isNamedOperand(expr)) {
873
+ if (seen.has(expr.name)) {
874
+ return;
875
+ }
876
+ seen.add(expr.name);
877
+ const { relations } = resolveFieldReference(expr.name, mapper);
878
+ if (!relations || relations.length === 0) {
879
+ return;
880
+ }
881
+ // Every to-ONE relation on the path is a hop that must EXIST — including a TRAILING one,
882
+ // which is the case a chain of to-many relations never produces. `parent.aBool` resolves
883
+ // to a single to-one relation, and `NOT { parent: { is: { aBool: true } } }` is TRUE for
884
+ // a row with no parent at all, so the negation returns rows the PDP denies. A trailing
885
+ // to-MANY relation is the collection being iterated rather than a hop, and keeps its
886
+ // empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
887
+ const last = assertDefined(relations[relations.length - 1], "Relation mapping is missing");
888
+ const hopFilter = buildHopsExistFilter(last.type === "one" ? relations : relations.slice(0, -1));
889
+ if (hopFilter !== undefined) {
890
+ hops.push(hopFilter);
891
+ }
892
+ return;
893
+ }
894
+ if (!isOperatorOperand(expr) || expr.operator === "lambda") {
895
+ return;
896
+ }
897
+ expr.operands.forEach(visit);
898
+ };
899
+ visit(operand);
900
+ if (hops.length === 0) {
901
+ return undefined;
902
+ }
903
+ return hops.length === 1
904
+ ? assertDefined(hops[0], "Leading-hops filter is missing")
905
+ : { AND: hops };
906
+ }
907
+ /**
908
+ * `{ NOT: filter }`, with every to-one hop `operand` dots through required OUTSIDE the
909
+ * negation so an absent parent stays denied (see buildExpressionHopsExistFilter).
910
+ */
911
+ function negateRequiringHops(operand, filter, mapper) {
912
+ const hops = buildExpressionHopsExistFilter(operand, mapper);
913
+ return hops === undefined ? { NOT: filter } : { AND: [hops, { NOT: filter }] };
914
+ }
198
915
  function buildNestedRelationFilter(relations, fieldFilter) {
199
916
  if (relations.length === 0)
200
917
  return fieldFilter;
@@ -216,7 +933,7 @@ function buildNestedRelationFilter(relations, fieldFilter) {
216
933
  currentFilter = { [relation.field]: filterValue };
217
934
  }
218
935
  }
219
- currentFilter = { [relation.name]: { [relationOperator]: currentFilter } };
936
+ currentFilter = relationFilter(relation, relationOperator, currentFilter);
220
937
  }
221
938
  return currentFilter;
222
939
  }
@@ -231,6 +948,9 @@ function resolveOperand(operand, mapper) {
231
948
  return { value: operand.value };
232
949
  }
233
950
  else if (isOperatorOperand(operand)) {
951
+ if (operand.operator === "timestamp") {
952
+ return resolveTimestampOperand(operand, mapper);
953
+ }
234
954
  const folded = tryFoldValueExpression(operand, mapper);
235
955
  if (folded !== null)
236
956
  return { value: folded };
@@ -239,8 +959,25 @@ function resolveOperand(operand, mapper) {
239
959
  }
240
960
  throw new Error("Operand must have name, value, or be an expression");
241
961
  }
962
+ function resolveTimestampOperand(expression, mapper) {
963
+ if (expression.operands.length !== 1) {
964
+ throw new Error("timestamp() requires exactly one operand");
965
+ }
966
+ const operand = assertDefined(expression.operands[0], "timestamp() requires an operand");
967
+ if (isNamedOperand(operand)) {
968
+ const fieldRef = resolveFieldReference(operand.name, mapper);
969
+ if (fieldRef.valueType !== "dateTime") {
970
+ throw new Error(`timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`);
971
+ }
972
+ return fieldRef;
973
+ }
974
+ if (!isValueOperand(operand) || typeof operand.value !== "string") {
975
+ throw new Error("timestamp() requires a field reference or RFC 3339 string");
976
+ }
977
+ return { value: normalizeRfc3339Milliseconds(operand.value) };
978
+ }
242
979
  function tryFoldValueExpression(expr, mapper) {
243
- if (expr.operator !== "add")
980
+ if (!ARITHMETIC_OPERATORS.has(expr.operator))
244
981
  return null;
245
982
  const leftOp = expr.operands[0];
246
983
  const rightOp = expr.operands[1];
@@ -253,12 +990,23 @@ function tryFoldValueExpression(expr, mapper) {
253
990
  if (!isResolvedValue(right))
254
991
  return null;
255
992
  try {
256
- return foldAdd(left.value, right.value);
993
+ return foldArithmetic(expr.operator, left.value, right.value);
257
994
  }
258
995
  catch {
259
996
  return null;
260
997
  }
261
998
  }
999
+ /**
1000
+ * Records that the lambda body being built touches a nullable element column, so the
1001
+ * enclosing collection operator can add its three-valued-logic guard.
1002
+ */
1003
+ function recordNullableElementField(config, defaultField) {
1004
+ if (!config.nullable) {
1005
+ return;
1006
+ }
1007
+ const scope = lambdaScopes[lambdaScopes.length - 1];
1008
+ scope?.nullableFields.add(config.field || defaultField);
1009
+ }
262
1010
  /**
263
1011
  * Creates a scoped mapper for collection operations
264
1012
  */
@@ -299,7 +1047,9 @@ function createScopedMapper(collectionPath, variableName, fullMapper) {
299
1047
  field = strippedKey;
300
1048
  }
301
1049
  // Return the field config if it exists, otherwise create a default one
302
- return currentConfig[field] || { field };
1050
+ const fieldConfig = currentConfig[field] || { field };
1051
+ recordNullableElementField(fieldConfig, field);
1052
+ return fieldConfig;
303
1053
  }
304
1054
  return { field: strippedKey };
305
1055
  }
@@ -338,15 +1088,10 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
338
1088
  if (!operand) {
339
1089
  throw new Error("not operator requires an operand");
340
1090
  }
341
- if (isNamedOperand(operand)) {
342
- const { relations, ...fieldRef } = resolveFieldReference(operand.name, mapper);
343
- if (!relations || relations.length === 0) {
344
- return buildFieldEqualsFilter(fieldRef, false);
345
- }
346
- }
347
- return {
348
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
349
- };
1091
+ return buildNegatedFilter(operand, mapper);
1092
+ }
1093
+ case "if": {
1094
+ return handleBooleanTernaryOperator(operands, mapper);
350
1095
  }
351
1096
  case "eq":
352
1097
  case "ne":
@@ -364,9 +1109,6 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
364
1109
  case "endsWith": {
365
1110
  return handleStringOperator(operator, operands, mapper);
366
1111
  }
367
- case "isSet": {
368
- return handleIsSetOperator(operands, mapper);
369
- }
370
1112
  case "hasIntersection": {
371
1113
  return handleHasIntersectionOperator(operands, mapper);
372
1114
  }
@@ -396,6 +1138,321 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
396
1138
  throw new Error(`Unsupported operator: ${operator}`);
397
1139
  }
398
1140
  }
1141
+ function containsCollectionOperator(expr) {
1142
+ if (!isOperatorOperand(expr)) {
1143
+ return false;
1144
+ }
1145
+ if (COLLECTION_OPERATORS.has(expr.operator)) {
1146
+ return true;
1147
+ }
1148
+ return expr.operands.some(containsCollectionOperator);
1149
+ }
1150
+ /**
1151
+ * Whether `expr` dots through at least one intermediate to-one hop, so negating it needs the
1152
+ * hop requirement of buildExpressionHopsExistFilter.
1153
+ */
1154
+ function referencesChainedRelation(expr, mapper) {
1155
+ if (isNamedOperand(expr)) {
1156
+ const { relations } = resolveFieldReference(expr.name, mapper);
1157
+ return relations !== undefined && relations.length > 1;
1158
+ }
1159
+ if (!isOperatorOperand(expr) || expr.operator === "lambda") {
1160
+ return false;
1161
+ }
1162
+ return expr.operands.some((operand) => referencesChainedRelation(operand, mapper));
1163
+ }
1164
+ /**
1165
+ * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
1166
+ * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
1167
+ * subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
1168
+ * wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
1169
+ * attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
1170
+ * therefore pushed down to the collection operators, which encode CEL's exact error
1171
+ * semantics:
1172
+ *
1173
+ * - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
1174
+ * and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
1175
+ * UNKNOWN-element (the nullable-field guard).
1176
+ * - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
1177
+ * elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
1178
+ */
1179
+ function buildNegatedFilter(operand, mapper) {
1180
+ if (isNamedOperand(operand)) {
1181
+ const { relations, ...fieldRef } = resolveFieldReference(operand.name, mapper);
1182
+ if (!relations || relations.length === 0) {
1183
+ return buildFieldEqualsFilter(fieldRef, false);
1184
+ }
1185
+ return negateRequiringHops(operand, buildPrismaFilterFromCerbosExpression(operand, mapper), mapper);
1186
+ }
1187
+ // `and`/`or` must be pushed through with De Morgan whenever a branch can be UNKNOWN, so
1188
+ // CEL's error absorption survives: `!(A && B)` with A erroring and B false is TRUE in CEL,
1189
+ // and `OR[!A, !B]` reproduces that where a single outer NOT over the conjunction — with the
1190
+ // hop requirement ANDed outside it — would deny. A chained relation is the second source of
1191
+ // UNKNOWN besides the collection macros, so it opens the same push-down.
1192
+ if (isOperatorOperand(operand) &&
1193
+ (containsCollectionOperator(operand) ||
1194
+ referencesChainedRelation(operand, mapper))) {
1195
+ switch (operand.operator) {
1196
+ case "and":
1197
+ return {
1198
+ OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1199
+ };
1200
+ case "or":
1201
+ return {
1202
+ AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1203
+ };
1204
+ case "not":
1205
+ return buildPrismaFilterFromCerbosExpression(assertDefined(operand.operands[0], "not operator requires an operand"), mapper);
1206
+ case "exists":
1207
+ case "all":
1208
+ case "except":
1209
+ case "exists_one":
1210
+ case "filter":
1211
+ return buildNegatedCollectionFilter(operand.operator, operand.operands, mapper);
1212
+ }
1213
+ }
1214
+ return negateRequiringHops(operand, buildPrismaFilterFromCerbosExpression(operand, mapper), mapper);
1215
+ }
1216
+ function getTernaryOperands(operands) {
1217
+ if (operands.length !== 3) {
1218
+ throw new Error(`if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`);
1219
+ }
1220
+ return {
1221
+ condition: assertDefined(operands[0], "if (ternary) requires a condition operand"),
1222
+ thenBranch: assertDefined(operands[1], "if (ternary) requires a then operand"),
1223
+ elseBranch: assertDefined(operands[2], "if (ternary) requires an else operand"),
1224
+ };
1225
+ }
1226
+ function getConstantBooleanCondition(condition) {
1227
+ if (!isValueOperand(condition)) {
1228
+ return undefined;
1229
+ }
1230
+ if (typeof condition.value !== "boolean") {
1231
+ throw new Error("if (ternary) condition must be a boolean expression");
1232
+ }
1233
+ return condition.value;
1234
+ }
1235
+ function buildBooleanBranchFilter(branch, mapper) {
1236
+ if (!isValueOperand(branch)) {
1237
+ return {
1238
+ kind: "filter",
1239
+ filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
1240
+ };
1241
+ }
1242
+ if (typeof branch.value !== "boolean") {
1243
+ throw new Error("if (ternary) branch in boolean position must be a boolean");
1244
+ }
1245
+ return { kind: "constant", value: branch.value };
1246
+ }
1247
+ /**
1248
+ * "The ternary's condition is definitively FALSE", which is exactly buildNegatedFilter's
1249
+ * contract — three-valued, so a condition that is UNKNOWN (a NULL column, an erroring lambda
1250
+ * body, an absent to-one parent) selects NEITHER branch.
1251
+ *
1252
+ * It delegates rather than spelling a second negation. A private copy is how the ternary
1253
+ * missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is TRUE when
1254
+ * the to-one parent is absent, so the else-branch was selected for rows CEL denies outright —
1255
+ * the missing path is an evaluation error, not a false condition
1256
+ * (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
1257
+ * which a chained `and`/`or` condition needs for CEL's error absorption to survive.
1258
+ */
1259
+ function buildFalseConditionFilter(condition, mapper) {
1260
+ if (isValueOperand(condition)) {
1261
+ throw new Error("Constant ternary conditions must be folded before use");
1262
+ }
1263
+ return buildNegatedFilter(condition, mapper);
1264
+ }
1265
+ function buildGuardedTernaryFilter({ condition, thenFilter, elseFilter, mapper, }) {
1266
+ const conditionTrue = buildPrismaFilterFromCerbosExpression(condition, mapper);
1267
+ const conditionFalse = buildFalseConditionFilter(condition, mapper);
1268
+ const guardedBranches = [];
1269
+ if (thenFilter.kind === "filter") {
1270
+ guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
1271
+ }
1272
+ else if (thenFilter.value) {
1273
+ guardedBranches.push(conditionTrue);
1274
+ }
1275
+ if (elseFilter.kind === "filter") {
1276
+ guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
1277
+ }
1278
+ else if (elseFilter.value) {
1279
+ guardedBranches.push(conditionFalse);
1280
+ }
1281
+ // For a known condition this contradiction is false. If the condition is
1282
+ // NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
1283
+ // ternary UNKNOWN under an outer NOT instead of authorizing the row.
1284
+ guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
1285
+ return {
1286
+ OR: guardedBranches,
1287
+ };
1288
+ }
1289
+ function finalizeTernaryBranchPredicate(predicate) {
1290
+ if (predicate.kind === "filter") {
1291
+ return predicate.filter;
1292
+ }
1293
+ if (predicate.value) {
1294
+ return buildAlwaysTrueFilter();
1295
+ }
1296
+ // Prisma has no model-agnostic always-false `where` shape: empty logical
1297
+ // arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
1298
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
1299
+ }
1300
+ function handleBooleanTernaryOperator(operands, mapper) {
1301
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
1302
+ const constantCondition = getConstantBooleanCondition(condition);
1303
+ if (constantCondition !== undefined) {
1304
+ return finalizeTernaryBranchPredicate(buildBooleanBranchFilter(constantCondition ? thenBranch : elseBranch, mapper));
1305
+ }
1306
+ return buildGuardedTernaryFilter({
1307
+ condition,
1308
+ thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
1309
+ elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
1310
+ mapper,
1311
+ });
1312
+ }
1313
+ function buildTernaryComparisonBranch({ operator, operands, ternaryIndex, branch, mapper, }) {
1314
+ const substitutedOperands = operands.map((operand, index) => index === ternaryIndex ? branch : operand);
1315
+ assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
1316
+ assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
1317
+ const normalized = normalizeBinaryOperands(operator, substitutedOperands);
1318
+ const normalizedFirst = normalized.operands[0];
1319
+ const normalizedSecond = normalized.operands[1];
1320
+ if (normalized.operands.length === 2 &&
1321
+ normalizedFirst !== undefined &&
1322
+ normalizedSecond !== undefined &&
1323
+ isValueOperand(normalizedFirst) &&
1324
+ isValueOperand(normalizedSecond)) {
1325
+ return {
1326
+ kind: "constant",
1327
+ value: evaluateConstantComparison(normalized.operator, normalizedFirst.value, normalizedSecond.value),
1328
+ };
1329
+ }
1330
+ return {
1331
+ kind: "filter",
1332
+ filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
1333
+ };
1334
+ }
1335
+ function tryHandleTernaryComparison(operator, operands, mapper) {
1336
+ if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
1337
+ return null;
1338
+ }
1339
+ const ternaryIndex = operands.findIndex((operand) => isOperatorOperand(operand) && operand.operator === "if");
1340
+ if (ternaryIndex === -1) {
1341
+ return null;
1342
+ }
1343
+ if (operands.length !== 2) {
1344
+ throw new Error(`${operator} with a ternary requires exactly 2 operands, got ${operands.length}`);
1345
+ }
1346
+ const ternary = assertDefined(operands[ternaryIndex], "Ternary comparison operand is missing");
1347
+ if (!isOperatorOperand(ternary)) {
1348
+ throw new Error("Ternary comparison operand must be an expression");
1349
+ }
1350
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(ternary.operands);
1351
+ const constantCondition = getConstantBooleanCondition(condition);
1352
+ if (constantCondition !== undefined) {
1353
+ return finalizeTernaryBranchPredicate(buildTernaryComparisonBranch({
1354
+ operator,
1355
+ operands,
1356
+ ternaryIndex,
1357
+ branch: constantCondition ? thenBranch : elseBranch,
1358
+ mapper,
1359
+ }));
1360
+ }
1361
+ return buildGuardedTernaryFilter({
1362
+ condition,
1363
+ thenFilter: buildTernaryComparisonBranch({
1364
+ operator,
1365
+ operands,
1366
+ ternaryIndex,
1367
+ branch: thenBranch,
1368
+ mapper,
1369
+ }),
1370
+ elseFilter: buildTernaryComparisonBranch({
1371
+ operator,
1372
+ operands,
1373
+ ternaryIndex,
1374
+ branch: elseBranch,
1375
+ mapper,
1376
+ }),
1377
+ mapper,
1378
+ });
1379
+ }
1380
+ function evaluateConstantComparison(operator, left, right) {
1381
+ switch (operator) {
1382
+ case "eq":
1383
+ return areValuesEqual(left, right);
1384
+ case "ne":
1385
+ return !areValuesEqual(left, right);
1386
+ case "lt":
1387
+ case "le":
1388
+ case "gt":
1389
+ case "ge": {
1390
+ if (typeof left === "number" &&
1391
+ typeof right === "number" &&
1392
+ (Number.isNaN(left) || Number.isNaN(right))) {
1393
+ return false;
1394
+ }
1395
+ if (!((typeof left === "number" && typeof right === "number") ||
1396
+ (typeof left === "string" && typeof right === "string"))) {
1397
+ throw new Error(`${operator} constant comparison requires two numbers or two strings`);
1398
+ }
1399
+ switch (operator) {
1400
+ case "lt":
1401
+ return compareConstantValues(left, right) < 0;
1402
+ case "le":
1403
+ return compareConstantValues(left, right) <= 0;
1404
+ case "gt":
1405
+ return compareConstantValues(left, right) > 0;
1406
+ case "ge":
1407
+ return compareConstantValues(left, right) >= 0;
1408
+ }
1409
+ }
1410
+ }
1411
+ throw new Error(`Unsupported constant comparison operator: ${operator}`);
1412
+ }
1413
+ function compareConstantValues(left, right) {
1414
+ if (typeof left === "number" && typeof right === "number") {
1415
+ return left === right ? 0 : left < right ? -1 : 1;
1416
+ }
1417
+ if (typeof left !== "string" || typeof right !== "string") {
1418
+ throw new Error("Cannot order constant values of different types");
1419
+ }
1420
+ const leftCodePoints = Array.from(left, (character) => character.codePointAt(0));
1421
+ const rightCodePoints = Array.from(right, (character) => character.codePointAt(0));
1422
+ const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
1423
+ for (let index = 0; index < sharedLength; index++) {
1424
+ const leftCodePoint = leftCodePoints[index];
1425
+ const rightCodePoint = rightCodePoints[index];
1426
+ if (leftCodePoint !== rightCodePoint) {
1427
+ return leftCodePoint < rightCodePoint ? -1 : 1;
1428
+ }
1429
+ }
1430
+ return leftCodePoints.length === rightCodePoints.length
1431
+ ? 0
1432
+ : leftCodePoints.length < rightCodePoints.length
1433
+ ? -1
1434
+ : 1;
1435
+ }
1436
+ function areValuesEqual(left, right) {
1437
+ if (Array.isArray(left)) {
1438
+ return (Array.isArray(right) &&
1439
+ left.length === right.length &&
1440
+ left.every((value, index) => areValuesEqual(value, right[index])));
1441
+ }
1442
+ if (Array.isArray(right)) {
1443
+ return false;
1444
+ }
1445
+ if (typeof left === "object" && left !== null) {
1446
+ if (typeof right !== "object" || right === null) {
1447
+ return false;
1448
+ }
1449
+ const leftKeys = Object.keys(left);
1450
+ const rightKeys = Object.keys(right);
1451
+ return (leftKeys.length === rightKeys.length &&
1452
+ leftKeys.every((key) => key in right && areValuesEqual(left[key], right[key])));
1453
+ }
1454
+ return left === right;
1455
+ }
399
1456
  function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
400
1457
  const collectionOperand = sizeOperand.operands[0];
401
1458
  if (!collectionOperand || !isNamedOperand(collectionOperand)) {
@@ -424,7 +1481,7 @@ function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
424
1481
  throw new Error("size operator requires a relation mapping");
425
1482
  }
426
1483
  const prismaOp = isNonEmpty ? "some" : "none";
427
- const leafFilter = { [deepest.name]: { [prismaOp]: {} } };
1484
+ const leafFilter = relationFilter(deepest, prismaOp, {});
428
1485
  if (relations.length === 1) {
429
1486
  return leafFilter;
430
1487
  }
@@ -434,6 +1491,11 @@ function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
434
1491
  * Helper function to process relational operators (eq, ne, lt, etc.)
435
1492
  */
436
1493
  function handleRelationalOperator(operator, operands, mapper) {
1494
+ const ternaryFilter = tryHandleTernaryComparison(operator, operands, mapper);
1495
+ if (ternaryFilter) {
1496
+ return ternaryFilter;
1497
+ }
1498
+ ({ operator, operands } = normalizeBinaryOperands(operator, operands));
437
1499
  const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
438
1500
  if (!prismaOperator) {
439
1501
  throw new Error(`Unsupported operator: ${operator}`);
@@ -447,57 +1509,304 @@ function handleRelationalOperator(operator, operands, mapper) {
447
1509
  if (isOperatorOperand(leftOperand) && leftOperand.operator === "size") {
448
1510
  return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
449
1511
  }
450
- const addOperand = [leftOperand, rightOperand].find((o) => isOperatorOperand(o) && o.operator === "add");
451
- if (addOperand) {
452
- const otherOperand = addOperand === leftOperand ? rightOperand : leftOperand;
453
- return handleAddComparison(operator, addOperand, otherOperand, mapper);
1512
+ const arithOperand = [leftOperand, rightOperand].find((o) => isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator));
1513
+ if (arithOperand) {
1514
+ const otherOperand = arithOperand === leftOperand ? rightOperand : leftOperand;
1515
+ return handleArithmeticComparison(operator, arithOperand, otherOperand, arithOperand === leftOperand, mapper);
1516
+ }
1517
+ const mapOperand = [leftOperand, rightOperand].find((o) => isOperatorOperand(o) && o.operator === "map");
1518
+ if (mapOperand) {
1519
+ throw new Error(`Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
1520
+ `Wrap the map() expression in hasIntersection(map(...), [...]) instead.`);
454
1521
  }
455
1522
  const left = resolveOperand(leftOperand, mapper);
456
- const right = requireResolvedValue(resolveOperand(rightOperand, mapper), "Right operand must be a value");
1523
+ const right = resolveOperand(rightOperand, mapper);
1524
+ if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
1525
+ return buildFieldToFieldFilter(operator, leftOperand, left, rightOperand, right);
1526
+ }
1527
+ if (isResolvedValue(left) && isResolvedFieldReference(right)) {
1528
+ return buildComparisonFilter(right, MIRRORED_OPERATOR[operator] ?? operator, left.value);
1529
+ }
1530
+ if (isResolvedValue(left) && isResolvedValue(right)) {
1531
+ const matches = evaluateConstantComparison(operator, left.value, right.value);
1532
+ if (matches) {
1533
+ return buildAlwaysTrueFilter();
1534
+ }
1535
+ throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
1536
+ }
1537
+ const rightValue = requireResolvedValue(right, "Right operand must be a value");
457
1538
  if (isResolvedFieldReference(left)) {
458
- return buildFieldFilter(left, prismaOperator, right.value);
1539
+ return buildComparisonFilter(left, operator, rightValue.value);
459
1540
  }
460
- return { [prismaOperator]: right.value };
1541
+ return { [prismaOperator]: rightValue.value };
461
1542
  }
462
1543
  /**
463
- * Helper function to handle "in" operator
1544
+ * Builds a field-vs-constant comparison. Fractional constants against ordering (and
1545
+ * equality) operators are emitted as a two-clause bracket because Prisma coerces filter
1546
+ * values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
1547
+ * inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
1548
+ * neighbor so the combination is correct BOTH for Float columns (the extra clause is
1549
+ * redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
464
1550
  */
465
- function handleInOperator(operands, mapper) {
466
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
467
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
468
- const fieldRef = requireResolvedFieldReference(resolveOperand(nameOperand, mapper), "Name operand must resolve to a field reference");
469
- const { value } = requireResolvedValue(resolveOperand(valueOperand, mapper), "Value operand must resolve to a value");
470
- const values = Array.isArray(value) ? value : [value];
471
- return buildFieldDirectOrInFilter(fieldRef, values);
1551
+ function buildComparisonFilter(fieldRef, operator, value) {
1552
+ const prismaOperator = assertDefined(CERBOS_TO_PRISMA_OPERATOR[operator], `Unsupported operator: ${operator}`);
1553
+ if (value === null) {
1554
+ assertNullOperandTranslatable(`\`${operator}\` against a null operand`, fieldRef.nullAttributeRepresentation);
1555
+ }
1556
+ // An attribute the caller sends as an explicit null holds a null VALUE in CEL, so equality
1557
+ // against a non-null operand is a definite FALSE and inequality a definite TRUE. Prisma leans
1558
+ // on SQL's UNKNOWN for both, which drops the row under either polarity, so the presence of the
1559
+ // column has to become part of the predicate. Confined to the equality family: an ordering
1560
+ // comparison against a null receiver is a no-overload error in CEL, which denies exactly as the
1561
+ // dropped row does.
1562
+ if (value !== null && isExplicitNullReference(fieldRef)) {
1563
+ const fieldName = getLeafField(fieldRef.path);
1564
+ if (operator === "eq") {
1565
+ return guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } });
1566
+ }
1567
+ if (operator === "ne") {
1568
+ return {
1569
+ NOT: guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } }),
1570
+ };
1571
+ }
1572
+ }
1573
+ if (typeof value === "number" && !Number.isInteger(value)) {
1574
+ const fieldName = getLeafField(fieldRef.path);
1575
+ const lower = Math.floor(value);
1576
+ const upper = Math.ceil(value);
1577
+ const equalsBracket = {
1578
+ AND: [
1579
+ { [fieldName]: { equals: value } },
1580
+ { [fieldName]: { gt: lower } },
1581
+ { [fieldName]: { lt: upper } },
1582
+ ],
1583
+ };
1584
+ let bracket;
1585
+ switch (operator) {
1586
+ case "gt":
1587
+ bracket = {
1588
+ OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
1589
+ };
1590
+ break;
1591
+ case "ge":
1592
+ bracket = {
1593
+ AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
1594
+ };
1595
+ break;
1596
+ case "lt":
1597
+ bracket = {
1598
+ OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
1599
+ };
1600
+ break;
1601
+ case "le":
1602
+ bracket = {
1603
+ AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
1604
+ };
1605
+ break;
1606
+ case "eq":
1607
+ bracket = equalsBracket;
1608
+ break;
1609
+ case "ne":
1610
+ bracket = { NOT: equalsBracket };
1611
+ break;
1612
+ }
1613
+ if (bracket) {
1614
+ return wrapRelations(fieldRef.relations, bracket);
1615
+ }
1616
+ }
1617
+ return buildFieldFilter(fieldRef, prismaOperator, value);
472
1618
  }
473
1619
  /**
474
- * Helper function to handle string operators (contains, startsWith, endsWith)
1620
+ * Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
1621
+ * references between fields of the SAME model, so the container is the root model for
1622
+ * root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
1623
+ * (an element column against an outer column) are cross-model and must fail loudly.
475
1624
  */
476
- function handleStringOperator(operator, operands, mapper) {
477
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
478
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
479
- const fieldRef = requireResolvedFieldReference(resolveOperand(nameOperand, mapper), "Name operand must resolve to a field reference");
480
- const { value } = requireResolvedValue(resolveOperand(valueOperand, mapper), "Value operand must resolve to a value");
481
- if (typeof value !== "string") {
482
- throw new Error(`${operator} operator requires string value`);
1625
+ function buildFieldToFieldFilter(operator, leftOperand, left, rightOperand, right) {
1626
+ const prismaOperator = assertDefined(CERBOS_TO_PRISMA_OPERATOR[operator], `Unsupported operator: ${operator}`);
1627
+ if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
1628
+ throw new Error("Field-to-field comparison requires two named operands");
1629
+ }
1630
+ const scope = lambdaScopes[lambdaScopes.length - 1];
1631
+ let container;
1632
+ if (scope) {
1633
+ const prefix = scope.variableName + ".";
1634
+ const leftInScope = leftOperand.name.startsWith(prefix);
1635
+ const rightInScope = rightOperand.name.startsWith(prefix);
1636
+ if (leftInScope !== rightInScope) {
1637
+ throw new Error(`Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
1638
+ "Prisma field references only work between fields of the same model");
1639
+ }
1640
+ if (leftInScope) {
1641
+ container = scope.relationModel;
1642
+ if (!container) {
1643
+ throw new Error("Field-to-field comparison inside a collection requires `relation.model` in the mapper");
1644
+ }
1645
+ }
483
1646
  }
484
- const fieldName = getLeafField(fieldRef.path);
485
- return wrapRelations(fieldRef.relations, {
486
- [fieldName]: { [operator]: value },
487
- });
1647
+ if (container === undefined) {
1648
+ if ((left.relations && left.relations.length > 0) ||
1649
+ (right.relations && right.relations.length > 0)) {
1650
+ throw new Error("Cannot compare columns across relations: Prisma field references only work between fields of the same model");
1651
+ }
1652
+ container = rootModelName;
1653
+ if (!container) {
1654
+ throw new Error("Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference");
1655
+ }
1656
+ }
1657
+ const leftField = getLeafField(left.path);
1658
+ const rightField = getLeafField(right.path);
1659
+ const fieldReference = { _ref: rightField, _container: container };
1660
+ // Two explicit nulls are EQUAL in CEL, and a null against a value is definitely NOT equal.
1661
+ // A bare field reference gives UNKNOWN for both, which drops the row under either polarity.
1662
+ const leftExplicit = isExplicitNullReference(left);
1663
+ const rightExplicit = isExplicitNullReference(right);
1664
+ // Mixing the two conventions across one comparison has no faithful rendering. The declared side
1665
+ // needs a definite answer for its NULL (CEL holds a null VALUE); the undeclared side needs
1666
+ // UNKNOWN for its NULL (a missing attribute, which CEL denies under both polarities). A definite
1667
+ // predicate returns rows the PDP refuses; a plain one drops rows the PDP allows. Refuse it
1668
+ // rather than pick a direction — declare both attributes, or neither.
1669
+ if (leftExplicit !== rightExplicit && (operator === "eq" || operator === "ne")) {
1670
+ throw new Error(`Cannot translate \`${operator}\` between two columns under mixed null conventions: ` +
1671
+ "cannot compare an attribute declared explicit-null with one on the omitted convention: the omitted side is UNKNOWN for a NULL column while the declared side is definite, and no single predicate is both. Declare nullAttributeRepresentation on both mapper entries, or on neither.");
1672
+ }
1673
+ if (leftExplicit && rightExplicit && (operator === "eq" || operator === "ne")) {
1674
+ const present = [];
1675
+ if (leftExplicit) {
1676
+ present.push({ [leftField]: { not: null } });
1677
+ }
1678
+ if (rightExplicit) {
1679
+ present.push({ [rightField]: { not: null } });
1680
+ }
1681
+ const equality = leftExplicit && rightExplicit
1682
+ ? {
1683
+ OR: [
1684
+ { AND: [{ [leftField]: null }, { [rightField]: null }] },
1685
+ { AND: [...present, { [leftField]: { equals: fieldReference } }] },
1686
+ ],
1687
+ }
1688
+ : { AND: [...present, { [leftField]: { equals: fieldReference } }] };
1689
+ return operator === "eq" ? equality : { NOT: equality };
1690
+ }
1691
+ // `ne` is the one operator whose Prisma spelling cannot carry a field reference: `not` nests a
1692
+ // NestedStringFilter, which has no `_ref` form, so `{ col: { not: { _ref } } }` is rejected by
1693
+ // the client rather than executed. Hoisting the negation to the top-level `NOT` combinator says
1694
+ // the same thing with a shape that does accept one — the same rewrite the explicit-null branch
1695
+ // above already performs. Three-valued logic is unchanged by the hoist: a NULL on either side
1696
+ // makes the inner equality UNKNOWN, and `NOT UNKNOWN` is still UNKNOWN, so the row stays out
1697
+ // under both polarities, which is what a missing attribute does on the check side.
1698
+ if (operator === "ne") {
1699
+ return { NOT: { [leftField]: { equals: fieldReference } } };
1700
+ }
1701
+ return { [leftField]: { [prismaOperator]: fieldReference } };
488
1702
  }
489
1703
  /**
490
- * Helper function to handle "isSet" operator
1704
+ * Helper function to handle "in" operator
491
1705
  */
492
- function handleIsSetOperator(operands, mapper) {
493
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
494
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
495
- const fieldRef = requireResolvedFieldReference(resolveOperand(nameOperand, mapper), "Name operand must resolve to a field reference");
496
- const resolvedValue = requireResolvedValue(resolveOperand(valueOperand, mapper), "Value operand must resolve to a value");
497
- const fieldName = getLeafField(fieldRef.path);
498
- return wrapRelations(fieldRef.relations, {
499
- [fieldName]: resolvedValue.value ? { not: null } : { equals: null },
500
- });
1706
+ function handleInOperator(operands, mapper) {
1707
+ if (operands.length !== 2) {
1708
+ throw new Error("in requires exactly two operands");
1709
+ }
1710
+ const member = assertDefined(operands[0], "in requires a member operand");
1711
+ const collection = assertDefined(operands[1], "in requires a collection operand");
1712
+ if (isNamedOperand(member) && isValueOperand(collection)) {
1713
+ const fieldRef = resolveFieldReference(member.name, mapper);
1714
+ const values = Array.isArray(collection.value)
1715
+ ? collection.value
1716
+ : [collection.value];
1717
+ return buildMembershipFilter(fieldRef, values);
1718
+ }
1719
+ if (isValueOperand(member) && isNamedOperand(collection)) {
1720
+ return buildMembershipFilter(resolveFieldReference(collection.name, mapper), [member.value]);
1721
+ }
1722
+ if (isNamedOperand(member) && isNamedOperand(collection)) {
1723
+ throw new Error("Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column");
1724
+ }
1725
+ throw new Error("in requires a field member and literal list, or a literal member and field collection");
1726
+ }
1727
+ // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
1728
+ const MAX_ENUMERATED_NEEDLES = 1000;
1729
+ /**
1730
+ * Helper function to handle string operators (contains, startsWith, endsWith).
1731
+ *
1732
+ * These operators are receiver-sensitive and the planner preserves policy source order:
1733
+ * operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
1734
+ * inverts the match direction, so unlike symmetric comparisons they are never normalized.
1735
+ */
1736
+ function handleStringOperator(operator, operands, mapper) {
1737
+ if (operands.length !== 2) {
1738
+ throw new Error(`${operator} requires exactly two operands`);
1739
+ }
1740
+ const receiver = resolveOperand(assertDefined(operands[0], `${operator} requires a receiver operand`), mapper);
1741
+ const needle = resolveOperand(assertDefined(operands[1], `${operator} requires a needle operand`), mapper);
1742
+ // Column receiver, constant needle: Prisma's LIKE-based filter.
1743
+ if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
1744
+ const { value } = needle;
1745
+ if (typeof value !== "string") {
1746
+ throw new Error(`${operator} operator requires string value`);
1747
+ }
1748
+ // Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
1749
+ // so a needle containing % or _ would match as a pattern instead of literally (e.g.
1750
+ // startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
1751
+ //
1752
+ // A backslash is the same hazard reached from the other direction, and only an executed
1753
+ // PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
1754
+ // character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
1755
+ // DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
1756
+ // ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
1757
+ // silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
1758
+ if (/[%_\\]/.test(value)) {
1759
+ throw new Error(`Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
1760
+ "Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
1761
+ "escape character on PostgreSQL and MySQL");
1762
+ }
1763
+ const fieldName = getLeafField(receiver.path);
1764
+ return wrapRelations(receiver.relations, {
1765
+ [fieldName]: { [operator]: value },
1766
+ });
1767
+ }
1768
+ // Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
1769
+ // candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
1770
+ // substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
1771
+ // NULL needle column stays excluded (CEL missing-attribute deny).
1772
+ if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
1773
+ if (typeof receiver.value !== "string") {
1774
+ throw new Error(`${operator} operator requires a string receiver`);
1775
+ }
1776
+ const haystack = receiver.value;
1777
+ const candidates = new Set([""]);
1778
+ if (operator === "startsWith") {
1779
+ for (let i = 1; i <= haystack.length; i++) {
1780
+ candidates.add(haystack.slice(0, i));
1781
+ }
1782
+ }
1783
+ else if (operator === "endsWith") {
1784
+ for (let i = 0; i < haystack.length; i++) {
1785
+ candidates.add(haystack.slice(i));
1786
+ }
1787
+ }
1788
+ else {
1789
+ for (let start = 0; start < haystack.length; start++) {
1790
+ for (let end = start + 1; end <= haystack.length; end++) {
1791
+ candidates.add(haystack.slice(start, end));
1792
+ }
1793
+ }
1794
+ }
1795
+ if (candidates.size > MAX_ENUMERATED_NEEDLES) {
1796
+ throw new Error(`Cannot translate ${operator} with a constant receiver of this length: ` +
1797
+ `enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`);
1798
+ }
1799
+ const fieldName = getLeafField(needle.path);
1800
+ return wrapRelations(needle.relations, {
1801
+ [fieldName]: { in: [...candidates] },
1802
+ });
1803
+ }
1804
+ if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
1805
+ // A column-valued needle would need its LIKE metacharacters escaped per row, which
1806
+ // Prisma's filters cannot do (and Prisma leaks wildcards from field references).
1807
+ throw new Error(`Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`);
1808
+ }
1809
+ throw new Error(`${operator} between two constants must be folded by the Cerbos planner`);
501
1810
  }
502
1811
  /**
503
1812
  * Helper function to handle "hasIntersection" operator
@@ -506,6 +1815,10 @@ function handleHasIntersectionOperator(operands, mapper) {
506
1815
  if (operands.length !== 2) {
507
1816
  throw new Error("hasIntersection requires exactly two operands");
508
1817
  }
1818
+ // Intersection is symmetric, and the planner preserves policy source order — e.g.
1819
+ // `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
1820
+ // field/map-first (see #256).
1821
+ ({ operands } = normalizeBinaryOperands("hasIntersection", operands));
509
1822
  const leftOperand = assertDefined(operands[0], "hasIntersection requires a left operand");
510
1823
  const rightOperand = assertDefined(operands[1], "hasIntersection requires a right operand");
511
1824
  // Check if left operand is a map operation
@@ -536,12 +1849,39 @@ function handleHasIntersectionOperator(operands, mapper) {
536
1849
  if (!isNamedOperand(projection)) {
537
1850
  throw new Error("Invalid map lambda expression structure");
538
1851
  }
539
- // Use scoped mapper for resolving the projection
540
- const resolved = resolveFieldReference(projection.name, scopedMapper);
1852
+ // Use scoped mapper for resolving the projection, collecting nullable element columns.
1853
+ const scope = {
1854
+ variableName: variable.name,
1855
+ relationModel: relations[relations.length - 1]?.model,
1856
+ nullableFields: new Set(),
1857
+ unknownFilters: [],
1858
+ };
1859
+ lambdaScopes.push(scope);
1860
+ let resolved;
1861
+ try {
1862
+ resolved = resolveFieldReference(projection.name, scopedMapper);
1863
+ }
1864
+ finally {
1865
+ lambdaScopes.pop();
1866
+ }
541
1867
  const fieldName = getLeafField(resolved.path);
542
- return buildNestedRelationFilter(relations, {
1868
+ const base = buildNestedRelationFilter(relations, {
543
1869
  [fieldName]: { in: rightOperand.value },
544
1870
  });
1871
+ if (scope.nullableFields.size === 0) {
1872
+ return base;
1873
+ }
1874
+ // CEL map() errors if ANY element is missing the projected attribute — even alongside a
1875
+ // matching element — and Cerbos treats that error as deny. Exclude rows holding an
1876
+ // element whose projected (nullable) column is NULL.
1877
+ return {
1878
+ AND: [
1879
+ base,
1880
+ {
1881
+ NOT: buildNestedRelationFilter(relations, buildNullWitnessFilter(scope.nullableFields)),
1882
+ },
1883
+ ],
1884
+ };
545
1885
  }
546
1886
  // Handle regular field reference
547
1887
  if (!isNamedOperand(leftOperand)) {
@@ -555,11 +1895,7 @@ function handleHasIntersectionOperator(operands, mapper) {
555
1895
  throw new Error("hasIntersection requires an array value");
556
1896
  }
557
1897
  if (relations && relations.length > 0) {
558
- const fieldName = getLeafField(path);
559
- const fieldFilter = {
560
- [fieldName]: { in: rightOperand.value },
561
- };
562
- return buildNestedRelationFilter(relations, fieldFilter);
1898
+ return buildMembershipFilter({ path, relations }, rightOperand.value);
563
1899
  }
564
1900
  const fieldName = getLeafField(path);
565
1901
  return { [fieldName]: { some: rightOperand.value } };
@@ -577,10 +1913,7 @@ function handleLambdaOperator(operands) {
577
1913
  field: key.replace(`${variable.name}.`, ""),
578
1914
  }));
579
1915
  }
580
- /**
581
- * Helper function to handle collection operators (exists, all, except, filter)
582
- */
583
- function handleCollectionOperator(operator, operands, mapper) {
1916
+ function buildCollectionLambdaParts(operator, operands, mapper) {
584
1917
  if (operands.length !== 2) {
585
1918
  throw new Error(`${operator} requires exactly two operands`);
586
1919
  }
@@ -603,13 +1936,33 @@ function handleCollectionOperator(operator, operands, mapper) {
603
1936
  if (!relations || relations.length === 0) {
604
1937
  throw new Error(`${operator} operator requires a relation mapping`);
605
1938
  }
1939
+ const head = assertDefined(relations[0], `${operator} operator requires a relation mapping`);
1940
+ const restRelations = relations.slice(1);
1941
+ const deepest = assertDefined(relations[relations.length - 1], `${operator} operator requires a relation mapping`);
606
1942
  const lambdaConditionOperand = assertDefined(lambda.operands[0], "Lambda expression must provide a condition");
607
- const lambdaCondition = buildPrismaFilterFromCerbosExpression(lambdaConditionOperand, // Use the condition part of the lambda
608
- scopedMapper);
609
- const relation = assertDefined(relations[0], `${operator} operator requires a relation mapping`);
1943
+ const scope = {
1944
+ variableName: variable.name,
1945
+ relationModel: deepest.model,
1946
+ nullableFields: new Set(),
1947
+ unknownFilters: [],
1948
+ };
1949
+ lambdaScopes.push(scope);
1950
+ let lambdaCondition;
1951
+ try {
1952
+ lambdaCondition = buildPrismaFilterFromCerbosExpression(lambdaConditionOperand, // Use the condition part of the lambda
1953
+ scopedMapper);
1954
+ }
1955
+ finally {
1956
+ lambdaScopes.pop();
1957
+ }
610
1958
  let filterValue = lambdaCondition;
611
- // If the lambda condition already has a relation structure, merge it
612
- if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
1959
+ if (restRelations.length > 0) {
1960
+ // Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
1961
+ // of the chain, so the element predicate must join through every intermediate hop.
1962
+ filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
1963
+ }
1964
+ else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
1965
+ // If the lambda condition already has a logical structure, use it as-is
613
1966
  filterValue = lambdaCondition;
614
1967
  }
615
1968
  else {
@@ -622,37 +1975,170 @@ function handleCollectionOperator(operator, operands, mapper) {
622
1975
  if (lambdaFieldValue === undefined) {
623
1976
  throw new Error("Lambda condition field value cannot be undefined");
624
1977
  }
625
- const filterField = relation.field || defaultKey;
1978
+ const filterField = head.field || defaultKey;
626
1979
  filterValue = {
627
1980
  [filterField]: lambdaFieldValue,
628
1981
  };
629
1982
  }
1983
+ return {
1984
+ head,
1985
+ restRelations,
1986
+ filterValue,
1987
+ nullableFields: scope.nullableFields,
1988
+ unknownFilters: scope.unknownFilters,
1989
+ };
1990
+ }
1991
+ /** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
1992
+ function buildNullWitnessFilter(nullableFields) {
1993
+ const checks = [...nullableFields].map((field) => ({ [field]: null }));
1994
+ if (checks.length === 1) {
1995
+ return checks[0];
1996
+ }
1997
+ return { OR: checks };
1998
+ }
1999
+ function buildUnknownElementFilter(parts) {
2000
+ const filters = [...parts.unknownFilters];
2001
+ if (parts.nullableFields.size > 0) {
2002
+ filters.push(buildNullWitnessFilter(parts.nullableFields));
2003
+ }
2004
+ if (filters.length === 0) {
2005
+ return undefined;
2006
+ }
2007
+ if (filters.length === 1) {
2008
+ return filters[0];
2009
+ }
2010
+ return { OR: filters };
2011
+ }
2012
+ function recordNestedCollectionUnknownFilter(filter) {
2013
+ const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
2014
+ enclosingScope?.unknownFilters.push(filter);
2015
+ }
2016
+ /** Wraps an element-level filter through the full relation chain (head + rest). */
2017
+ function wrapCollectionElementFilter(parts, elementFilter) {
2018
+ const inner = parts.restRelations.length > 0
2019
+ ? buildNestedRelationFilter(parts.restRelations, elementFilter)
2020
+ : elementFilter;
2021
+ return relationFilter(parts.head, "some", inner);
2022
+ }
2023
+ function throwUnsupportedCollectionOperator(operator) {
2024
+ if (operator === "exists_one") {
2025
+ throw new Error("exists_one requires counting matching elements, which Prisma where-filters cannot express");
2026
+ }
2027
+ throw new Error("The filter() collection operator returns a list, not a boolean. " +
2028
+ "It cannot be used as a standalone condition. " +
2029
+ "Use exists() or combine filter() with size() instead.");
2030
+ }
2031
+ /**
2032
+ * Helper function to handle collection operators (exists, all, except, filter)
2033
+ */
2034
+ function handleCollectionOperator(operator, operands, mapper) {
2035
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(operator, operands, mapper, false);
2036
+ if (knownValueFilter !== undefined) {
2037
+ return knownValueFilter;
2038
+ }
2039
+ if (operator === "exists_one" || operator === "filter") {
2040
+ throwUnsupportedCollectionOperator(operator);
2041
+ }
2042
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
2043
+ const { head, filterValue, nullableFields } = parts;
630
2044
  switch (operator) {
631
- case "exists":
632
- return { [relation.name]: { some: filterValue } };
633
- case "filter":
634
- throw new Error("The filter() collection operator returns a list, not a boolean. " +
635
- "It cannot be used as a standalone condition. " +
636
- "Use exists() or combine filter() with size() instead.");
2045
+ case "exists": {
2046
+ // A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
2047
+ // false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
2048
+ const filter = relationFilter(head, "some", filterValue);
2049
+ const unknownElement = buildUnknownElementFilter(parts);
2050
+ if (unknownElement !== undefined) {
2051
+ recordNestedCollectionUnknownFilter({
2052
+ AND: [
2053
+ { NOT: filter },
2054
+ wrapCollectionElementFilter(parts, unknownElement),
2055
+ ],
2056
+ });
2057
+ }
2058
+ return filter;
2059
+ }
637
2060
  case "except":
638
- return { [relation.name]: { some: { NOT: filterValue } } };
639
- case "exists_one":
2061
+ return relationFilter(head, "some", { NOT: filterValue });
2062
+ case "all": {
2063
+ if (parts.restRelations.length > 0) {
2064
+ throw new Error("all() over a multi-hop relation chain is not supported");
2065
+ }
2066
+ const base = relationFilter(head, "every", filterValue);
2067
+ if (nullableFields.size === 0) {
2068
+ return base;
2069
+ }
2070
+ // CEL all() errors when any element evaluation errors without a false witness; SQL
2071
+ // `every` would treat those elements as vacuously passing. Exclude rows holding any
2072
+ // element whose referenced nullable column is NULL. (`every` already rejects rows
2073
+ // with a definitive false witness, matching error absorption.)
640
2074
  return {
641
- [relation.name]: {
642
- some: filterValue,
643
- },
644
2075
  AND: [
2076
+ base,
2077
+ relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
2078
+ ],
2079
+ };
2080
+ }
2081
+ default:
2082
+ throw new Error(`Unexpected operator: ${operator}`);
2083
+ }
2084
+ }
2085
+ /**
2086
+ * Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
2087
+ * buildNegatedFilter).
2088
+ */
2089
+ function buildNegatedCollectionFilter(operator, operands, mapper) {
2090
+ const knownValueFilter = tryHandleKnownValueCollectionOperator(operator, operands, mapper, true);
2091
+ if (knownValueFilter !== undefined) {
2092
+ return knownValueFilter;
2093
+ }
2094
+ if (operator === "exists_one" || operator === "filter") {
2095
+ throwUnsupportedCollectionOperator(operator);
2096
+ }
2097
+ const parts = buildCollectionLambdaParts(operator, operands, mapper);
2098
+ const { head, filterValue, nullableFields } = parts;
2099
+ // An absent to-one parent must stay denied under negation rather than satisfying it
2100
+ // vacuously (#309).
2101
+ const leadingHopsExist = buildLeadingHopsExistFilter(head, parts.restRelations);
2102
+ const requireLeadingHops = (filter) => leadingHopsExist === undefined
2103
+ ? filter
2104
+ : { AND: [leadingHopsExist, filter] };
2105
+ switch (operator) {
2106
+ case "exists": {
2107
+ const base = requireLeadingHops({
2108
+ NOT: relationFilter(head, "some", filterValue),
2109
+ });
2110
+ const unknownElement = buildUnknownElementFilter(parts);
2111
+ if (unknownElement === undefined) {
2112
+ return base;
2113
+ }
2114
+ // !exists is TRUE only when every element is definitively false: no P-match AND no
2115
+ // element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
2116
+ return {
2117
+ AND: [
2118
+ base,
645
2119
  {
646
- [relation.name]: {
647
- every: {
648
- OR: [filterValue, { NOT: filterValue }],
649
- },
650
- },
2120
+ NOT: wrapCollectionElementFilter(parts, unknownElement),
651
2121
  },
652
2122
  ],
653
2123
  };
2124
+ }
654
2125
  case "all":
655
- return { [relation.name]: { every: filterValue } };
2126
+ // !all is TRUE only with a definitive false witness, which also absorbs error
2127
+ // elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
2128
+ return relationFilter(head, "some", { NOT: filterValue });
2129
+ case "except": {
2130
+ // !except(c,P) = every element definitively matches P.
2131
+ const base = requireLeadingHops(relationFilter(head, "none", { NOT: filterValue }));
2132
+ if (nullableFields.size === 0) {
2133
+ return base;
2134
+ }
2135
+ return {
2136
+ AND: [
2137
+ base,
2138
+ relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
2139
+ ],
2140
+ };
2141
+ }
656
2142
  default:
657
2143
  throw new Error(`Unexpected operator: ${operator}`);
658
2144
  }
@@ -697,60 +2183,152 @@ function handleMapOperator(operands, mapper) {
697
2183
  function buildImpossibleFilter(fieldRef) {
698
2184
  return buildFieldFilter(fieldRef, "in", []);
699
2185
  }
700
- function handleAddComparison(operator, addExpr, otherOperand, mapper) {
701
- const addLeftOp = assertDefined(addExpr.operands[0], "add operator requires a left operand");
702
- const addRightOp = assertDefined(addExpr.operands[1], "add operator requires a right operand");
703
- const addLeft = resolveOperand(addLeftOp, mapper);
704
- const addRight = resolveOperand(addRightOp, mapper);
2186
+ const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
2187
+ /**
2188
+ * Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
2189
+ * column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
2190
+ * one constant side always rewrites to a plain comparison. Multiplying/dividing by a
2191
+ * negative constant mirrors directional operators. The rewrite happens in IEEE double space,
2192
+ * matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
2193
+ * (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
2194
+ * exactly as CEL evaluates it).
2195
+ */
2196
+ function handleArithmeticComparison(operator, arithExpr, otherOperand, arithIsLeft, mapper) {
2197
+ const arithOp = arithExpr.operator;
2198
+ const arithLeftOp = assertDefined(arithExpr.operands[0], `${arithOp} operator requires a left operand`);
2199
+ const arithRightOp = assertDefined(arithExpr.operands[1], `${arithOp} operator requires a right operand`);
2200
+ if (isOperatorOperand(otherOperand) &&
2201
+ ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
2202
+ tryFoldValueExpression(otherOperand, mapper) === null) {
2203
+ throw new Error("Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter");
2204
+ }
2205
+ const arithLeft = resolveOperand(arithLeftOp, mapper);
2206
+ const arithRight = resolveOperand(arithRightOp, mapper);
705
2207
  const other = resolveOperand(otherOperand, mapper);
706
- if (isResolvedValue(addLeft) && isResolvedValue(addRight)) {
707
- const folded = foldAdd(addLeft.value, addRight.value);
708
- const prismaOp = CERBOS_TO_PRISMA_OPERATOR[operator];
709
- if (!prismaOp)
710
- throw new Error(`Unsupported operator: ${operator}`);
711
- if (isResolvedFieldReference(other)) {
712
- return buildFieldFilter(other, prismaOp, folded);
2208
+ // Fully constant arithmetic: fold and compare the other side against the result. The
2209
+ // arithmetic side keeps its source position, so when the folded constant was written
2210
+ // FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
2211
+ if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
2212
+ const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
2213
+ if (!isResolvedFieldReference(other)) {
2214
+ throw new Error(`${arithOp} with two values requires a field reference on the other side`);
713
2215
  }
714
- throw new Error("add with two values requires a field reference on the other side");
2216
+ const effectiveOperator = arithIsLeft
2217
+ ? (MIRRORED_OPERATOR[operator] ?? operator)
2218
+ : operator;
2219
+ return buildComparisonFilter(other, effectiveOperator, folded);
715
2220
  }
716
2221
  if (!isResolvedValue(other)) {
717
- throw new Error("add operator with field references requires a value on the other side of the comparison");
2222
+ throw new Error(`${arithOp} operator with field references requires a value on the other side of the comparison`);
718
2223
  }
719
2224
  let fieldRef;
720
- let addValue;
2225
+ let constant;
721
2226
  let fieldIsLeft;
722
- if (isResolvedFieldReference(addLeft) && isResolvedValue(addRight)) {
723
- fieldRef = addLeft;
724
- addValue = addRight.value;
2227
+ if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
2228
+ fieldRef = arithLeft;
2229
+ constant = arithRight.value;
725
2230
  fieldIsLeft = true;
726
2231
  }
727
- else if (isResolvedValue(addLeft) && isResolvedFieldReference(addRight)) {
728
- fieldRef = addRight;
729
- addValue = addLeft.value;
2232
+ else if (isResolvedValue(arithLeft) &&
2233
+ isResolvedFieldReference(arithRight)) {
2234
+ fieldRef = arithRight;
2235
+ constant = arithLeft.value;
730
2236
  fieldIsLeft = false;
731
2237
  }
732
2238
  else {
733
- throw new Error("add operator requires exactly one field reference and one value, or two values");
734
- }
735
- if (operator !== "eq" && operator !== "ne") {
736
- throw new Error(`Operator ${operator} is not supported with add and field references`);
737
- }
738
- const solvedValue = solveAdd(other.value, addValue, fieldIsLeft);
739
- if (solvedValue === null) {
740
- if (operator === "eq")
741
- return buildImpossibleFilter(fieldRef);
742
- return {};
2239
+ throw new Error(`${arithOp} operator requires exactly one field reference and one value, or two values`);
2240
+ }
2241
+ // The comparison operator was normalized arithmetic-side-first by the caller; if the
2242
+ // arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
2243
+ let effectiveOperator = arithIsLeft
2244
+ ? operator
2245
+ : (MIRRORED_OPERATOR[operator] ?? operator);
2246
+ // String concatenation solving (eq/ne only).
2247
+ if (arithOp === "add" && typeof constant === "string") {
2248
+ if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
2249
+ throw new Error(`Operator ${effectiveOperator} is not supported with string concatenation`);
2250
+ }
2251
+ const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
2252
+ if (solvedValue === null) {
2253
+ if (effectiveOperator === "eq")
2254
+ return buildImpossibleFilter(fieldRef);
2255
+ return {};
2256
+ }
2257
+ return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
2258
+ }
2259
+ if (typeof constant !== "number" || typeof other.value !== "number") {
2260
+ throw new Error(`${arithOp} comparison requires numeric operands`);
2261
+ }
2262
+ if (arithOp === "add" &&
2263
+ (effectiveOperator === "eq" || effectiveOperator === "ne") &&
2264
+ (!Number.isInteger(constant) || !Number.isInteger(other.value))) {
2265
+ throw new Error("Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible");
2266
+ }
2267
+ let solved;
2268
+ switch (arithOp) {
2269
+ case "add":
2270
+ solved = other.value - constant;
2271
+ break;
2272
+ case "sub":
2273
+ if (fieldIsLeft) {
2274
+ // f - c CMP v ⇔ f CMP v + c
2275
+ solved = other.value + constant;
2276
+ }
2277
+ else {
2278
+ // c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
2279
+ solved = constant - other.value;
2280
+ effectiveOperator =
2281
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2282
+ }
2283
+ break;
2284
+ case "mult":
2285
+ if (constant === 0) {
2286
+ throw new Error("Multiplication by a constant zero must be folded by the Cerbos planner");
2287
+ }
2288
+ solved = other.value / constant;
2289
+ if (constant < 0) {
2290
+ effectiveOperator =
2291
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2292
+ }
2293
+ break;
2294
+ case "div":
2295
+ if (!fieldIsLeft) {
2296
+ throw new Error("Division by a column is not supported: the comparison cannot be solved to a plain column filter");
2297
+ }
2298
+ if (constant === 0) {
2299
+ throw new Error("Division by a constant zero is not supported");
2300
+ }
2301
+ solved = other.value * constant;
2302
+ if (constant < 0) {
2303
+ effectiveOperator =
2304
+ MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
2305
+ }
2306
+ break;
2307
+ default:
2308
+ throw new Error(`Unsupported operator: ${arithOp}`);
743
2309
  }
744
- return buildFieldFilter(fieldRef, CERBOS_TO_PRISMA_OPERATOR[operator], solvedValue);
2310
+ return buildComparisonFilter(fieldRef, effectiveOperator, solved);
745
2311
  }
746
- function foldAdd(left, right) {
747
- if (typeof left === "string" || typeof right === "string") {
2312
+ function foldArithmetic(operator, left, right) {
2313
+ if (operator === "add" &&
2314
+ (typeof left === "string" || typeof right === "string")) {
748
2315
  return String(left) + String(right);
749
2316
  }
750
- if (typeof left === "number" && typeof right === "number") {
751
- return left + right;
2317
+ if (typeof left !== "number" || typeof right !== "number") {
2318
+ throw new Error(`${operator} operator requires string or number operands`);
2319
+ }
2320
+ switch (operator) {
2321
+ case "add":
2322
+ return left + right;
2323
+ case "sub":
2324
+ return left - right;
2325
+ case "mult":
2326
+ return left * right;
2327
+ case "div":
2328
+ return left / right;
2329
+ default:
2330
+ throw new Error(`Unsupported operator: ${operator}`);
752
2331
  }
753
- throw new Error("add operator requires string or number operands");
754
2332
  }
755
2333
  function solveAdd(comparisonValue, addConstant, fieldIsLeft) {
756
2334
  if (typeof comparisonValue === "string" && typeof addConstant === "string") {
@@ -891,6 +2469,24 @@ function handleOverlapsOperator(operands, mapper) {
891
2469
  // identical conditions since the same segment pairs are compared in both.
892
2470
  return validConditions[0];
893
2471
  }
2472
+ /**
2473
+ * Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
2474
+ *
2475
+ * `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
2476
+ * match as wildcards instead of literally and widen the filter past what the PDP allows.
2477
+ * `\` is unsafe because PostgreSQL and MySQL treat it as the DEFAULT escape character with no
2478
+ * `ESCAPE` clause present, where SQLite has none at all — so the same prefix means different
2479
+ * things per provider. `[` is unsafe for a third reason: SQL Server opens a character class on
2480
+ * `[` even when an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly
2481
+ * rather than emit a filter that admits denied rows.
2482
+ */
2483
+ function assertLikeSafePrefix(prefix) {
2484
+ if (/[%_\[\\]/.test(prefix)) {
2485
+ throw new Error("Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _, \\ or [): " +
2486
+ "Prisma emits LIKE without an ESCAPE clause, \\ is the default escape character on " +
2487
+ "PostgreSQL and MySQL, and [ opens a character class on SQL Server even with one");
2488
+ }
2489
+ }
894
2490
  function handleFieldOverlaps(left, right) {
895
2491
  if (left.type === "field" && right.type === "field") {
896
2492
  throw new Error("overlaps: cannot compare two field-reference hierarchies");
@@ -908,7 +2504,9 @@ function handleFieldOverlaps(left, right) {
908
2504
  conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
909
2505
  }
910
2506
  conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
911
- conditions.push(buildFieldFilter(field.fieldRef, "startsWith", otherRaw + delimiter));
2507
+ const prefix = otherRaw + delimiter;
2508
+ assertLikeSafePrefix(prefix);
2509
+ conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
912
2510
  if (conditions.length === 1)
913
2511
  return conditions[0];
914
2512
  return { OR: conditions };
@@ -949,6 +2547,7 @@ function handleAncestorDescendantOperator(operands, mapper, direction) {
949
2547
  const descendant = direction === "ancestor" ? right : left;
950
2548
  if (ancestor.type === "constant" && descendant.type === "field") {
951
2549
  const prefix = ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
2550
+ assertLikeSafePrefix(prefix);
952
2551
  return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
953
2552
  }
954
2553
  if (ancestor.type === "field" && descendant.type === "constant") {