@cerbos/orm-prisma 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +598 -603
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +23 -13
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2555
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +27 -17
  95. package/src/adversarial.test.ts +376 -1071
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +433 -250
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +10 -0
  104. package/src/generated/prisma-adversarial/client.ts +10 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
  114. package/src/generated/prisma-adversarial/models.ts +2 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
  117. package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
  118. package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  121. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  130. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  131. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  132. package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
  133. package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
  134. package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
  135. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  136. package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
  137. package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
  138. package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
  139. package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
  140. package/src/generated/prisma-adversarial-pg/client.ts +10 -0
  141. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
  142. package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
  143. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
  144. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  148. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
  149. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
  150. package/src/generated/prisma-adversarial-pg/models.ts +2 -0
  151. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
  152. package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
  153. package/src/hierarchy.ts +405 -0
  154. package/src/index.ts +66 -3646
  155. package/src/literals.ts +363 -0
  156. package/src/mapping.ts +474 -0
  157. package/src/narrowing.ts +33 -0
  158. package/src/plan.ts +125 -0
  159. package/src/presence.ts +114 -0
  160. package/src/regex.ts +596 -0
  161. package/src/relations.ts +226 -0
  162. package/src/rewrite.ts +524 -0
  163. package/src/solve.ts +96 -0
  164. package/src/strings.ts +152 -0
  165. package/src/ternary.ts +359 -0
  166. package/src/test-setup.adversarial-url.ts +6 -3
  167. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  168. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  169. package/src/test-setup.adversarial.v7.ts +1 -0
  170. package/src/timestamp.ts +308 -0
  171. package/src/translate.ts +419 -0
  172. package/src/translator.test.ts +497 -1317
  173. package/src/types.ts +1310 -0
  174. package/src/value.ts +16 -0
package/src/index.ts CHANGED
@@ -1,127 +1,40 @@
1
1
  import { PlanKind } from "@cerbos/core";
2
- import type {
3
- PlanResourcesResponse,
4
- PlanExpressionOperand,
5
- Value,
6
- } from "@cerbos/core";
2
+ import type { PlanResourcesResponse } from "@cerbos/core";
7
3
 
8
- export { PlanKind };
4
+ import { rejectConstantFalse } from "./fields";
5
+ import { assertStructuralNulls } from "./mapping";
6
+ import type { TranslationContext } from "./mapping";
7
+ import { isValueOperand } from "./plan";
8
+ import { constantFoldExpression, hoistOuterScopeReferences } from "./rewrite";
9
+ import { buildPrismaFilterFromCerbosExpression } from "./translate";
10
+ import { rewriteFunctionCalls } from "./functions";
11
+ import { expandLiteralCollections } from "./literals";
12
+ import { settleTypeMismatches } from "./types";
13
+ import { UnsupportedQueryPlanError } from "./errors";
14
+ import type { PrismaFilter } from "./value";
9
15
 
10
- const CERBOS_TO_PRISMA_OPERATOR: Record<string, string> = {
11
- eq: "equals",
12
- ne: "not",
13
- lt: "lt",
14
- le: "lte",
15
- gt: "gt",
16
- ge: "gte",
17
- };
18
-
19
- // Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
20
- const MIRRORED_OPERATOR: Record<string, string> = {
21
- lt: "gt",
22
- gt: "lt",
23
- le: "ge",
24
- ge: "le",
25
- };
26
-
27
- const RFC3339_MILLISECOND_TIMESTAMP =
28
- /^((?!0000)\d{4})-(\d{2})-(\d{2})[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.(\d{1,9}))?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
29
- const MIN_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
30
- "0001-01-01T00:00:00.000Z"
31
- );
32
- const MAX_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
33
- "9999-12-31T23:59:59.999Z"
34
- );
35
-
36
- function normalizeRfc3339Milliseconds(value: string): string {
37
- const match = RFC3339_MILLISECOND_TIMESTAMP.exec(value);
38
- const yearText = match?.[1];
39
- const monthText = match?.[2];
40
- const dayText = match?.[3];
41
- const fraction = match?.[4] ?? "";
42
- if (!yearText || !monthText || !dayText) {
43
- throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
44
- }
45
- if ([...fraction.slice(3)].some((digit) => digit !== "0")) {
46
- throw new Error(
47
- `Timestamp value exceeds millisecond precision: ${value}`
48
- );
49
- }
50
-
51
- const year = Number(yearText);
52
- const month = Number(monthText);
53
- const day = Number(dayText);
54
- const calendarDate = new Date(0);
55
- calendarDate.setUTCHours(0, 0, 0, 0);
56
- calendarDate.setUTCFullYear(year, month - 1, day);
57
- if (
58
- calendarDate.getUTCFullYear() !== year ||
59
- calendarDate.getUTCMonth() !== month - 1 ||
60
- calendarDate.getUTCDate() !== day
61
- ) {
62
- throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
63
- }
64
-
65
- const milliseconds = Date.parse(value);
66
- if (Number.isNaN(milliseconds)) {
67
- throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
68
- }
69
- if (
70
- milliseconds < MIN_RFC3339_TIMESTAMP_MILLISECONDS ||
71
- milliseconds > MAX_RFC3339_TIMESTAMP_MILLISECONDS
72
- ) {
73
- throw new Error(
74
- `Timestamp value is outside CEL's supported instant range: ${value}`
75
- );
76
- }
77
- return new Date(milliseconds).toISOString();
78
- }
79
-
80
- /**
81
- * Normalize a binary comparison to field-side-first. The planner preserves policy source
82
- * order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
83
- * `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
84
- * handlers can assume the field/expression side is first. Without this, value-first
85
- * comparisons are silently inverted (#256).
86
- */
87
- function normalizeBinaryOperands(
88
- operator: string,
89
- operands: PlanExpressionOperand[]
90
- ): { operator: string; operands: PlanExpressionOperand[] } {
91
- const first = operands[0];
92
- const second = operands[1];
93
- if (
94
- operands.length === 2 &&
95
- first !== undefined &&
96
- second !== undefined &&
97
- isValueOperand(first) &&
98
- !isValueOperand(second)
99
- ) {
100
- return {
101
- operator: MIRRORED_OPERATOR[operator] ?? operator,
102
- operands: [second, first],
103
- };
104
- }
105
- return { operator, operands };
106
- }
16
+ export { PlanKind, UnsupportedQueryPlanError };
107
17
 
108
- // Type Definitions
109
- export type PrismaFilter = Record<string, any>;
18
+ export type { PrismaFilter, PrismaValue } from "./value";
110
19
 
111
20
  export type MapperConfig = {
112
21
  field?: string;
113
22
  /**
114
- * Declares the database value kind when the Cerbos expression wraps a field in a typed
115
- * constructor. Date-time metadata is required for timestamp(field) so a string column is
116
- * never silently treated as a temporal column merely because it happens to contain ISO text.
23
+ * Declares the Prisma scalar kind so incompatible operands are rejected before query
24
+ * execution. Undeclared scalar kinds preserve the historical behavior. Date-time metadata
25
+ * is required for timestamp(field); raw DateTime column comparisons lose lexical spelling.
117
26
  */
118
- valueType?: "dateTime";
27
+ valueType?: "dateTime" | "string" | "number" | "boolean";
119
28
  /**
120
29
  * Marks the mapped column as nullable in the database. Cerbos treats a missing attribute as
121
30
  * an evaluation error (deny), which matches SQL three-valued logic for simple predicates —
122
31
  * but relation subqueries (some/every/none) collapse UNKNOWN to false at the EXISTS boundary,
123
32
  * so collection macros over elements with NULL fields need an explicit guard to stay
124
- * deny-aligned. Declaring nullability here enables those guards.
33
+ * deny-aligned. An element column is treated as nullable unless this says `false`, because
34
+ * the unguarded filter is the one that over-grants; declare `nullable: false` on every required
35
+ * element column, since Prisma rejects a `null` comparison against one. Scalar hierarchy
36
+ * segments likewise preserve missing-value errors unless nullable: false declares that a
37
+ * segment cannot be NULL.
125
38
  */
126
39
  nullable?: boolean;
127
40
  /**
@@ -147,7 +60,7 @@ export type MapperConfig = {
147
60
  * no-overload error on a null receiver in CEL, which denies under both polarities exactly as a
148
61
  * dropped row does, so they are left alone.
149
62
  *
150
- * Distinct from `nullable` above, which declares that a *relation element* column can be NULL so
63
+ * Distinct from `nullable` above, which declares whether a *relation element* column can be NULL so
151
64
  * collection macros gain their three-valued guards. The two are independent: `nullable` is about
152
65
  * what a subquery does with an UNKNOWN element, this is about what the caller sent to the PDP.
153
66
  *
@@ -201,11 +114,15 @@ export type Mapper =
201
114
  * The planner emits the same `eq(attr, null)` node either way, so the plan cannot reveal which
202
115
  * convention is in use and the adapter has to be told.
203
116
  *
204
- * - `"explicit"` (default) — a NULL column is sent as an explicit `null` attribute. CEL compares
117
+ * - `"explicit"` — a NULL column is sent as an explicit `null` attribute. CEL compares
205
118
  * `null == null`, so `IS NULL` selects exactly the rows `check()` allows.
206
- * - `"omitted"` — a NULL column sends no attribute at all. CEL then raises a missing-attribute
207
- * error, which Cerbos treats as a deny, so a filter that *selects* NULL rows returns rows the
208
- * PDP denies. Null comparison operands are rejected instead of translated.
119
+ * - `"omitted"` (default) — a NULL column sends no attribute at all. CEL then raises a
120
+ * missing-attribute error, which Cerbos treats as a deny, so a filter that *selects* NULL rows returns rows the
121
+ * PDP denies. A null comparison operand is never translated into a NULL-selecting filter.
122
+ *
123
+ * The default is the convention whose wrong guess fails closed: a caller that actually sends
124
+ * explicit nulls loses the NULL rows, never gains rows the PDP denies
125
+ * (docs/adr/0011-denial-fails-closed-in-the-result.md).
209
126
  *
210
127
  * See https://github.com/cerbos/query-plan-adapters/issues/302.
211
128
  */
@@ -222,7 +139,7 @@ export interface QueryPlanToPrismaArgs {
222
139
  model?: string;
223
140
  /**
224
141
  * Which NULL-column representation the caller uses when building `check()` attributes.
225
- * Defaults to `"explicit"`, preserving the historical `IS NULL` translation.
142
+ * Defaults to `"omitted"`, which never selects NULL rows for an undeclared attribute.
226
143
  */
227
144
  nullAttributeRepresentation?: NullAttributeRepresentation;
228
145
  }
@@ -236,455 +153,6 @@ export type QueryPlanToPrismaResult =
236
153
  filters: PrismaFilter;
237
154
  };
238
155
 
239
- // Type guards for operands
240
- interface NamedOperand {
241
- name: string;
242
- }
243
-
244
- interface ValueOperand {
245
- value: Value;
246
- }
247
-
248
- interface OperatorOperand {
249
- operator: string;
250
- operands: PlanExpressionOperand[];
251
- }
252
-
253
- function isNamedOperand(
254
- operand: PlanExpressionOperand
255
- ): operand is NamedOperand {
256
- return "name" in operand && typeof operand.name === "string";
257
- }
258
-
259
- function isValueOperand(
260
- operand: PlanExpressionOperand
261
- ): operand is ValueOperand {
262
- return "value" in operand && operand.value !== undefined;
263
- }
264
-
265
- function isOperatorOperand(
266
- operand: PlanExpressionOperand
267
- ): operand is OperatorOperand {
268
- return (
269
- "operator" in operand &&
270
- typeof operand.operator === "string" &&
271
- "operands" in operand &&
272
- Array.isArray(operand.operands)
273
- );
274
- }
275
-
276
- function assertDefined<T>(value: T | undefined, message: string): T {
277
- if (value === undefined) {
278
- throw new Error(message);
279
- }
280
- return value;
281
- }
282
-
283
- // Operators whose second operand is a lambda that binds an iteration variable.
284
- const LAMBDA_BINDING_OPERATORS = new Set([
285
- "exists",
286
- "exists_one",
287
- "all",
288
- "filter",
289
- "map",
290
- "except",
291
- ]);
292
-
293
- /**
294
- * Substitute a lambda iteration variable with a concrete collection element
295
- * inside a lambda body. A bare reference to the variable becomes the element
296
- * itself; a `variable.path.to.field` reference drills into the element. A
297
- * nested collection macro whose lambda rebinds the same variable name shadows
298
- * the outer variable, so substitution only descends into its collection
299
- * operand.
300
- */
301
- function substituteLambdaVariable(
302
- operand: PlanExpressionOperand,
303
- variableName: string,
304
- element: Value
305
- ): PlanExpressionOperand {
306
- if (isNamedOperand(operand)) {
307
- if (operand.name === variableName) {
308
- return { value: element };
309
- }
310
- if (operand.name.startsWith(`${variableName}.`)) {
311
- let current: Value = element;
312
- for (const segment of operand.name
313
- .slice(variableName.length + 1)
314
- .split(".")) {
315
- if (
316
- current === null ||
317
- typeof current !== "object" ||
318
- Array.isArray(current) ||
319
- !(segment in current) ||
320
- current[segment] === undefined
321
- ) {
322
- throw new Error(
323
- `Cannot resolve "${operand.name}": collection element has no field "${segment}"`
324
- );
325
- }
326
- current = current[segment];
327
- }
328
- return { value: current };
329
- }
330
- return operand;
331
- }
332
-
333
- if (isOperatorOperand(operand)) {
334
- if (
335
- LAMBDA_BINDING_OPERATORS.has(operand.operator) &&
336
- operand.operands.length === 2
337
- ) {
338
- const [nestedCollection, nestedLambda] = operand.operands;
339
- if (
340
- nestedCollection !== undefined &&
341
- nestedLambda !== undefined &&
342
- isOperatorOperand(nestedLambda) &&
343
- nestedLambda.operator === "lambda"
344
- ) {
345
- const nestedVariable = nestedLambda.operands[1];
346
- if (
347
- nestedVariable !== undefined &&
348
- isNamedOperand(nestedVariable) &&
349
- nestedVariable.name === variableName
350
- ) {
351
- // The nested lambda shadows our variable: substitute only in the
352
- // collection operand.
353
- return {
354
- operator: operand.operator,
355
- operands: [
356
- substituteLambdaVariable(nestedCollection, variableName, element),
357
- nestedLambda,
358
- ],
359
- };
360
- }
361
- }
362
- }
363
- return {
364
- operator: operand.operator,
365
- operands: operand.operands.map((o) =>
366
- substituteLambdaVariable(o, variableName, element)
367
- ),
368
- };
369
- }
370
-
371
- return operand;
372
- }
373
-
374
- /**
375
- * Fold a collection macro whose collection operand is a literal value list.
376
- *
377
- * The planner emits this shape when a known-value collection (typically a
378
- * folded principal attribute) has more than 10 elements — at 10 or fewer it
379
- * unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
380
- * cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
381
- * the same fold here so the translated filter does not depend on which side of
382
- * that threshold the collection lands: substitute each element into the lambda
383
- * body and combine the per-element filters with OR (`exists`) or AND (`all`).
384
- *
385
- * An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
386
- * match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
387
- * over an empty collection.
388
- */
389
- function handleKnownValueCollectionOperator(
390
- operator: string,
391
- collection: ValueOperand,
392
- lambda: PlanExpressionOperand,
393
- mapper: Mapper,
394
- negated: boolean
395
- ): PrismaFilter {
396
- if (operator !== "exists" && operator !== "all") {
397
- throw new Error(
398
- `${operator} over a literal collection value is not supported. ` +
399
- "Only exists() and all() can be folded into a flat filter."
400
- );
401
- }
402
-
403
- const elements = collection.value;
404
- if (!Array.isArray(elements)) {
405
- throw new Error(
406
- `${operator} over a literal collection requires a list value`
407
- );
408
- }
409
-
410
- if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
411
- throw new Error(
412
- `Second operand of ${operator} must be a lambda expression`
413
- );
414
- }
415
- if (lambda.operands.length !== 2) {
416
- throw new Error(
417
- `${operator} over a literal collection supports single-variable lambdas only`
418
- );
419
- }
420
-
421
- const body = assertDefined(
422
- lambda.operands[0],
423
- "Lambda expression must provide a condition"
424
- );
425
- const variable = assertDefined(
426
- lambda.operands[1],
427
- "Lambda variable must have a name"
428
- );
429
- if (!isNamedOperand(variable)) {
430
- throw new Error("Lambda variable must have a name");
431
- }
432
-
433
- const filters = elements.map((element) => {
434
- const substituted = substituteLambdaVariable(
435
- body,
436
- variable.name,
437
- element
438
- );
439
- return negated
440
- ? buildNegatedFilter(substituted, mapper)
441
- : buildPrismaFilterFromCerbosExpression(substituted, mapper);
442
- });
443
-
444
- const combinesWithOr = operator === "exists" ? !negated : negated;
445
- return combinesWithOr ? { OR: filters } : { AND: filters };
446
- }
447
-
448
- function tryHandleKnownValueCollectionOperator(
449
- operator: string,
450
- operands: PlanExpressionOperand[],
451
- mapper: Mapper,
452
- negated: boolean
453
- ): PrismaFilter | undefined {
454
- if (operands.length !== 2) {
455
- return undefined;
456
- }
457
-
458
- const collection = operands[0];
459
- const lambda = operands[1];
460
- if (
461
- collection === undefined ||
462
- lambda === undefined ||
463
- !isValueOperand(collection)
464
- ) {
465
- return undefined;
466
- }
467
-
468
- // A literal value list arrives when the planner could not unroll a macro
469
- // over a known collection (more than 10 elements). Fold it before attempting
470
- // relation resolution because there is no relation mapping for a literal.
471
- return handleKnownValueCollectionOperator(
472
- operator,
473
- collection,
474
- lambda,
475
- mapper,
476
- negated
477
- );
478
- }
479
-
480
- function getLeafField(path: string[]): string {
481
- const fieldName = path[path.length - 1];
482
- if (!fieldName) {
483
- throw new Error("Field path cannot be empty");
484
- }
485
- return fieldName;
486
- }
487
-
488
- function getFilterEntry(filter: Record<string, unknown>): [string, unknown] {
489
- const entry = Object.entries(filter)[0];
490
- if (!entry) {
491
- throw new Error("Filter must contain at least one entry");
492
- }
493
- return entry;
494
- }
495
-
496
- // Field reference resolution types
497
- type RelationConfig = {
498
- name: string;
499
- type: "one" | "many";
500
- model?: string;
501
- field?: string;
502
- nestedMapper?: Record<string, MapperConfig>;
503
- subqueryFilter?: PrismaFilter;
504
- };
505
-
506
- type ResolvedFieldReference = {
507
- path: string[];
508
- relations?: RelationConfig[];
509
- valueType?: "dateTime";
510
- nullAttributeRepresentation?: NullAttributeRepresentation;
511
- };
512
-
513
- /** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
514
- function isExplicitNullReference(fieldRef: ResolvedFieldReference): boolean {
515
- return fieldRef.nullAttributeRepresentation === "explicit";
516
- }
517
-
518
- type ResolvedValue = {
519
- value: any;
520
- };
521
-
522
- type ResolvedOperand = ResolvedFieldReference | ResolvedValue;
523
-
524
- function isResolvedFieldReference(
525
- operand: ResolvedOperand
526
- ): operand is ResolvedFieldReference {
527
- return "path" in operand;
528
- }
529
-
530
- function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue {
531
- return "value" in operand;
532
- }
533
-
534
- function requireResolvedValue(
535
- operand: ResolvedOperand,
536
- message: string
537
- ): ResolvedValue {
538
- if (!isResolvedValue(operand)) {
539
- throw new Error(message);
540
- }
541
- return operand;
542
- }
543
-
544
- function wrapRelations(
545
- relations: RelationConfig[] | undefined,
546
- filter: PrismaFilter
547
- ): PrismaFilter {
548
- if (!relations || relations.length === 0) {
549
- return filter;
550
- }
551
- return buildNestedRelationFilter(relations, filter);
552
- }
553
-
554
- function buildFieldEqualsFilter(
555
- fieldRef: ResolvedFieldReference,
556
- value: Value
557
- ): PrismaFilter {
558
- const fieldName = getLeafField(fieldRef.path);
559
- return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
560
- }
561
-
562
- function buildDirectOrInLeaf(
563
- fieldRef: ResolvedFieldReference,
564
- values: Value[]
565
- ): PrismaFilter {
566
- const fieldName = getLeafField(fieldRef.path);
567
- return values.length === 1
568
- ? { [fieldName]: values[0] }
569
- : { [fieldName]: { in: values } };
570
- }
571
-
572
- function buildFieldDirectOrInFilter(
573
- fieldRef: ResolvedFieldReference,
574
- values: Value[]
575
- ): PrismaFilter {
576
- return wrapRelations(fieldRef.relations, buildDirectOrInLeaf(fieldRef, values));
577
- }
578
-
579
- /**
580
- * `field IS NOT NULL AND <leaf>`, so a NULL column makes the whole thing definitely FALSE rather
581
- * than merely unmatched — which is what lets an enclosing `NOT` include the row.
582
- *
583
- * Prisma has no three-valued logic to expose: a record either matches a `where` or it does not.
584
- * That is exactly why the guard has to be explicit — `{ NOT: { field: { equals: "x" } } }` leans on
585
- * the generated SQL's UNKNOWN, which excludes the record under both polarities, and an
586
- * explicit-null attribute needs the negation to include it.
587
- *
588
- * The guard is ANDed with the leaf BEFORE the relation wrapping, so both halves are evaluated
589
- * against the same element rather than against two independently-matched ones.
590
- */
591
- function guardedFieldFilter(
592
- fieldRef: ResolvedFieldReference,
593
- leaf: PrismaFilter
594
- ): PrismaFilter {
595
- const fieldName = getLeafField(fieldRef.path);
596
- return wrapRelations(fieldRef.relations, {
597
- AND: [{ [fieldName]: { not: null } }, leaf],
598
- });
599
- }
600
-
601
- function buildMembershipFilter(
602
- fieldRef: ResolvedFieldReference,
603
- values: Value[]
604
- ): PrismaFilter {
605
- const nonNullValues = values.filter((value) => value !== null);
606
- const carriesNull = nonNullValues.length !== values.length;
607
- if (carriesNull) {
608
- assertNullOperandTranslatable(
609
- "a null element in an `in` list",
610
- fieldRef.nullAttributeRepresentation
611
- );
612
- }
613
- const filters: PrismaFilter[] = [];
614
-
615
- if (nonNullValues.length > 0 || values.length === 0) {
616
- // Without a null element nothing has made the membership definite yet: a NULL column is
617
- // dropped by `{ in: [...] }` under BOTH polarities, while CEL compares a null VALUE against
618
- // each element and gets a definite false, so its negation is TRUE. With a null element the
619
- // `[null]` disjunct below already settles it.
620
- filters.push(
621
- carriesNull || !isExplicitNullReference(fieldRef)
622
- ? buildFieldDirectOrInFilter(fieldRef, nonNullValues)
623
- : guardedFieldFilter(
624
- fieldRef,
625
- buildDirectOrInLeaf(fieldRef, nonNullValues)
626
- )
627
- );
628
- }
629
- if (carriesNull) {
630
- filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
631
- }
632
-
633
- if (filters.length === 1) {
634
- return assertDefined(filters[0], "Membership filter is missing");
635
- }
636
- return { OR: filters };
637
- }
638
-
639
- function buildAlwaysTrueFilter(): PrismaFilter {
640
- return {};
641
- }
642
-
643
- type TernaryBranchPredicate =
644
- | { kind: "constant"; value: boolean }
645
- | { kind: "filter"; filter: PrismaFilter };
646
-
647
- // Translation-scoped context. Set at queryPlanToPrisma entry; the lambda stack tracks which
648
- // collection variable (and related Prisma model) encloses the expression currently being built,
649
- // so leaf handlers can distinguish element columns from outer columns and collect nullable
650
- // element fields for three-valued-logic guards. Translation is synchronous, so module scope is
651
- // safe.
652
- type LambdaScope = {
653
- variableName: string;
654
- relationModel: string | undefined;
655
- nullableFields: Set<string>;
656
- unknownFilters: PrismaFilter[];
657
- };
658
- let lambdaScopes: LambdaScope[] = [];
659
- let rootModelName: string | undefined;
660
- let nullRepresentation: NullAttributeRepresentation = "explicit";
661
-
662
- /**
663
- * Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
664
- * operand.
665
- *
666
- * Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
667
- * missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
668
- * the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
669
- * null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
670
- * than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
671
- * `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
672
- * under any nesting; narrowing it requires negation-parity tracking.
673
- */
674
- function assertNullOperandTranslatable(
675
- context: string,
676
- declared?: NullAttributeRepresentation
677
- ): void {
678
- if ((declared ?? nullRepresentation) === "omitted") {
679
- throw new Error(
680
- `Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
681
- "sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
682
- "(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
683
- 'explicit nulls and use "explicit", or keep this shape out of the policy.'
684
- );
685
- }
686
- }
687
-
688
156
  /**
689
157
  * Converts a Cerbos query plan to a Prisma filter.
690
158
  */
@@ -692,3105 +160,57 @@ export function queryPlanToPrisma({
692
160
  queryPlan,
693
161
  mapper = {},
694
162
  model,
695
- nullAttributeRepresentation = "explicit",
163
+ nullAttributeRepresentation = "omitted",
696
164
  }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
697
- nullRepresentation = nullAttributeRepresentation;
165
+ const context: TranslationContext = {
166
+ mapper,
167
+ rootModel: model,
168
+ nullRepresentation: nullAttributeRepresentation,
169
+ scopes: [],
170
+ };
171
+
698
172
  switch (queryPlan.kind) {
699
173
  case PlanKind.ALWAYS_ALLOWED:
700
174
  return { kind: PlanKind.ALWAYS_ALLOWED };
701
175
  case PlanKind.ALWAYS_DENIED:
702
176
  return { kind: PlanKind.ALWAYS_DENIED };
703
177
  case PlanKind.CONDITIONAL: {
704
- lambdaScopes = [];
705
- rootModelName = model;
706
- const condition = constantFoldExpression(
707
- hoistOuterScopeReferences(queryPlan.condition, [])
178
+ const settled = settleTypeMismatches(
179
+ hoistOuterScopeReferences(
180
+ expandLiteralCollections(rewriteFunctionCalls(queryPlan.condition, context)),
181
+ []
182
+ ),
183
+ context
708
184
  );
185
+
186
+ // After settling: a struct literal compared with a column of another type is decided by
187
+ // the types alone (`aString == {"a": null}` is false, or an error on a missing column), so
188
+ // its null member never reaches a filter and must not refuse the plan.
189
+ assertStructuralNulls(settled, context);
190
+ const condition = constantFoldExpression(settled);
191
+
709
192
  if (isValueOperand(condition)) {
710
- // Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
711
- // they reach the adapter; this can only surface with hand-crafted plans.
193
+ // The planner folds a condition that is constant on its own, but not one the mapped
194
+ // column types decide (`R.attr.aNumber == "5"` is false for every row): that surfaces
195
+ // here, and is answered as the planner would have answered it.
712
196
  if (condition.value === true) {
713
197
  return { kind: PlanKind.CONDITIONAL, filters: {} };
714
198
  }
715
- // Prisma has no model-agnostic always-false `where` shape: empty logical arrays
716
- // are ignored.
717
- throw new Error(
718
- "A constant-false conditional predicate must be folded by the Cerbos planner"
719
- );
720
- }
721
- // A `map()` at the ROOT of the condition returns a list, not a boolean. Translating it
722
- // produces a projection filter that is not a valid Prisma `where` clause — the shape
723
- // must be refused HERE rather than left for findMany to reject at query time, because
724
- // a projection that happened to coerce would be a silently-wrong filter. (`filter()`
725
- // as a condition is already rejected inside handleCollectionOperator with its own
726
- // message; nested inside a comparison or size() both remain translatable.)
727
- if (isOperatorOperand(condition) && condition.operator === "map") {
728
- throw new Error(
729
- "map() returns a list, not a boolean, so it cannot be a condition on its own; " +
730
- "only comparisons or size() over its result have a boolean meaning"
731
- );
732
- }
733
- return {
734
- kind: PlanKind.CONDITIONAL,
735
- filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
736
- };
737
- }
738
- default:
739
- throw Error(`Invalid query plan.`);
740
- }
741
- }
742
-
743
- const COLLECTION_OPERATORS = new Set([
744
- "exists",
745
- "exists_one",
746
- "all",
747
- "except",
748
- "filter",
749
- ]);
750
-
751
- function mentionsVariable(
752
- expr: PlanExpressionOperand,
753
- variableName: string
754
- ): boolean {
755
- if (isNamedOperand(expr)) {
756
- return (
757
- expr.name === variableName || expr.name.startsWith(variableName + ".")
758
- );
759
- }
760
- if (isOperatorOperand(expr)) {
761
- return expr.operands.some((operand) =>
762
- mentionsVariable(operand, variableName)
763
- );
764
- }
765
- return false;
766
- }
767
-
768
- function isOuterScopeName(name: string, enclosingVariables: string[]): boolean {
769
- return !enclosingVariables.some(
770
- (variable) => name === variable || name.startsWith(variable + ".")
771
- );
772
- }
773
-
774
- /** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
775
- function substituteNamedOperand(
776
- expr: PlanExpressionOperand,
777
- name: string,
778
- value: boolean
779
- ): PlanExpressionOperand {
780
- if (isNamedOperand(expr)) {
781
- return expr.name === name ? { value } : expr;
782
- }
783
- if (isOperatorOperand(expr)) {
784
- return {
785
- operator: expr.operator,
786
- operands: expr.operands.map((operand) =>
787
- substituteNamedOperand(operand, name, value)
788
- ),
789
- };
790
- }
791
- return expr;
792
- }
793
-
794
- /**
795
- * Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
796
- * boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
797
- * the body itself.
798
- */
799
- function findOuterBooleanReference(
800
- expr: PlanExpressionOperand,
801
- enclosingVariables: string[]
802
- ): string | undefined {
803
- if (isNamedOperand(expr)) {
804
- return isOuterScopeName(expr.name, enclosingVariables)
805
- ? expr.name
806
- : undefined;
807
- }
808
- if (!isOperatorOperand(expr)) {
809
- return undefined;
810
- }
811
- const booleanPositions: PlanExpressionOperand[] = [];
812
- switch (expr.operator) {
813
- case "and":
814
- case "or":
815
- case "not":
816
- booleanPositions.push(...expr.operands);
817
- break;
818
- case "if":
819
- booleanPositions.push(...expr.operands);
820
- break;
821
- default:
822
- // Comparisons wrapping a ternary keep the outer reference in the ternary condition.
823
- booleanPositions.push(
824
- ...expr.operands.filter(
825
- (operand) =>
826
- isOperatorOperand(operand) &&
827
- (operand.operator === "if" ||
828
- operand.operator === "and" ||
829
- operand.operator === "or" ||
830
- operand.operator === "not")
831
- )
832
- );
833
- break;
834
- }
835
- for (const operand of booleanPositions) {
836
- const found = findOuterBooleanReference(operand, enclosingVariables);
837
- if (found !== undefined) {
838
- return found;
839
- }
840
- }
841
- return undefined;
842
- }
843
-
844
- /**
845
- * Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
846
- * outer lambda's element) so that every filter lands on the model it belongs to. Without this,
847
- * an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
848
- * tag model. Two sound transforms, applied bottom-up:
849
- *
850
- * - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
851
- * `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
852
- * OR), including the empty-collection case (both sides are FALSE).
853
- * - Case split (any collection op): a bare outer boolean reference Q in a boolean position
854
- * becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
855
- * keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
856
- * guarded-ternary encoding.
857
- */
858
- function hoistOuterScopeReferences(
859
- expr: PlanExpressionOperand,
860
- enclosingVariables: string[]
861
- ): PlanExpressionOperand {
862
- if (!isOperatorOperand(expr)) {
863
- return expr;
864
- }
865
-
866
- if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
867
- const [collection, lambda] = expr.operands;
868
- if (
869
- collection !== undefined &&
870
- lambda !== undefined &&
871
- isOperatorOperand(lambda) &&
872
- lambda.operator === "lambda" &&
873
- lambda.operands.length === 2 &&
874
- lambda.operands[1] !== undefined &&
875
- isNamedOperand(lambda.operands[1])
876
- ) {
877
- const variable = lambda.operands[1];
878
- const body = hoistOuterScopeReferences(
879
- assertDefined(lambda.operands[0], "Lambda requires a condition"),
880
- [...enclosingVariables, variable.name]
881
- );
882
-
883
- const rebuild = (newBody: PlanExpressionOperand): OperatorOperand => ({
884
- operator: expr.operator,
885
- operands: [
886
- collection,
887
- { operator: "lambda", operands: [newBody, variable] },
888
- ],
889
- });
890
-
891
- // Conjunct hoisting out of exists().
892
- if (
893
- expr.operator === "exists" &&
894
- isOperatorOperand(body) &&
895
- body.operator === "and"
896
- ) {
897
- const elementConjuncts = body.operands.filter((operand) =>
898
- mentionsVariable(operand, variable.name)
899
- );
900
- const outerConjuncts = body.operands.filter(
901
- (operand) => !mentionsVariable(operand, variable.name)
902
- );
903
- if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
904
- const innerBody =
905
- elementConjuncts.length === 1
906
- ? elementConjuncts[0]!
907
- : { operator: "and", operands: elementConjuncts };
908
- return {
909
- operator: "and",
910
- operands: [rebuild(innerBody), ...outerConjuncts],
911
- };
912
- }
913
- }
914
-
915
- // Case split on a bare outer boolean reference.
916
- const outerRef = findOuterBooleanReference(body, [
917
- ...enclosingVariables,
918
- variable.name,
919
- ]);
920
- if (outerRef !== undefined) {
921
- const q: PlanExpressionOperand = { name: outerRef };
922
- const notQ: PlanExpressionOperand = { operator: "not", operands: [q] };
923
- return {
924
- operator: "or",
925
- operands: [
926
- {
927
- operator: "and",
928
- operands: [
929
- q,
930
- rebuild(substituteNamedOperand(body, outerRef, true)),
931
- ],
932
- },
933
- {
934
- operator: "and",
935
- operands: [
936
- notQ,
937
- rebuild(substituteNamedOperand(body, outerRef, false)),
938
- ],
939
- },
940
- // Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
941
- { operator: "and", operands: [q, notQ] },
942
- ],
943
- };
944
- }
945
-
946
- return rebuild(body);
947
- }
948
- }
949
-
950
- return {
951
- operator: expr.operator,
952
- operands: expr.operands.map((operand) =>
953
- hoistOuterScopeReferences(operand, enclosingVariables)
954
- ),
955
- };
956
- }
957
-
958
- const FOLDABLE_COMPARISON_OPERATORS = new Set([
959
- "eq",
960
- "ne",
961
- "lt",
962
- "le",
963
- "gt",
964
- "ge",
965
- ]);
966
199
 
967
- /**
968
- * Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
969
- * produce need folding (constant ternary conditions, comparisons between two constants,
970
- * logical operators with constant operands, collection macros with constant bodies); real
971
- * planner output arrives pre-folded.
972
- */
973
- function constantFoldExpression(
974
- expr: PlanExpressionOperand
975
- ): PlanExpressionOperand {
976
- if (!isOperatorOperand(expr)) {
977
- return expr;
978
- }
979
- const operands = expr.operands.map(constantFoldExpression);
980
- const folded: OperatorOperand = { operator: expr.operator, operands };
981
-
982
- switch (expr.operator) {
983
- case "if": {
984
- const [condition, thenBranch, elseBranch] = operands;
985
- if (condition !== undefined && isValueOperand(condition)) {
986
- if (typeof condition.value !== "boolean") {
987
- throw new Error("if (ternary) condition must be a boolean expression");
988
- }
989
- return assertDefined(
990
- condition.value ? thenBranch : elseBranch,
991
- "if (ternary) requires branch operands"
992
- );
993
- }
994
- return folded;
995
- }
996
- case "and": {
997
- if (operands.some((o) => isValueOperand(o) && o.value === false)) {
998
- return { value: false };
999
- }
1000
- const remaining = operands.filter((o) => !isValueOperand(o));
1001
- if (remaining.length === 0) return { value: true };
1002
- if (remaining.length === 1) return remaining[0]!;
1003
- return { operator: "and", operands: remaining };
1004
- }
1005
- case "or": {
1006
- if (operands.some((o) => isValueOperand(o) && o.value === true)) {
1007
- return { value: true };
1008
- }
1009
- const remaining = operands.filter((o) => !isValueOperand(o));
1010
- if (remaining.length === 0) return { value: false };
1011
- if (remaining.length === 1) return remaining[0]!;
1012
- return { operator: "or", operands: remaining };
1013
- }
1014
- case "not": {
1015
- const operand = operands[0];
1016
- if (operand !== undefined && isValueOperand(operand)) {
1017
- return { value: operand.value !== true };
1018
- }
1019
- return folded;
1020
- }
1021
- case "add":
1022
- case "sub":
1023
- case "mult":
1024
- case "div": {
1025
- const [left, right] = operands;
1026
- if (
1027
- left !== undefined &&
1028
- right !== undefined &&
1029
- isValueOperand(left) &&
1030
- isValueOperand(right)
1031
- ) {
1032
- return {
1033
- value: foldArithmetic(expr.operator, left.value, right.value),
1034
- };
1035
- }
1036
- return folded;
1037
- }
1038
- case "contains":
1039
- case "startsWith":
1040
- case "endsWith": {
1041
- const [receiver, needle] = operands;
1042
- if (
1043
- receiver !== undefined &&
1044
- needle !== undefined &&
1045
- isValueOperand(receiver) &&
1046
- isValueOperand(needle) &&
1047
- typeof receiver.value === "string" &&
1048
- typeof needle.value === "string"
1049
- ) {
1050
- const r = receiver.value;
1051
- const n = needle.value;
1052
- return {
1053
- value:
1054
- expr.operator === "contains"
1055
- ? r.includes(n)
1056
- : expr.operator === "startsWith"
1057
- ? r.startsWith(n)
1058
- : r.endsWith(n),
1059
- };
1060
- }
1061
- return folded;
1062
- }
1063
- case "exists":
1064
- case "all": {
1065
- const [collection, lambda] = operands;
1066
- if (
1067
- collection !== undefined &&
1068
- lambda !== undefined &&
1069
- isOperatorOperand(lambda) &&
1070
- lambda.operator === "lambda" &&
1071
- lambda.operands[0] !== undefined &&
1072
- isValueOperand(lambda.operands[0])
1073
- ) {
1074
- const bodyValue = lambda.operands[0].value === true;
1075
- if (expr.operator === "exists" && !bodyValue) return { value: false };
1076
- if (expr.operator === "all" && bodyValue) return { value: true };
1077
- const sizeExpr: PlanExpressionOperand = {
1078
- operator: "size",
1079
- operands: [collection],
1080
- };
1081
- // exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
1082
- return expr.operator === "exists"
1083
- ? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
1084
- : { operator: "eq", operands: [sizeExpr, { value: 0 }] };
1085
- }
1086
- return folded;
1087
- }
1088
- default: {
1089
- if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
1090
- const [left, right] = operands;
1091
- if (
1092
- left !== undefined &&
1093
- right !== undefined &&
1094
- isValueOperand(left) &&
1095
- isValueOperand(right)
1096
- ) {
1097
- return {
1098
- value: evaluateConstantComparison(
1099
- expr.operator,
1100
- left.value,
1101
- right.value
1102
- ),
1103
- };
200
+ if (condition.value === false) {
201
+ return { kind: PlanKind.ALWAYS_DENIED };
1104
202
  }
1105
- }
1106
- return folded;
1107
- }
1108
- }
1109
- }
1110
-
1111
- /**
1112
- * Resolves a field reference considering relations and nested fields.
1113
- */
1114
- function resolveFieldReference(
1115
- reference: string,
1116
- mapper: Mapper
1117
- ): ResolvedFieldReference {
1118
- const parts = reference.split(".");
1119
- const config =
1120
- typeof mapper === "function" ? mapper(reference) : mapper[reference];
1121
-
1122
- let matchedPrefix = "";
1123
- let matchedConfig: MapperConfig | undefined;
1124
-
1125
- // If no direct match, look for partial matches
1126
- if (!config) {
1127
- for (let i = parts.length - 1; i >= 0; i--) {
1128
- const prefix = parts.slice(0, i + 1).join(".");
1129
- const prefixConfig =
1130
- typeof mapper === "function" ? mapper(prefix) : mapper[prefix];
1131
203
 
1132
- if (prefixConfig) {
1133
- matchedPrefix = prefix;
1134
- matchedConfig = prefixConfig;
1135
- break;
204
+ rejectConstantFalse();
1136
205
  }
1137
- }
1138
- }
1139
-
1140
- const activeConfig = config ?? matchedConfig;
1141
-
1142
- // Handle relation mapping
1143
- if (activeConfig?.relation) {
1144
- const { name, type, fields } = activeConfig.relation;
1145
- const matchedParts = matchedPrefix ? matchedPrefix.split(".") : [];
1146
- const remainingParts = matchedPrefix
1147
- ? parts.slice(matchedParts.length)
1148
- : parts.slice(1);
1149
206
 
1150
- let field: string | undefined;
1151
- const relations: RelationConfig[] = [
1152
- {
1153
- name,
1154
- type,
1155
- model: activeConfig.relation.model,
1156
- field: activeConfig.relation.field,
1157
- nestedMapper: fields,
1158
- subqueryFilter: activeConfig.relation.subqueryFilter,
1159
- },
1160
- ];
1161
-
1162
- // Process nested relations
1163
- if (fields && remainingParts.length > 0) {
1164
- let currentMapper: Record<string, MapperConfig> | undefined = fields;
1165
- let currentParts = remainingParts;
1166
-
1167
- while (currentParts.length > 0) {
1168
- if (!currentMapper) {
1169
- break;
1170
- }
1171
-
1172
- const currentPart = currentParts[0];
1173
- if (!currentPart) {
1174
- break;
1175
- }
1176
-
1177
- const nextConfig: MapperConfig | undefined = currentMapper[currentPart];
1178
- if (nextConfig?.relation) {
1179
- relations.push({
1180
- name: nextConfig.relation.name,
1181
- type: nextConfig.relation.type,
1182
- model: nextConfig.relation.model,
1183
- field: nextConfig.relation.field,
1184
- nestedMapper: nextConfig.relation.fields,
1185
- subqueryFilter: nextConfig.relation.subqueryFilter,
1186
- });
1187
- currentMapper = nextConfig.relation.fields || {};
1188
- currentParts = currentParts.slice(1);
1189
- } else {
1190
- const lastPart = currentParts[currentParts.length - 1];
1191
- if (!lastPart) {
1192
- break;
1193
- }
1194
- field = nextConfig?.field || lastPart;
1195
- break;
1196
- }
1197
- }
207
+ return {
208
+ kind: PlanKind.CONDITIONAL,
209
+ filters: buildPrismaFilterFromCerbosExpression(condition, context),
210
+ };
1198
211
  }
1199
212
 
1200
- return {
1201
- path: field ? [field] : remainingParts,
1202
- relations,
1203
- valueType: activeConfig.valueType,
1204
- nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
1205
- };
1206
- }
1207
-
1208
- // Simple field mapping
1209
- return {
1210
- path: [activeConfig?.field || reference],
1211
- valueType: activeConfig?.valueType,
1212
- nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
1213
- };
1214
- }
1215
-
1216
- /**
1217
- * Determines the appropriate Prisma operator based on relation type.
1218
- */
1219
- function getPrismaRelationOperator(relation: {
1220
- name: string;
1221
- type: "one" | "many";
1222
- field?: string;
1223
- }): string {
1224
- return relation.type === "one" ? "is" : "some";
1225
- }
1226
-
1227
- /**
1228
- * One relation-scoped filter (`{ tags: { some: … } }`), narrowed by the store-side predicate the
1229
- * caller declared on the mapping.
1230
- *
1231
- * Every nested relation filter the adapter emits goes through here, so a declared
1232
- * `subqueryFilter` reaches the collection macros, the membership shapes, the emptiness checks and
1233
- * the hop-existence guard alike. Adding a new relation shape means routing it through here, or
1234
- * the declaration silently stops applying to it.
1235
- *
1236
- * `every` is the one operator that cannot simply absorb the predicate. `{ every: AND(W, P) }`
1237
- * would REQUIRE every record to satisfy W, which is the opposite of "ignore the records W hides",
1238
- * so it is rewritten to `{ none: AND(W, NOT P) }` — no visible record violates P. Both agree with
1239
- * `every` when nothing is declared, and both stay vacuously true over an empty relation.
1240
- */
1241
- function relationFilter(
1242
- relation: RelationConfig,
1243
- operator: string,
1244
- inner: PrismaFilter
1245
- ): PrismaFilter {
1246
- const declared = relation.subqueryFilter;
1247
- if (declared === undefined) {
1248
- return { [relation.name]: { [operator]: inner } };
1249
- }
1250
- const narrow = (filter: PrismaFilter): PrismaFilter =>
1251
- Object.keys(filter).length === 0 ? declared : { AND: [declared, filter] };
1252
- if (operator === "every") {
1253
- return Object.keys(inner).length === 0
1254
- ? { [relation.name]: { every: inner } }
1255
- : { [relation.name]: { none: { AND: [declared, { NOT: inner }] } } };
213
+ default:
214
+ throw new UnsupportedQueryPlanError(`Invalid query plan.`);
1256
215
  }
1257
- return { [relation.name]: { [operator]: narrow(inner) } };
1258
- }
1259
-
1260
- /**
1261
- * Builds a nested relation filter for Prisma queries.
1262
- */
1263
- /**
1264
- * "Every intermediate hop of a dotted path exists", as a Prisma filter.
1265
- *
1266
- * CEL cannot dot through a list, so each intermediate segment of `a.b.c` is a to-ONE
1267
- * parent: absent, the caller sends no attribute and CEL raises a missing-path error, which
1268
- * denies. `NOT { categories: { some: { subCategories: { some: P } } } }` is TRUE for a row
1269
- * with no category at all, so the negation returns rows the PDP denies — the parent must be
1270
- * required separately (cerbos/query-plan-adapters#309).
1271
- *
1272
- * Returns undefined when the reference has no intermediate hop, so a direct relation keeps
1273
- * its empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
1274
- */
1275
- function buildLeadingHopsExistFilter(
1276
- head: RelationConfig,
1277
- restRelations: RelationConfig[]
1278
- ): PrismaFilter | undefined {
1279
- if (restRelations.length === 0) {
1280
- return undefined;
1281
- }
1282
- // The last entry is the collection being iterated; everything before it is a hop.
1283
- return buildHopsExistFilter([head, ...restRelations.slice(0, -1)]);
1284
- }
1285
-
1286
- /**
1287
- * "Every one of these relations exists", as a nested Prisma filter with an empty innermost
1288
- * predicate — `{ parent: { is: { inner: { is: {} } } } }`.
1289
- */
1290
- function buildHopsExistFilter(
1291
- hops: RelationConfig[]
1292
- ): PrismaFilter | undefined {
1293
- if (hops.length === 0) {
1294
- return undefined;
1295
- }
1296
- let inner: PrismaFilter = {};
1297
- for (let i = hops.length - 1; i >= 0; i--) {
1298
- const relation = assertDefined(hops[i], "Relation mapping is missing");
1299
- inner = relationFilter(relation, getPrismaRelationOperator(relation), inner);
1300
- }
1301
- return inner;
1302
- }
1303
-
1304
- /**
1305
- * "Every to-one hop this expression dots through exists", as a Prisma filter, or undefined
1306
- * when it dots through none.
1307
- *
1308
- * Prisma has no UNKNOWN: a chained relation filter is exact under the POSITIVE polarity —
1309
- * `categories: { some: { subCategories: { some: P } } }` cannot hold without the category —
1310
- * but `NOT` inverts the hop requirement along with the predicate, so every operator built on
1311
- * a chain readmits the parentless rows once negated. #309 fixed that inside the collection
1312
- * macros, which left the sibling operators reached through the same chain unguarded:
1313
- * membership and `hasIntersection` (cerbos/query-plan-adapters#315), and the negated count
1314
- * spelling `!(size(chain) > 0)` (#316), all of which the macros' guard never saw.
1315
- *
1316
- * Requiring the hops OUTSIDE the negation is what fixes all of them at once, and it is
1317
- * unconditionally faithful to CEL rather than a per-operator patch: a missing hop is a
1318
- * missing-path error, which denies under BOTH polarities, so the requirement never depends
1319
- * on which operator sits above the chain.
1320
- *
1321
- * Lambda bodies are deliberately not walked — their references resolve against a scoped
1322
- * mapper, and a negated macro carries its own guard through buildNegatedCollectionFilter.
1323
- */
1324
- function buildExpressionHopsExistFilter(
1325
- operand: PlanExpressionOperand,
1326
- mapper: Mapper
1327
- ): PrismaFilter | undefined {
1328
- const hops: PrismaFilter[] = [];
1329
- const seen = new Set<string>();
1330
-
1331
- const visit = (expr: PlanExpressionOperand): void => {
1332
- if (isNamedOperand(expr)) {
1333
- if (seen.has(expr.name)) {
1334
- return;
1335
- }
1336
- seen.add(expr.name);
1337
- const { relations } = resolveFieldReference(expr.name, mapper);
1338
- if (!relations || relations.length === 0) {
1339
- return;
1340
- }
1341
- // Every to-ONE relation on the path is a hop that must EXIST — including a TRAILING one,
1342
- // which is the case a chain of to-many relations never produces. `parent.aBool` resolves
1343
- // to a single to-one relation, and `NOT { parent: { is: { aBool: true } } }` is TRUE for
1344
- // a row with no parent at all, so the negation returns rows the PDP denies. A trailing
1345
- // to-MANY relation is the collection being iterated rather than a hop, and keeps its
1346
- // empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
1347
- const last = assertDefined(
1348
- relations[relations.length - 1],
1349
- "Relation mapping is missing"
1350
- );
1351
- const hopFilter = buildHopsExistFilter(
1352
- last.type === "one" ? relations : relations.slice(0, -1)
1353
- );
1354
- if (hopFilter !== undefined) {
1355
- hops.push(hopFilter);
1356
- }
1357
- return;
1358
- }
1359
- if (!isOperatorOperand(expr) || expr.operator === "lambda") {
1360
- return;
1361
- }
1362
- expr.operands.forEach(visit);
1363
- };
1364
-
1365
- visit(operand);
1366
-
1367
- if (hops.length === 0) {
1368
- return undefined;
1369
- }
1370
- return hops.length === 1
1371
- ? assertDefined(hops[0], "Leading-hops filter is missing")
1372
- : { AND: hops };
1373
- }
1374
-
1375
- /**
1376
- * `{ NOT: filter }`, with every to-one hop `operand` dots through required OUTSIDE the
1377
- * negation so an absent parent stays denied (see buildExpressionHopsExistFilter).
1378
- */
1379
- function negateRequiringHops(
1380
- operand: PlanExpressionOperand,
1381
- filter: PrismaFilter,
1382
- mapper: Mapper
1383
- ): PrismaFilter {
1384
- const hops = buildExpressionHopsExistFilter(operand, mapper);
1385
- return hops === undefined ? { NOT: filter } : { AND: [hops, { NOT: filter }] };
1386
- }
1387
-
1388
- function buildNestedRelationFilter(
1389
- relations: RelationConfig[],
1390
- fieldFilter: any
1391
- ): any {
1392
- if (relations.length === 0) return fieldFilter;
1393
-
1394
- let currentFilter = fieldFilter;
1395
-
1396
- // Build nested structure from inside out
1397
- for (let i = relations.length - 1; i >= 0; i--) {
1398
- const relation = relations[i];
1399
- if (!relation) {
1400
- throw new Error("Relation mapping is missing");
1401
- }
1402
- const relationOperator = getPrismaRelationOperator(relation);
1403
-
1404
- // Handle special case for the deepest relation
1405
- if (relation.field && i === relations.length - 1) {
1406
- const [key, filterValue] = getFilterEntry(currentFilter);
1407
- if (key === "NOT") {
1408
- currentFilter = { NOT: { [relation.field]: filterValue } };
1409
- } else {
1410
- currentFilter = { [relation.field]: filterValue };
1411
- }
1412
- }
1413
-
1414
- currentFilter = relationFilter(relation, relationOperator, currentFilter);
1415
- }
1416
-
1417
- return currentFilter;
1418
- }
1419
-
1420
- /**
1421
- * Resolves a PlanExpressionOperand into a ResolvedOperand.
1422
- */
1423
- function resolveOperand(
1424
- operand: PlanExpressionOperand,
1425
- mapper: Mapper
1426
- ): ResolvedOperand {
1427
- if (isNamedOperand(operand)) {
1428
- return resolveFieldReference(operand.name, mapper);
1429
- } else if (isValueOperand(operand)) {
1430
- return { value: operand.value };
1431
- } else if (isOperatorOperand(operand)) {
1432
- if (operand.operator === "timestamp") {
1433
- return resolveTimestampOperand(operand, mapper);
1434
- }
1435
- const folded = tryFoldValueExpression(operand, mapper);
1436
- if (folded !== null) return { value: folded };
1437
- const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
1438
- return { value: nestedResult };
1439
- }
1440
- throw new Error("Operand must have name, value, or be an expression");
1441
- }
1442
-
1443
- function resolveTimestampOperand(
1444
- expression: OperatorOperand,
1445
- mapper: Mapper
1446
- ): ResolvedOperand {
1447
- if (expression.operands.length !== 1) {
1448
- throw new Error("timestamp() requires exactly one operand");
1449
- }
1450
-
1451
- const operand = assertDefined(
1452
- expression.operands[0],
1453
- "timestamp() requires an operand"
1454
- );
1455
- if (isNamedOperand(operand)) {
1456
- const fieldRef = resolveFieldReference(operand.name, mapper);
1457
- if (fieldRef.valueType !== "dateTime") {
1458
- throw new Error(
1459
- `timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`
1460
- );
1461
- }
1462
- return fieldRef;
1463
- }
1464
-
1465
- if (!isValueOperand(operand) || typeof operand.value !== "string") {
1466
- throw new Error("timestamp() requires a field reference or RFC 3339 string");
1467
- }
1468
- return { value: normalizeRfc3339Milliseconds(operand.value) };
1469
- }
1470
-
1471
- function tryFoldValueExpression(
1472
- expr: OperatorOperand,
1473
- mapper: Mapper
1474
- ): Value | null {
1475
- if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
1476
- const leftOp = expr.operands[0];
1477
- const rightOp = expr.operands[1];
1478
- if (!leftOp || !rightOp) return null;
1479
-
1480
- const left = resolveOperand(leftOp, mapper);
1481
- if (!isResolvedValue(left)) return null;
1482
- const right = resolveOperand(rightOp, mapper);
1483
- if (!isResolvedValue(right)) return null;
1484
-
1485
- try {
1486
- return foldArithmetic(expr.operator, left.value, right.value);
1487
- } catch {
1488
- return null;
1489
- }
1490
- }
1491
-
1492
- /**
1493
- * Records that the lambda body being built touches a nullable element column, so the
1494
- * enclosing collection operator can add its three-valued-logic guard.
1495
- */
1496
- function recordNullableElementField(
1497
- config: MapperConfig,
1498
- defaultField: string
1499
- ): void {
1500
- if (!config.nullable) {
1501
- return;
1502
- }
1503
- const scope = lambdaScopes[lambdaScopes.length - 1];
1504
- scope?.nullableFields.add(config.field || defaultField);
1505
- }
1506
-
1507
- /**
1508
- * Creates a scoped mapper for collection operations
1509
- */
1510
- function createScopedMapper(
1511
- collectionPath: string,
1512
- variableName: string,
1513
- fullMapper: Mapper
1514
- ): Mapper {
1515
- return (key: string) => {
1516
- // If the key starts with the variable name, it's accessing the collection item
1517
- if (key.startsWith(variableName + ".")) {
1518
- const strippedKey = key.replace(variableName + ".", "");
1519
- const parts = strippedKey.split(".");
1520
-
1521
- // Get the collection's relation config
1522
- const collectionConfig =
1523
- typeof fullMapper === "function"
1524
- ? fullMapper(collectionPath)
1525
- : fullMapper[collectionPath];
1526
-
1527
- if (collectionConfig?.relation?.fields) {
1528
- // For nested paths, traverse the fields configuration
1529
- const baseConfig = collectionConfig.relation.fields;
1530
- if (!baseConfig) {
1531
- return { field: strippedKey };
1532
- }
1533
-
1534
- let currentConfig = baseConfig;
1535
- let field = parts[0] || strippedKey;
1536
-
1537
- for (let i = 0; i < parts.length - 1; i++) {
1538
- const part = parts[i];
1539
- const nextPart = parts[i + 1];
1540
- if (!part || !nextPart) {
1541
- break;
1542
- }
1543
-
1544
- const nextConfig = currentConfig[part];
1545
- if (nextConfig?.relation?.fields) {
1546
- currentConfig = nextConfig.relation.fields;
1547
- field = nextPart;
1548
- } else {
1549
- break;
1550
- }
1551
- }
1552
-
1553
- if (!field) {
1554
- field = strippedKey;
1555
- }
1556
-
1557
- // Return the field config if it exists, otherwise create a default one
1558
- const fieldConfig = currentConfig[field] || { field };
1559
- recordNullableElementField(fieldConfig, field);
1560
- return fieldConfig;
1561
- }
1562
- return { field: strippedKey };
1563
- }
1564
-
1565
- // For keys not referencing the collection item, use the full mapper
1566
- if (typeof fullMapper === "function") {
1567
- return fullMapper(key);
1568
- }
1569
- return fullMapper[key] || { field: key };
1570
- };
1571
- }
1572
-
1573
- /**
1574
- * Builds a Prisma filter from a Cerbos expression.
1575
- */
1576
- function buildPrismaFilterFromCerbosExpression(
1577
- expression: PlanExpressionOperand,
1578
- mapper: Mapper
1579
- ): PrismaFilter {
1580
- // A bare named operand represents a boolean field reference (e.g. `R.attr.booleanAttr`)
1581
- if (isNamedOperand(expression)) {
1582
- const fieldRef = resolveFieldReference(expression.name, mapper);
1583
- return buildFieldEqualsFilter(fieldRef, true);
1584
- }
1585
-
1586
- if (!isOperatorOperand(expression)) {
1587
- throw new Error("Invalid Cerbos expression structure");
1588
- }
1589
-
1590
- const { operator, operands } = expression;
1591
-
1592
- // Process different operator types
1593
- switch (operator) {
1594
- case "and":
1595
- return {
1596
- AND: operands.map((operand) =>
1597
- buildPrismaFilterFromCerbosExpression(operand, mapper)
1598
- ),
1599
- };
1600
-
1601
- case "or":
1602
- return {
1603
- OR: operands.map((operand) =>
1604
- buildPrismaFilterFromCerbosExpression(operand, mapper)
1605
- ),
1606
- };
1607
-
1608
- case "not": {
1609
- const operand = operands[0];
1610
- if (!operand) {
1611
- throw new Error("not operator requires an operand");
1612
- }
1613
- return buildNegatedFilter(operand, mapper);
1614
- }
1615
-
1616
- case "if": {
1617
- return handleBooleanTernaryOperator(operands, mapper);
1618
- }
1619
-
1620
- case "eq":
1621
- case "ne":
1622
- case "lt":
1623
- case "le":
1624
- case "gt":
1625
- case "ge": {
1626
- return handleRelationalOperator(operator, operands, mapper);
1627
- }
1628
-
1629
- case "in": {
1630
- return handleInOperator(operands, mapper);
1631
- }
1632
-
1633
- case "contains":
1634
- case "startsWith":
1635
- case "endsWith": {
1636
- return handleStringOperator(operator, operands, mapper);
1637
- }
1638
-
1639
- case "hasIntersection": {
1640
- return handleHasIntersectionOperator(operands, mapper);
1641
- }
1642
-
1643
- case "lambda": {
1644
- return handleLambdaOperator(operands);
1645
- }
1646
-
1647
- case "exists":
1648
- case "exists_one":
1649
- case "all":
1650
- case "except":
1651
- case "filter": {
1652
- return handleCollectionOperator(operator, operands, mapper);
1653
- }
1654
-
1655
- case "map": {
1656
- return handleMapOperator(operands, mapper);
1657
- }
1658
-
1659
- case "overlaps": {
1660
- return handleOverlapsOperator(operands, mapper);
1661
- }
1662
-
1663
- case "ancestorOf": {
1664
- return handleAncestorDescendantOperator(operands, mapper, "ancestor");
1665
- }
1666
-
1667
- case "descendentOf": {
1668
- return handleAncestorDescendantOperator(operands, mapper, "descendant");
1669
- }
1670
-
1671
- default:
1672
- throw new Error(`Unsupported operator: ${operator}`);
1673
- }
1674
- }
1675
-
1676
- function containsCollectionOperator(expr: PlanExpressionOperand): boolean {
1677
- if (!isOperatorOperand(expr)) {
1678
- return false;
1679
- }
1680
- if (COLLECTION_OPERATORS.has(expr.operator)) {
1681
- return true;
1682
- }
1683
- return expr.operands.some(containsCollectionOperator);
1684
- }
1685
-
1686
- /**
1687
- * Whether `expr` dots through at least one intermediate to-one hop, so negating it needs the
1688
- * hop requirement of buildExpressionHopsExistFilter.
1689
- */
1690
- function referencesChainedRelation(
1691
- expr: PlanExpressionOperand,
1692
- mapper: Mapper
1693
- ): boolean {
1694
- if (isNamedOperand(expr)) {
1695
- const { relations } = resolveFieldReference(expr.name, mapper);
1696
- return relations !== undefined && relations.length > 1;
1697
- }
1698
- if (!isOperatorOperand(expr) || expr.operator === "lambda") {
1699
- return false;
1700
- }
1701
- return expr.operands.some((operand) =>
1702
- referencesChainedRelation(operand, mapper)
1703
- );
1704
- }
1705
-
1706
- /**
1707
- * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
1708
- * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
1709
- * subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
1710
- * wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
1711
- * attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
1712
- * therefore pushed down to the collection operators, which encode CEL's exact error
1713
- * semantics:
1714
- *
1715
- * - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
1716
- * and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
1717
- * UNKNOWN-element (the nullable-field guard).
1718
- * - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
1719
- * elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
1720
- */
1721
- function buildNegatedFilter(
1722
- operand: PlanExpressionOperand,
1723
- mapper: Mapper
1724
- ): PrismaFilter {
1725
- if (isNamedOperand(operand)) {
1726
- const { relations, ...fieldRef } = resolveFieldReference(
1727
- operand.name,
1728
- mapper
1729
- );
1730
- if (!relations || relations.length === 0) {
1731
- return buildFieldEqualsFilter(fieldRef, false);
1732
- }
1733
- return negateRequiringHops(
1734
- operand,
1735
- buildPrismaFilterFromCerbosExpression(operand, mapper),
1736
- mapper
1737
- );
1738
- }
1739
-
1740
- // `and`/`or` must be pushed through with De Morgan whenever a branch can be UNKNOWN, so
1741
- // CEL's error absorption survives: `!(A && B)` with A erroring and B false is TRUE in CEL,
1742
- // and `OR[!A, !B]` reproduces that where a single outer NOT over the conjunction — with the
1743
- // hop requirement ANDed outside it — would deny. A chained relation is the second source of
1744
- // UNKNOWN besides the collection macros, so it opens the same push-down.
1745
- if (
1746
- isOperatorOperand(operand) &&
1747
- (containsCollectionOperator(operand) ||
1748
- referencesChainedRelation(operand, mapper))
1749
- ) {
1750
- switch (operand.operator) {
1751
- case "and":
1752
- return {
1753
- OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1754
- };
1755
- case "or":
1756
- return {
1757
- AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
1758
- };
1759
- case "not":
1760
- return buildPrismaFilterFromCerbosExpression(
1761
- assertDefined(
1762
- operand.operands[0],
1763
- "not operator requires an operand"
1764
- ),
1765
- mapper
1766
- );
1767
- case "exists":
1768
- case "all":
1769
- case "except":
1770
- case "exists_one":
1771
- case "filter":
1772
- return buildNegatedCollectionFilter(
1773
- operand.operator,
1774
- operand.operands,
1775
- mapper
1776
- );
1777
- }
1778
- }
1779
-
1780
- return negateRequiringHops(
1781
- operand,
1782
- buildPrismaFilterFromCerbosExpression(operand, mapper),
1783
- mapper
1784
- );
1785
- }
1786
-
1787
- function getTernaryOperands(operands: PlanExpressionOperand[]): {
1788
- condition: PlanExpressionOperand;
1789
- thenBranch: PlanExpressionOperand;
1790
- elseBranch: PlanExpressionOperand;
1791
- } {
1792
- if (operands.length !== 3) {
1793
- throw new Error(
1794
- `if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
1795
- );
1796
- }
1797
-
1798
- return {
1799
- condition: assertDefined(
1800
- operands[0],
1801
- "if (ternary) requires a condition operand"
1802
- ),
1803
- thenBranch: assertDefined(
1804
- operands[1],
1805
- "if (ternary) requires a then operand"
1806
- ),
1807
- elseBranch: assertDefined(
1808
- operands[2],
1809
- "if (ternary) requires an else operand"
1810
- ),
1811
- };
1812
- }
1813
-
1814
- function getConstantBooleanCondition(
1815
- condition: PlanExpressionOperand
1816
- ): boolean | undefined {
1817
- if (!isValueOperand(condition)) {
1818
- return undefined;
1819
- }
1820
- if (typeof condition.value !== "boolean") {
1821
- throw new Error("if (ternary) condition must be a boolean expression");
1822
- }
1823
- return condition.value;
1824
- }
1825
-
1826
- function buildBooleanBranchFilter(
1827
- branch: PlanExpressionOperand,
1828
- mapper: Mapper
1829
- ): TernaryBranchPredicate {
1830
- if (!isValueOperand(branch)) {
1831
- return {
1832
- kind: "filter",
1833
- filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
1834
- };
1835
- }
1836
- if (typeof branch.value !== "boolean") {
1837
- throw new Error(
1838
- "if (ternary) branch in boolean position must be a boolean"
1839
- );
1840
- }
1841
- return { kind: "constant", value: branch.value };
1842
- }
1843
-
1844
- /**
1845
- * "The ternary's condition is definitively FALSE", which is exactly buildNegatedFilter's
1846
- * contract — three-valued, so a condition that is UNKNOWN (a NULL column, an erroring lambda
1847
- * body, an absent to-one parent) selects NEITHER branch.
1848
- *
1849
- * It delegates rather than spelling a second negation. A private copy is how the ternary
1850
- * missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is TRUE when
1851
- * the to-one parent is absent, so the else-branch was selected for rows CEL denies outright —
1852
- * the missing path is an evaluation error, not a false condition
1853
- * (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
1854
- * which a chained `and`/`or` condition needs for CEL's error absorption to survive.
1855
- */
1856
- function buildFalseConditionFilter(
1857
- condition: PlanExpressionOperand,
1858
- mapper: Mapper
1859
- ): PrismaFilter {
1860
- if (isValueOperand(condition)) {
1861
- throw new Error("Constant ternary conditions must be folded before use");
1862
- }
1863
-
1864
- return buildNegatedFilter(condition, mapper);
1865
- }
1866
-
1867
- function buildGuardedTernaryFilter({
1868
- condition,
1869
- thenFilter,
1870
- elseFilter,
1871
- mapper,
1872
- }: {
1873
- condition: PlanExpressionOperand;
1874
- thenFilter: TernaryBranchPredicate;
1875
- elseFilter: TernaryBranchPredicate;
1876
- mapper: Mapper;
1877
- }): PrismaFilter {
1878
- const conditionTrue = buildPrismaFilterFromCerbosExpression(
1879
- condition,
1880
- mapper
1881
- );
1882
- const conditionFalse = buildFalseConditionFilter(condition, mapper);
1883
- const guardedBranches: PrismaFilter[] = [];
1884
-
1885
- if (thenFilter.kind === "filter") {
1886
- guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
1887
- } else if (thenFilter.value) {
1888
- guardedBranches.push(conditionTrue);
1889
- }
1890
-
1891
- if (elseFilter.kind === "filter") {
1892
- guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
1893
- } else if (elseFilter.value) {
1894
- guardedBranches.push(conditionFalse);
1895
- }
1896
-
1897
- // For a known condition this contradiction is false. If the condition is
1898
- // NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
1899
- // ternary UNKNOWN under an outer NOT instead of authorizing the row.
1900
- guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
1901
-
1902
- return {
1903
- OR: guardedBranches,
1904
- };
1905
- }
1906
-
1907
- function finalizeTernaryBranchPredicate(
1908
- predicate: TernaryBranchPredicate
1909
- ): PrismaFilter {
1910
- if (predicate.kind === "filter") {
1911
- return predicate.filter;
1912
- }
1913
- if (predicate.value) {
1914
- return buildAlwaysTrueFilter();
1915
- }
1916
- // Prisma has no model-agnostic always-false `where` shape: empty logical
1917
- // arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
1918
- throw new Error(
1919
- "A constant-false conditional predicate must be folded by the Cerbos planner"
1920
- );
1921
- }
1922
-
1923
- function handleBooleanTernaryOperator(
1924
- operands: PlanExpressionOperand[],
1925
- mapper: Mapper
1926
- ): PrismaFilter {
1927
- const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
1928
- const constantCondition = getConstantBooleanCondition(condition);
1929
- if (constantCondition !== undefined) {
1930
- return finalizeTernaryBranchPredicate(
1931
- buildBooleanBranchFilter(
1932
- constantCondition ? thenBranch : elseBranch,
1933
- mapper
1934
- )
1935
- );
1936
- }
1937
-
1938
- return buildGuardedTernaryFilter({
1939
- condition,
1940
- thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
1941
- elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
1942
- mapper,
1943
- });
1944
- }
1945
-
1946
- function buildTernaryComparisonBranch({
1947
- operator,
1948
- operands,
1949
- ternaryIndex,
1950
- branch,
1951
- mapper,
1952
- }: {
1953
- operator: string;
1954
- operands: PlanExpressionOperand[];
1955
- ternaryIndex: number;
1956
- branch: PlanExpressionOperand;
1957
- mapper: Mapper;
1958
- }): TernaryBranchPredicate {
1959
- const substitutedOperands = operands.map((operand, index) =>
1960
- index === ternaryIndex ? branch : operand
1961
- );
1962
- assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
1963
- assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
1964
-
1965
- const normalized = normalizeBinaryOperands(operator, substitutedOperands);
1966
-
1967
- const normalizedFirst = normalized.operands[0];
1968
- const normalizedSecond = normalized.operands[1];
1969
- if (
1970
- normalized.operands.length === 2 &&
1971
- normalizedFirst !== undefined &&
1972
- normalizedSecond !== undefined &&
1973
- isValueOperand(normalizedFirst) &&
1974
- isValueOperand(normalizedSecond)
1975
- ) {
1976
- return {
1977
- kind: "constant",
1978
- value: evaluateConstantComparison(
1979
- normalized.operator,
1980
- normalizedFirst.value,
1981
- normalizedSecond.value
1982
- ),
1983
- };
1984
- }
1985
-
1986
- return {
1987
- kind: "filter",
1988
- filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
1989
- };
1990
- }
1991
-
1992
- function tryHandleTernaryComparison(
1993
- operator: string,
1994
- operands: PlanExpressionOperand[],
1995
- mapper: Mapper
1996
- ): PrismaFilter | null {
1997
- if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
1998
- return null;
1999
- }
2000
-
2001
- const ternaryIndex = operands.findIndex(
2002
- (operand) => isOperatorOperand(operand) && operand.operator === "if"
2003
- );
2004
- if (ternaryIndex === -1) {
2005
- return null;
2006
- }
2007
- if (operands.length !== 2) {
2008
- throw new Error(
2009
- `${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
2010
- );
2011
- }
2012
-
2013
- const ternary = assertDefined(
2014
- operands[ternaryIndex],
2015
- "Ternary comparison operand is missing"
2016
- );
2017
- if (!isOperatorOperand(ternary)) {
2018
- throw new Error("Ternary comparison operand must be an expression");
2019
- }
2020
-
2021
- const { condition, thenBranch, elseBranch } = getTernaryOperands(
2022
- ternary.operands
2023
- );
2024
- const constantCondition = getConstantBooleanCondition(condition);
2025
- if (constantCondition !== undefined) {
2026
- return finalizeTernaryBranchPredicate(
2027
- buildTernaryComparisonBranch({
2028
- operator,
2029
- operands,
2030
- ternaryIndex,
2031
- branch: constantCondition ? thenBranch : elseBranch,
2032
- mapper,
2033
- })
2034
- );
2035
- }
2036
-
2037
- return buildGuardedTernaryFilter({
2038
- condition,
2039
- thenFilter: buildTernaryComparisonBranch({
2040
- operator,
2041
- operands,
2042
- ternaryIndex,
2043
- branch: thenBranch,
2044
- mapper,
2045
- }),
2046
- elseFilter: buildTernaryComparisonBranch({
2047
- operator,
2048
- operands,
2049
- ternaryIndex,
2050
- branch: elseBranch,
2051
- mapper,
2052
- }),
2053
- mapper,
2054
- });
2055
- }
2056
-
2057
- function evaluateConstantComparison(
2058
- operator: string,
2059
- left: Value,
2060
- right: Value
2061
- ): boolean {
2062
- switch (operator) {
2063
- case "eq":
2064
- return areValuesEqual(left, right);
2065
- case "ne":
2066
- return !areValuesEqual(left, right);
2067
- case "lt":
2068
- case "le":
2069
- case "gt":
2070
- case "ge": {
2071
- if (
2072
- typeof left === "number" &&
2073
- typeof right === "number" &&
2074
- (Number.isNaN(left) || Number.isNaN(right))
2075
- ) {
2076
- return false;
2077
- }
2078
- if (
2079
- !(
2080
- (typeof left === "number" && typeof right === "number") ||
2081
- (typeof left === "string" && typeof right === "string")
2082
- )
2083
- ) {
2084
- throw new Error(
2085
- `${operator} constant comparison requires two numbers or two strings`
2086
- );
2087
- }
2088
- switch (operator) {
2089
- case "lt":
2090
- return compareConstantValues(left, right) < 0;
2091
- case "le":
2092
- return compareConstantValues(left, right) <= 0;
2093
- case "gt":
2094
- return compareConstantValues(left, right) > 0;
2095
- case "ge":
2096
- return compareConstantValues(left, right) >= 0;
2097
- }
2098
- }
2099
- }
2100
-
2101
- throw new Error(`Unsupported constant comparison operator: ${operator}`);
2102
- }
2103
-
2104
- function compareConstantValues(left: number | string, right: number | string) {
2105
- if (typeof left === "number" && typeof right === "number") {
2106
- return left === right ? 0 : left < right ? -1 : 1;
2107
- }
2108
- if (typeof left !== "string" || typeof right !== "string") {
2109
- throw new Error("Cannot order constant values of different types");
2110
- }
2111
-
2112
- const leftCodePoints = Array.from(left, (character) =>
2113
- character.codePointAt(0)
2114
- );
2115
- const rightCodePoints = Array.from(right, (character) =>
2116
- character.codePointAt(0)
2117
- );
2118
- const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
2119
- for (let index = 0; index < sharedLength; index++) {
2120
- const leftCodePoint = leftCodePoints[index]!;
2121
- const rightCodePoint = rightCodePoints[index]!;
2122
- if (leftCodePoint !== rightCodePoint) {
2123
- return leftCodePoint < rightCodePoint ? -1 : 1;
2124
- }
2125
- }
2126
- return leftCodePoints.length === rightCodePoints.length
2127
- ? 0
2128
- : leftCodePoints.length < rightCodePoints.length
2129
- ? -1
2130
- : 1;
2131
- }
2132
-
2133
- function areValuesEqual(left: Value, right: Value): boolean {
2134
- if (Array.isArray(left)) {
2135
- return (
2136
- Array.isArray(right) &&
2137
- left.length === right.length &&
2138
- left.every((value, index) => areValuesEqual(value, right[index]!))
2139
- );
2140
- }
2141
-
2142
- if (Array.isArray(right)) {
2143
- return false;
2144
- }
2145
-
2146
- if (typeof left === "object" && left !== null) {
2147
- if (typeof right !== "object" || right === null) {
2148
- return false;
2149
- }
2150
- const leftKeys = Object.keys(left);
2151
- const rightKeys = Object.keys(right);
2152
- return (
2153
- leftKeys.length === rightKeys.length &&
2154
- leftKeys.every(
2155
- (key) => key in right && areValuesEqual(left[key]!, right[key]!)
2156
- )
2157
- );
2158
- }
2159
-
2160
- return left === right;
2161
- }
2162
-
2163
- function handleSizeComparison(
2164
- operator: string,
2165
- sizeOperand: OperatorOperand,
2166
- valueOperand: PlanExpressionOperand,
2167
- mapper: Mapper
2168
- ): PrismaFilter {
2169
- const collectionOperand = sizeOperand.operands[0];
2170
- if (!collectionOperand || !isNamedOperand(collectionOperand)) {
2171
- throw new Error("size operator requires a named collection operand");
2172
- }
2173
-
2174
- if (!isValueOperand(valueOperand)) {
2175
- throw new Error("size comparison requires a numeric value operand");
2176
- }
2177
-
2178
- const count = valueOperand.value;
2179
- if (typeof count !== "number") {
2180
- throw new Error("size comparison requires a numeric value");
2181
- }
2182
-
2183
- const isNonEmpty =
2184
- (operator === "gt" && count === 0) || (operator === "ge" && count === 1);
2185
-
2186
- const isEmpty =
2187
- (operator === "eq" && count === 0) ||
2188
- (operator === "lt" && count === 1) ||
2189
- (operator === "le" && count === 0);
2190
-
2191
- if (!isNonEmpty && !isEmpty) {
2192
- throw new Error(
2193
- `Unsupported size comparison: size(...) ${operator} ${count}`
2194
- );
2195
- }
2196
-
2197
- const { relations } = resolveFieldReference(collectionOperand.name, mapper);
2198
- if (!relations || relations.length === 0) {
2199
- throw new Error("size operator requires a relation mapping");
2200
- }
2201
-
2202
- const deepest = relations[relations.length - 1];
2203
- if (!deepest) {
2204
- throw new Error("size operator requires a relation mapping");
2205
- }
2206
-
2207
- const prismaOp = isNonEmpty ? "some" : "none";
2208
- const leafFilter = relationFilter(deepest, prismaOp, {});
2209
-
2210
- if (relations.length === 1) {
2211
- return leafFilter;
2212
- }
2213
-
2214
- return buildNestedRelationFilter(relations.slice(0, -1), leafFilter);
2215
- }
2216
-
2217
- /**
2218
- * Helper function to process relational operators (eq, ne, lt, etc.)
2219
- */
2220
- function handleRelationalOperator(
2221
- operator: string,
2222
- operands: PlanExpressionOperand[],
2223
- mapper: Mapper
2224
- ): PrismaFilter {
2225
- const ternaryFilter = tryHandleTernaryComparison(
2226
- operator,
2227
- operands,
2228
- mapper
2229
- );
2230
- if (ternaryFilter) {
2231
- return ternaryFilter;
2232
- }
2233
-
2234
- ({ operator, operands } = normalizeBinaryOperands(operator, operands));
2235
- const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
2236
-
2237
- if (!prismaOperator) {
2238
- throw new Error(`Unsupported operator: ${operator}`);
2239
- }
2240
-
2241
- const leftOperand = operands.find(
2242
- (o) => isNamedOperand(o) || isOperatorOperand(o)
2243
- );
2244
- if (!leftOperand) throw new Error("No valid left operand found");
2245
-
2246
- const rightOperand = operands.find((o) => o !== leftOperand);
2247
- if (!rightOperand) throw new Error("No valid right operand found");
2248
-
2249
- if (isOperatorOperand(leftOperand) && leftOperand.operator === "size") {
2250
- return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
2251
- }
2252
-
2253
- const arithOperand = [leftOperand, rightOperand].find(
2254
- (o): o is OperatorOperand =>
2255
- isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator)
2256
- );
2257
- if (arithOperand) {
2258
- const otherOperand =
2259
- arithOperand === leftOperand ? rightOperand : leftOperand;
2260
- return handleArithmeticComparison(
2261
- operator,
2262
- arithOperand,
2263
- otherOperand,
2264
- arithOperand === leftOperand,
2265
- mapper
2266
- );
2267
- }
2268
-
2269
- const mapOperand = [leftOperand, rightOperand].find(
2270
- (o): o is OperatorOperand =>
2271
- isOperatorOperand(o) && o.operator === "map"
2272
- );
2273
- if (mapOperand) {
2274
- throw new Error(
2275
- `Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
2276
- `Wrap the map() expression in hasIntersection(map(...), [...]) instead.`
2277
- );
2278
- }
2279
-
2280
- const left = resolveOperand(leftOperand, mapper);
2281
- const right = resolveOperand(rightOperand, mapper);
2282
-
2283
- if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
2284
- return buildFieldToFieldFilter(
2285
- operator,
2286
- leftOperand,
2287
- left,
2288
- rightOperand,
2289
- right
2290
- );
2291
- }
2292
-
2293
- if (isResolvedValue(left) && isResolvedFieldReference(right)) {
2294
- return buildComparisonFilter(
2295
- right,
2296
- MIRRORED_OPERATOR[operator] ?? operator,
2297
- left.value
2298
- );
2299
- }
2300
-
2301
- if (isResolvedValue(left) && isResolvedValue(right)) {
2302
- const matches = evaluateConstantComparison(
2303
- operator,
2304
- left.value,
2305
- right.value
2306
- );
2307
- if (matches) {
2308
- return buildAlwaysTrueFilter();
2309
- }
2310
- throw new Error(
2311
- "A constant-false conditional predicate must be folded by the Cerbos planner"
2312
- );
2313
- }
2314
-
2315
- const rightValue = requireResolvedValue(
2316
- right,
2317
- "Right operand must be a value"
2318
- );
2319
-
2320
- if (isResolvedFieldReference(left)) {
2321
- return buildComparisonFilter(left, operator, rightValue.value);
2322
- }
2323
-
2324
- return { [prismaOperator]: rightValue.value };
2325
- }
2326
-
2327
- /**
2328
- * Builds a field-vs-constant comparison. Fractional constants against ordering (and
2329
- * equality) operators are emitted as a two-clause bracket because Prisma coerces filter
2330
- * values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
2331
- * inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
2332
- * neighbor so the combination is correct BOTH for Float columns (the extra clause is
2333
- * redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
2334
- */
2335
- function buildComparisonFilter(
2336
- fieldRef: ResolvedFieldReference,
2337
- operator: string,
2338
- value: Value
2339
- ): PrismaFilter {
2340
- const prismaOperator = assertDefined(
2341
- CERBOS_TO_PRISMA_OPERATOR[operator],
2342
- `Unsupported operator: ${operator}`
2343
- );
2344
-
2345
- if (value === null) {
2346
- assertNullOperandTranslatable(
2347
- `\`${operator}\` against a null operand`,
2348
- fieldRef.nullAttributeRepresentation
2349
- );
2350
- }
2351
-
2352
- // An attribute the caller sends as an explicit null holds a null VALUE in CEL, so equality
2353
- // against a non-null operand is a definite FALSE and inequality a definite TRUE. Prisma leans
2354
- // on SQL's UNKNOWN for both, which drops the row under either polarity, so the presence of the
2355
- // column has to become part of the predicate. Confined to the equality family: an ordering
2356
- // comparison against a null receiver is a no-overload error in CEL, which denies exactly as the
2357
- // dropped row does.
2358
- if (value !== null && isExplicitNullReference(fieldRef)) {
2359
- const fieldName = getLeafField(fieldRef.path);
2360
- if (operator === "eq") {
2361
- return guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } });
2362
- }
2363
- if (operator === "ne") {
2364
- return {
2365
- NOT: guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } }),
2366
- };
2367
- }
2368
- }
2369
-
2370
- if (typeof value === "number" && !Number.isInteger(value)) {
2371
- const fieldName = getLeafField(fieldRef.path);
2372
- const lower = Math.floor(value);
2373
- const upper = Math.ceil(value);
2374
- const equalsBracket = {
2375
- AND: [
2376
- { [fieldName]: { equals: value } },
2377
- { [fieldName]: { gt: lower } },
2378
- { [fieldName]: { lt: upper } },
2379
- ],
2380
- };
2381
- let bracket: PrismaFilter | undefined;
2382
- switch (operator) {
2383
- case "gt":
2384
- bracket = {
2385
- OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
2386
- };
2387
- break;
2388
- case "ge":
2389
- bracket = {
2390
- AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
2391
- };
2392
- break;
2393
- case "lt":
2394
- bracket = {
2395
- OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
2396
- };
2397
- break;
2398
- case "le":
2399
- bracket = {
2400
- AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
2401
- };
2402
- break;
2403
- case "eq":
2404
- bracket = equalsBracket;
2405
- break;
2406
- case "ne":
2407
- bracket = { NOT: equalsBracket };
2408
- break;
2409
- }
2410
- if (bracket) {
2411
- return wrapRelations(fieldRef.relations, bracket);
2412
- }
2413
- }
2414
-
2415
- return buildFieldFilter(fieldRef, prismaOperator, value);
2416
- }
2417
-
2418
- /**
2419
- * Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
2420
- * references between fields of the SAME model, so the container is the root model for
2421
- * root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
2422
- * (an element column against an outer column) are cross-model and must fail loudly.
2423
- */
2424
- function buildFieldToFieldFilter(
2425
- operator: string,
2426
- leftOperand: PlanExpressionOperand,
2427
- left: ResolvedFieldReference,
2428
- rightOperand: PlanExpressionOperand,
2429
- right: ResolvedFieldReference
2430
- ): PrismaFilter {
2431
- const prismaOperator = assertDefined(
2432
- CERBOS_TO_PRISMA_OPERATOR[operator],
2433
- `Unsupported operator: ${operator}`
2434
- );
2435
-
2436
- if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
2437
- throw new Error("Field-to-field comparison requires two named operands");
2438
- }
2439
-
2440
- const scope = lambdaScopes[lambdaScopes.length - 1];
2441
- let container: string | undefined;
2442
-
2443
- if (scope) {
2444
- const prefix = scope.variableName + ".";
2445
- const leftInScope = leftOperand.name.startsWith(prefix);
2446
- const rightInScope = rightOperand.name.startsWith(prefix);
2447
- if (leftInScope !== rightInScope) {
2448
- throw new Error(
2449
- `Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
2450
- "Prisma field references only work between fields of the same model"
2451
- );
2452
- }
2453
- if (leftInScope) {
2454
- container = scope.relationModel;
2455
- if (!container) {
2456
- throw new Error(
2457
- "Field-to-field comparison inside a collection requires `relation.model` in the mapper"
2458
- );
2459
- }
2460
- }
2461
- }
2462
-
2463
- if (container === undefined) {
2464
- if (
2465
- (left.relations && left.relations.length > 0) ||
2466
- (right.relations && right.relations.length > 0)
2467
- ) {
2468
- throw new Error(
2469
- "Cannot compare columns across relations: Prisma field references only work between fields of the same model"
2470
- );
2471
- }
2472
- container = rootModelName;
2473
- if (!container) {
2474
- throw new Error(
2475
- "Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
2476
- );
2477
- }
2478
- }
2479
-
2480
- const leftField = getLeafField(left.path);
2481
- const rightField = getLeafField(right.path);
2482
- const fieldReference = { _ref: rightField, _container: container };
2483
-
2484
- // Two explicit nulls are EQUAL in CEL, and a null against a value is definitely NOT equal.
2485
- // A bare field reference gives UNKNOWN for both, which drops the row under either polarity.
2486
- const leftExplicit = isExplicitNullReference(left);
2487
- const rightExplicit = isExplicitNullReference(right);
2488
- // Mixing the two conventions across one comparison has no faithful rendering. The declared side
2489
- // needs a definite answer for its NULL (CEL holds a null VALUE); the undeclared side needs
2490
- // UNKNOWN for its NULL (a missing attribute, which CEL denies under both polarities). A definite
2491
- // predicate returns rows the PDP refuses; a plain one drops rows the PDP allows. Refuse it
2492
- // rather than pick a direction — declare both attributes, or neither.
2493
- if (leftExplicit !== rightExplicit && (operator === "eq" || operator === "ne")) {
2494
- throw new Error(
2495
- `Cannot translate \`${operator}\` between two columns under mixed null conventions: ` +
2496
- "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."
2497
- );
2498
- }
2499
- if (leftExplicit && rightExplicit && (operator === "eq" || operator === "ne")) {
2500
- const present: PrismaFilter[] = [];
2501
- if (leftExplicit) {
2502
- present.push({ [leftField]: { not: null } });
2503
- }
2504
- if (rightExplicit) {
2505
- present.push({ [rightField]: { not: null } });
2506
- }
2507
- const equality: PrismaFilter =
2508
- leftExplicit && rightExplicit
2509
- ? {
2510
- OR: [
2511
- { AND: [{ [leftField]: null }, { [rightField]: null }] },
2512
- { AND: [...present, { [leftField]: { equals: fieldReference } }] },
2513
- ],
2514
- }
2515
- : { AND: [...present, { [leftField]: { equals: fieldReference } }] };
2516
- return operator === "eq" ? equality : { NOT: equality };
2517
- }
2518
-
2519
- // `ne` is the one operator whose Prisma spelling cannot carry a field reference: `not` nests a
2520
- // NestedStringFilter, which has no `_ref` form, so `{ col: { not: { _ref } } }` is rejected by
2521
- // the client rather than executed. Hoisting the negation to the top-level `NOT` combinator says
2522
- // the same thing with a shape that does accept one — the same rewrite the explicit-null branch
2523
- // above already performs. Three-valued logic is unchanged by the hoist: a NULL on either side
2524
- // makes the inner equality UNKNOWN, and `NOT UNKNOWN` is still UNKNOWN, so the row stays out
2525
- // under both polarities, which is what a missing attribute does on the check side.
2526
- if (operator === "ne") {
2527
- return { NOT: { [leftField]: { equals: fieldReference } } };
2528
- }
2529
-
2530
- return { [leftField]: { [prismaOperator]: fieldReference } };
2531
- }
2532
-
2533
- /**
2534
- * Helper function to handle "in" operator
2535
- */
2536
- function handleInOperator(
2537
- operands: PlanExpressionOperand[],
2538
- mapper: Mapper
2539
- ): PrismaFilter {
2540
- if (operands.length !== 2) {
2541
- throw new Error("in requires exactly two operands");
2542
- }
2543
- const member = assertDefined(operands[0], "in requires a member operand");
2544
- const collection = assertDefined(
2545
- operands[1],
2546
- "in requires a collection operand"
2547
- );
2548
-
2549
- if (isNamedOperand(member) && isValueOperand(collection)) {
2550
- const fieldRef = resolveFieldReference(member.name, mapper);
2551
- const values = Array.isArray(collection.value)
2552
- ? collection.value
2553
- : [collection.value];
2554
- return buildMembershipFilter(fieldRef, values);
2555
- }
2556
-
2557
- if (isValueOperand(member) && isNamedOperand(collection)) {
2558
- return buildMembershipFilter(
2559
- resolveFieldReference(collection.name, mapper),
2560
- [member.value]
2561
- );
2562
- }
2563
-
2564
- if (isNamedOperand(member) && isNamedOperand(collection)) {
2565
- throw new Error(
2566
- "Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column"
2567
- );
2568
- }
2569
-
2570
- throw new Error(
2571
- "in requires a field member and literal list, or a literal member and field collection"
2572
- );
2573
- }
2574
-
2575
- // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
2576
- const MAX_ENUMERATED_NEEDLES = 1000;
2577
-
2578
- /**
2579
- * Helper function to handle string operators (contains, startsWith, endsWith).
2580
- *
2581
- * These operators are receiver-sensitive and the planner preserves policy source order:
2582
- * operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
2583
- * inverts the match direction, so unlike symmetric comparisons they are never normalized.
2584
- */
2585
- function handleStringOperator(
2586
- operator: string,
2587
- operands: PlanExpressionOperand[],
2588
- mapper: Mapper
2589
- ): PrismaFilter {
2590
- if (operands.length !== 2) {
2591
- throw new Error(`${operator} requires exactly two operands`);
2592
- }
2593
- const receiver = resolveOperand(
2594
- assertDefined(operands[0], `${operator} requires a receiver operand`),
2595
- mapper
2596
- );
2597
- const needle = resolveOperand(
2598
- assertDefined(operands[1], `${operator} requires a needle operand`),
2599
- mapper
2600
- );
2601
-
2602
- // Column receiver, constant needle: Prisma's LIKE-based filter.
2603
- if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
2604
- const { value } = needle;
2605
- if (typeof value !== "string") {
2606
- throw new Error(`${operator} operator requires string value`);
2607
- }
2608
- // Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
2609
- // so a needle containing % or _ would match as a pattern instead of literally (e.g.
2610
- // startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
2611
- //
2612
- // A backslash is the same hazard reached from the other direction, and only an executed
2613
- // PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
2614
- // character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
2615
- // DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
2616
- // ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
2617
- // silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
2618
- if (/[%_\\]/.test(value)) {
2619
- throw new Error(
2620
- `Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
2621
- "Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
2622
- "escape character on PostgreSQL and MySQL"
2623
- );
2624
- }
2625
- const fieldName = getLeafField(receiver.path);
2626
- return wrapRelations(receiver.relations, {
2627
- [fieldName]: { [operator]: value },
2628
- });
2629
- }
2630
-
2631
- // Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
2632
- // candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
2633
- // substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
2634
- // NULL needle column stays excluded (CEL missing-attribute deny).
2635
- if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
2636
- if (typeof receiver.value !== "string") {
2637
- throw new Error(`${operator} operator requires a string receiver`);
2638
- }
2639
- const haystack = receiver.value;
2640
- const candidates = new Set<string>([""]);
2641
- if (operator === "startsWith") {
2642
- for (let i = 1; i <= haystack.length; i++) {
2643
- candidates.add(haystack.slice(0, i));
2644
- }
2645
- } else if (operator === "endsWith") {
2646
- for (let i = 0; i < haystack.length; i++) {
2647
- candidates.add(haystack.slice(i));
2648
- }
2649
- } else {
2650
- for (let start = 0; start < haystack.length; start++) {
2651
- for (let end = start + 1; end <= haystack.length; end++) {
2652
- candidates.add(haystack.slice(start, end));
2653
- }
2654
- }
2655
- }
2656
- if (candidates.size > MAX_ENUMERATED_NEEDLES) {
2657
- throw new Error(
2658
- `Cannot translate ${operator} with a constant receiver of this length: ` +
2659
- `enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
2660
- );
2661
- }
2662
- const fieldName = getLeafField(needle.path);
2663
- return wrapRelations(needle.relations, {
2664
- [fieldName]: { in: [...candidates] },
2665
- });
2666
- }
2667
-
2668
- if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
2669
- // A column-valued needle would need its LIKE metacharacters escaped per row, which
2670
- // Prisma's filters cannot do (and Prisma leaks wildcards from field references).
2671
- throw new Error(
2672
- `Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
2673
- );
2674
- }
2675
-
2676
- throw new Error(
2677
- `${operator} between two constants must be folded by the Cerbos planner`
2678
- );
2679
- }
2680
-
2681
- /**
2682
- * Helper function to handle "hasIntersection" operator
2683
- */
2684
- function handleHasIntersectionOperator(
2685
- operands: PlanExpressionOperand[],
2686
- mapper: Mapper
2687
- ): PrismaFilter {
2688
- if (operands.length !== 2) {
2689
- throw new Error("hasIntersection requires exactly two operands");
2690
- }
2691
-
2692
- // Intersection is symmetric, and the planner preserves policy source order — e.g.
2693
- // `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
2694
- // field/map-first (see #256).
2695
- ({ operands } = normalizeBinaryOperands("hasIntersection", operands));
2696
-
2697
- const leftOperand = assertDefined(
2698
- operands[0],
2699
- "hasIntersection requires a left operand"
2700
- );
2701
- const rightOperand = assertDefined(
2702
- operands[1],
2703
- "hasIntersection requires a right operand"
2704
- );
2705
-
2706
- // Check if left operand is a map operation
2707
- if (isOperatorOperand(leftOperand) && leftOperand.operator === "map") {
2708
- if (!isValueOperand(rightOperand)) {
2709
- throw new Error("Second operand of hasIntersection must be a value");
2710
- }
2711
-
2712
- const collection = assertDefined(
2713
- leftOperand.operands[0],
2714
- "Map expression must include a collection reference"
2715
- );
2716
- const lambda = assertDefined(
2717
- leftOperand.operands[1],
2718
- "Map expression must include a lambda expression"
2719
- );
2720
-
2721
- if (!isNamedOperand(collection)) {
2722
- throw new Error("First operand of map must be a collection reference");
2723
- }
2724
-
2725
- // Get variable name from lambda
2726
- if (!isOperatorOperand(lambda)) {
2727
- throw new Error("Lambda expression must have operands");
2728
- }
2729
-
2730
- const variable = assertDefined(
2731
- lambda.operands[1],
2732
- "Lambda variable must have a name"
2733
- );
2734
- if (!isNamedOperand(variable)) {
2735
- throw new Error("Lambda variable must have a name");
2736
- }
2737
-
2738
- // Create scoped mapper for the collection
2739
- const scopedMapper = createScopedMapper(
2740
- collection.name,
2741
- variable.name,
2742
- mapper
2743
- );
2744
-
2745
- const { relations } = resolveFieldReference(collection.name, mapper);
2746
- if (!relations || relations.length === 0) {
2747
- throw new Error("Map operation requires relations");
2748
- }
2749
-
2750
- const projection = assertDefined(
2751
- lambda.operands[0],
2752
- "Invalid map lambda expression structure"
2753
- );
2754
- if (!isNamedOperand(projection)) {
2755
- throw new Error("Invalid map lambda expression structure");
2756
- }
2757
-
2758
- // Use scoped mapper for resolving the projection, collecting nullable element columns.
2759
- const scope: LambdaScope = {
2760
- variableName: variable.name,
2761
- relationModel: relations[relations.length - 1]?.model,
2762
- nullableFields: new Set(),
2763
- unknownFilters: [],
2764
- };
2765
- lambdaScopes.push(scope);
2766
- let resolved: ResolvedFieldReference;
2767
- try {
2768
- resolved = resolveFieldReference(projection.name, scopedMapper);
2769
- } finally {
2770
- lambdaScopes.pop();
2771
- }
2772
- const fieldName = getLeafField(resolved.path);
2773
-
2774
- const base = buildNestedRelationFilter(relations, {
2775
- [fieldName]: { in: rightOperand.value },
2776
- });
2777
- if (scope.nullableFields.size === 0) {
2778
- return base;
2779
- }
2780
- // CEL map() errors if ANY element is missing the projected attribute — even alongside a
2781
- // matching element — and Cerbos treats that error as deny. Exclude rows holding an
2782
- // element whose projected (nullable) column is NULL.
2783
- return {
2784
- AND: [
2785
- base,
2786
- {
2787
- NOT: buildNestedRelationFilter(
2788
- relations,
2789
- buildNullWitnessFilter(scope.nullableFields)
2790
- ),
2791
- },
2792
- ],
2793
- };
2794
- }
2795
-
2796
- // Handle regular field reference
2797
- if (!isNamedOperand(leftOperand)) {
2798
- throw new Error(
2799
- "First operand of hasIntersection must be a field reference or map expression"
2800
- );
2801
- }
2802
-
2803
- if (!isValueOperand(rightOperand)) {
2804
- throw new Error("Second operand of hasIntersection must be a value");
2805
- }
2806
-
2807
- const { path, relations } = resolveFieldReference(leftOperand.name, mapper);
2808
-
2809
- if (!Array.isArray(rightOperand.value)) {
2810
- throw new Error("hasIntersection requires an array value");
2811
- }
2812
-
2813
- if (relations && relations.length > 0) {
2814
- return buildMembershipFilter({ path, relations }, rightOperand.value);
2815
- }
2816
-
2817
- const fieldName = getLeafField(path);
2818
- return { [fieldName]: { some: rightOperand.value } };
2819
- }
2820
-
2821
- /**
2822
- * Helper function to handle "lambda" operator
2823
- */
2824
- function handleLambdaOperator(operands: PlanExpressionOperand[]): PrismaFilter {
2825
- const condition = assertDefined(
2826
- operands[0],
2827
- "Lambda requires a condition operand"
2828
- );
2829
- const variable = assertDefined(
2830
- operands[1],
2831
- "Lambda requires a variable operand"
2832
- );
2833
-
2834
- if (!isNamedOperand(variable)) {
2835
- throw new Error("Lambda variable must have a name");
2836
- }
2837
-
2838
- return buildPrismaFilterFromCerbosExpression(condition, (key: string) => ({
2839
- field: key.replace(`${variable.name}.`, ""),
2840
- }));
2841
- }
2842
-
2843
- type CollectionLambdaParts = {
2844
- head: RelationConfig;
2845
- restRelations: RelationConfig[];
2846
- /** Element predicate, already wrapped through any relations beyond the first. */
2847
- filterValue: PrismaFilter;
2848
- /** Nullable element columns referenced by the lambda body (for 3VL guards). */
2849
- nullableFields: Set<string>;
2850
- /** Element-level predicates under which a nested collection expression is UNKNOWN. */
2851
- unknownFilters: PrismaFilter[];
2852
- };
2853
-
2854
- function buildCollectionLambdaParts(
2855
- operator: string,
2856
- operands: PlanExpressionOperand[],
2857
- mapper: Mapper
2858
- ): CollectionLambdaParts {
2859
- if (operands.length !== 2) {
2860
- throw new Error(`${operator} requires exactly two operands`);
2861
- }
2862
-
2863
- const collection = assertDefined(
2864
- operands[0],
2865
- `${operator} requires a collection operand`
2866
- );
2867
- const lambda = assertDefined(
2868
- operands[1],
2869
- `${operator} requires a lambda operand`
2870
- );
2871
-
2872
- if (!isNamedOperand(collection)) {
2873
- throw new Error(
2874
- `First operand of ${operator} must be a collection reference`
2875
- );
2876
- }
2877
-
2878
- if (!isOperatorOperand(lambda)) {
2879
- throw new Error(
2880
- `Second operand of ${operator} must be a lambda expression`
2881
- );
2882
- }
2883
-
2884
- // Get variable name from lambda
2885
- const variable = assertDefined(
2886
- lambda.operands[1],
2887
- "Lambda variable must have a name"
2888
- );
2889
- if (!isNamedOperand(variable)) {
2890
- throw new Error("Lambda variable must have a name");
2891
- }
2892
-
2893
- // Create scoped mapper for the collection
2894
- const scopedMapper = createScopedMapper(
2895
- collection.name,
2896
- variable.name,
2897
- mapper
2898
- );
2899
-
2900
- const { relations } = resolveFieldReference(collection.name, mapper);
2901
- if (!relations || relations.length === 0) {
2902
- throw new Error(`${operator} operator requires a relation mapping`);
2903
- }
2904
-
2905
- const head = assertDefined(
2906
- relations[0],
2907
- `${operator} operator requires a relation mapping`
2908
- );
2909
- const restRelations = relations.slice(1);
2910
- const deepest = assertDefined(
2911
- relations[relations.length - 1],
2912
- `${operator} operator requires a relation mapping`
2913
- );
2914
-
2915
- const lambdaConditionOperand = assertDefined(
2916
- lambda.operands[0],
2917
- "Lambda expression must provide a condition"
2918
- );
2919
-
2920
- const scope: LambdaScope = {
2921
- variableName: variable.name,
2922
- relationModel: deepest.model,
2923
- nullableFields: new Set(),
2924
- unknownFilters: [],
2925
- };
2926
- lambdaScopes.push(scope);
2927
- let lambdaCondition: PrismaFilter;
2928
- try {
2929
- lambdaCondition = buildPrismaFilterFromCerbosExpression(
2930
- lambdaConditionOperand, // Use the condition part of the lambda
2931
- scopedMapper
2932
- );
2933
- } finally {
2934
- lambdaScopes.pop();
2935
- }
2936
-
2937
- let filterValue = lambdaCondition;
2938
-
2939
- if (restRelations.length > 0) {
2940
- // Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
2941
- // of the chain, so the element predicate must join through every intermediate hop.
2942
- filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
2943
- } else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
2944
- // If the lambda condition already has a logical structure, use it as-is
2945
- filterValue = lambdaCondition;
2946
- } else {
2947
- const lambdaKeys = Object.keys(lambdaCondition);
2948
- const defaultKey = lambdaKeys[0];
2949
- if (!defaultKey) {
2950
- throw new Error("Lambda condition must have at least one field");
2951
- }
2952
- const lambdaFieldValue = lambdaCondition[defaultKey];
2953
- if (lambdaFieldValue === undefined) {
2954
- throw new Error("Lambda condition field value cannot be undefined");
2955
- }
2956
- const filterField = head.field || defaultKey;
2957
- filterValue = {
2958
- [filterField]: lambdaFieldValue,
2959
- };
2960
- }
2961
-
2962
- return {
2963
- head,
2964
- restRelations,
2965
- filterValue,
2966
- nullableFields: scope.nullableFields,
2967
- unknownFilters: scope.unknownFilters,
2968
- };
2969
- }
2970
-
2971
- /** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
2972
- function buildNullWitnessFilter(nullableFields: Set<string>): PrismaFilter {
2973
- const checks = [...nullableFields].map((field) => ({ [field]: null }));
2974
- if (checks.length === 1) {
2975
- return checks[0]!;
2976
- }
2977
- return { OR: checks };
2978
- }
2979
-
2980
- function buildUnknownElementFilter(
2981
- parts: CollectionLambdaParts
2982
- ): PrismaFilter | undefined {
2983
- const filters = [...parts.unknownFilters];
2984
- if (parts.nullableFields.size > 0) {
2985
- filters.push(buildNullWitnessFilter(parts.nullableFields));
2986
- }
2987
- if (filters.length === 0) {
2988
- return undefined;
2989
- }
2990
- if (filters.length === 1) {
2991
- return filters[0];
2992
- }
2993
- return { OR: filters };
2994
- }
2995
-
2996
- function recordNestedCollectionUnknownFilter(filter: PrismaFilter): void {
2997
- const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
2998
- enclosingScope?.unknownFilters.push(filter);
2999
- }
3000
-
3001
- /** Wraps an element-level filter through the full relation chain (head + rest). */
3002
- function wrapCollectionElementFilter(
3003
- parts: CollectionLambdaParts,
3004
- elementFilter: PrismaFilter
3005
- ): PrismaFilter {
3006
- const inner =
3007
- parts.restRelations.length > 0
3008
- ? buildNestedRelationFilter(parts.restRelations, elementFilter)
3009
- : elementFilter;
3010
- return relationFilter(parts.head, "some", inner);
3011
- }
3012
-
3013
- function throwUnsupportedCollectionOperator(operator: string): never {
3014
- if (operator === "exists_one") {
3015
- throw new Error(
3016
- "exists_one requires counting matching elements, which Prisma where-filters cannot express"
3017
- );
3018
- }
3019
- throw new Error(
3020
- "The filter() collection operator returns a list, not a boolean. " +
3021
- "It cannot be used as a standalone condition. " +
3022
- "Use exists() or combine filter() with size() instead."
3023
- );
3024
- }
3025
-
3026
- /**
3027
- * Helper function to handle collection operators (exists, all, except, filter)
3028
- */
3029
- function handleCollectionOperator(
3030
- operator: string,
3031
- operands: PlanExpressionOperand[],
3032
- mapper: Mapper
3033
- ): PrismaFilter {
3034
- const knownValueFilter = tryHandleKnownValueCollectionOperator(
3035
- operator,
3036
- operands,
3037
- mapper,
3038
- false
3039
- );
3040
- if (knownValueFilter !== undefined) {
3041
- return knownValueFilter;
3042
- }
3043
-
3044
- if (operator === "exists_one" || operator === "filter") {
3045
- throwUnsupportedCollectionOperator(operator);
3046
- }
3047
-
3048
- const parts = buildCollectionLambdaParts(operator, operands, mapper);
3049
- const { head, filterValue, nullableFields } = parts;
3050
-
3051
- switch (operator) {
3052
- case "exists": {
3053
- // A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
3054
- // false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
3055
- const filter = relationFilter(head, "some", filterValue);
3056
- const unknownElement = buildUnknownElementFilter(parts);
3057
- if (unknownElement !== undefined) {
3058
- recordNestedCollectionUnknownFilter({
3059
- AND: [
3060
- { NOT: filter },
3061
- wrapCollectionElementFilter(parts, unknownElement),
3062
- ],
3063
- });
3064
- }
3065
- return filter;
3066
- }
3067
- case "except":
3068
- return relationFilter(head, "some", { NOT: filterValue });
3069
- case "all": {
3070
- if (parts.restRelations.length > 0) {
3071
- throw new Error(
3072
- "all() over a multi-hop relation chain is not supported"
3073
- );
3074
- }
3075
- const base = relationFilter(head, "every", filterValue);
3076
- if (nullableFields.size === 0) {
3077
- return base;
3078
- }
3079
- // CEL all() errors when any element evaluation errors without a false witness; SQL
3080
- // `every` would treat those elements as vacuously passing. Exclude rows holding any
3081
- // element whose referenced nullable column is NULL. (`every` already rejects rows
3082
- // with a definitive false witness, matching error absorption.)
3083
- return {
3084
- AND: [
3085
- base,
3086
- relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
3087
- ],
3088
- };
3089
- }
3090
- default:
3091
- throw new Error(`Unexpected operator: ${operator}`);
3092
- }
3093
- }
3094
-
3095
- /**
3096
- * Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
3097
- * buildNegatedFilter).
3098
- */
3099
- function buildNegatedCollectionFilter(
3100
- operator: string,
3101
- operands: PlanExpressionOperand[],
3102
- mapper: Mapper
3103
- ): PrismaFilter {
3104
- const knownValueFilter = tryHandleKnownValueCollectionOperator(
3105
- operator,
3106
- operands,
3107
- mapper,
3108
- true
3109
- );
3110
- if (knownValueFilter !== undefined) {
3111
- return knownValueFilter;
3112
- }
3113
-
3114
- if (operator === "exists_one" || operator === "filter") {
3115
- throwUnsupportedCollectionOperator(operator);
3116
- }
3117
-
3118
- const parts = buildCollectionLambdaParts(operator, operands, mapper);
3119
- const { head, filterValue, nullableFields } = parts;
3120
- // An absent to-one parent must stay denied under negation rather than satisfying it
3121
- // vacuously (#309).
3122
- const leadingHopsExist = buildLeadingHopsExistFilter(
3123
- head,
3124
- parts.restRelations
3125
- );
3126
- const requireLeadingHops = (filter: PrismaFilter): PrismaFilter =>
3127
- leadingHopsExist === undefined
3128
- ? filter
3129
- : { AND: [leadingHopsExist, filter] };
3130
-
3131
- switch (operator) {
3132
- case "exists": {
3133
- const base = requireLeadingHops({
3134
- NOT: relationFilter(head, "some", filterValue),
3135
- });
3136
- const unknownElement = buildUnknownElementFilter(parts);
3137
- if (unknownElement === undefined) {
3138
- return base;
3139
- }
3140
- // !exists is TRUE only when every element is definitively false: no P-match AND no
3141
- // element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
3142
- return {
3143
- AND: [
3144
- base,
3145
- {
3146
- NOT: wrapCollectionElementFilter(
3147
- parts,
3148
- unknownElement
3149
- ),
3150
- },
3151
- ],
3152
- };
3153
- }
3154
- case "all":
3155
- // !all is TRUE only with a definitive false witness, which also absorbs error
3156
- // elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
3157
- return relationFilter(head, "some", { NOT: filterValue });
3158
- case "except": {
3159
- // !except(c,P) = every element definitively matches P.
3160
- const base = requireLeadingHops(
3161
- relationFilter(head, "none", { NOT: filterValue })
3162
- );
3163
- if (nullableFields.size === 0) {
3164
- return base;
3165
- }
3166
- return {
3167
- AND: [
3168
- base,
3169
- relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
3170
- ],
3171
- };
3172
- }
3173
- default:
3174
- throw new Error(`Unexpected operator: ${operator}`);
3175
- }
3176
- }
3177
-
3178
- /**
3179
- * Helper function to handle "map" operator
3180
- */
3181
- function handleMapOperator(
3182
- operands: PlanExpressionOperand[],
3183
- mapper: Mapper
3184
- ): PrismaFilter {
3185
- if (operands.length !== 2) {
3186
- throw new Error("map requires exactly two operands");
3187
- }
3188
-
3189
- const collection = assertDefined(
3190
- operands[0],
3191
- "map requires a collection operand"
3192
- );
3193
- const lambda = assertDefined(
3194
- operands[1],
3195
- "map requires a lambda operand"
3196
- );
3197
-
3198
- if (!isNamedOperand(collection)) {
3199
- throw new Error("First operand of map must be a collection reference");
3200
- }
3201
-
3202
- if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
3203
- throw new Error("Second operand of map must be a lambda expression");
3204
- }
3205
-
3206
- // Get variable name from lambda
3207
- const projection = assertDefined(
3208
- lambda.operands[0],
3209
- "Map lambda expression must provide a projection"
3210
- );
3211
- const variable = assertDefined(
3212
- lambda.operands[1],
3213
- "Map lambda expression must provide a variable"
3214
- );
3215
- if (!isNamedOperand(projection) || !isNamedOperand(variable)) {
3216
- throw new Error("Invalid map lambda expression structure");
3217
- }
3218
-
3219
- // Create scoped mapper for the collection
3220
- const scopedMapper = createScopedMapper(
3221
- collection.name,
3222
- variable.name,
3223
- mapper
3224
- );
3225
-
3226
- const { relations } = resolveFieldReference(collection.name, mapper);
3227
- if (!relations || relations.length === 0) {
3228
- throw new Error("map operator requires a relation mapping");
3229
- }
3230
-
3231
- // Use scoped mapper for resolving the projection
3232
- const resolved = resolveFieldReference(projection.name, scopedMapper);
3233
- const fieldName = getLeafField(resolved.path);
3234
- const lastRelation = assertDefined(
3235
- relations[relations.length - 1],
3236
- "Relation mapping must contain at least one relation"
3237
- );
3238
-
3239
- return buildNestedRelationFilter(relations, {
3240
- [getPrismaRelationOperator(lastRelation)]: {
3241
- select: { [fieldName]: true },
3242
- },
3243
- });
3244
- }
3245
-
3246
- function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
3247
- return buildFieldFilter(fieldRef, "in", []);
3248
- }
3249
-
3250
- const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
3251
-
3252
- /**
3253
- * Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
3254
- * column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
3255
- * one constant side always rewrites to a plain comparison. Multiplying/dividing by a
3256
- * negative constant mirrors directional operators. The rewrite happens in IEEE double space,
3257
- * matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
3258
- * (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
3259
- * exactly as CEL evaluates it).
3260
- */
3261
- function handleArithmeticComparison(
3262
- operator: string,
3263
- arithExpr: OperatorOperand,
3264
- otherOperand: PlanExpressionOperand,
3265
- arithIsLeft: boolean,
3266
- mapper: Mapper
3267
- ): PrismaFilter {
3268
- const arithOp = arithExpr.operator;
3269
- const arithLeftOp = assertDefined(
3270
- arithExpr.operands[0],
3271
- `${arithOp} operator requires a left operand`
3272
- );
3273
- const arithRightOp = assertDefined(
3274
- arithExpr.operands[1],
3275
- `${arithOp} operator requires a right operand`
3276
- );
3277
-
3278
- if (
3279
- isOperatorOperand(otherOperand) &&
3280
- ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
3281
- tryFoldValueExpression(otherOperand, mapper) === null
3282
- ) {
3283
- throw new Error(
3284
- "Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter"
3285
- );
3286
- }
3287
-
3288
- const arithLeft = resolveOperand(arithLeftOp, mapper);
3289
- const arithRight = resolveOperand(arithRightOp, mapper);
3290
- const other = resolveOperand(otherOperand, mapper);
3291
-
3292
- // Fully constant arithmetic: fold and compare the other side against the result. The
3293
- // arithmetic side keeps its source position, so when the folded constant was written
3294
- // FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
3295
- if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
3296
- const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
3297
- if (!isResolvedFieldReference(other)) {
3298
- throw new Error(
3299
- `${arithOp} with two values requires a field reference on the other side`
3300
- );
3301
- }
3302
- const effectiveOperator = arithIsLeft
3303
- ? (MIRRORED_OPERATOR[operator] ?? operator)
3304
- : operator;
3305
- return buildComparisonFilter(other, effectiveOperator, folded);
3306
- }
3307
-
3308
- if (!isResolvedValue(other)) {
3309
- throw new Error(
3310
- `${arithOp} operator with field references requires a value on the other side of the comparison`
3311
- );
3312
- }
3313
-
3314
- let fieldRef: ResolvedFieldReference;
3315
- let constant: Value;
3316
- let fieldIsLeft: boolean;
3317
-
3318
- if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
3319
- fieldRef = arithLeft;
3320
- constant = arithRight.value;
3321
- fieldIsLeft = true;
3322
- } else if (
3323
- isResolvedValue(arithLeft) &&
3324
- isResolvedFieldReference(arithRight)
3325
- ) {
3326
- fieldRef = arithRight;
3327
- constant = arithLeft.value;
3328
- fieldIsLeft = false;
3329
- } else {
3330
- throw new Error(
3331
- `${arithOp} operator requires exactly one field reference and one value, or two values`
3332
- );
3333
- }
3334
-
3335
- // The comparison operator was normalized arithmetic-side-first by the caller; if the
3336
- // arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
3337
- let effectiveOperator = arithIsLeft
3338
- ? operator
3339
- : (MIRRORED_OPERATOR[operator] ?? operator);
3340
-
3341
- // String concatenation solving (eq/ne only).
3342
- if (arithOp === "add" && typeof constant === "string") {
3343
- if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
3344
- throw new Error(
3345
- `Operator ${effectiveOperator} is not supported with string concatenation`
3346
- );
3347
- }
3348
- const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
3349
- if (solvedValue === null) {
3350
- if (effectiveOperator === "eq") return buildImpossibleFilter(fieldRef);
3351
- return {};
3352
- }
3353
- return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
3354
- }
3355
-
3356
- if (typeof constant !== "number" || typeof other.value !== "number") {
3357
- throw new Error(`${arithOp} comparison requires numeric operands`);
3358
- }
3359
-
3360
- if (
3361
- arithOp === "add" &&
3362
- (effectiveOperator === "eq" || effectiveOperator === "ne") &&
3363
- (!Number.isInteger(constant) || !Number.isInteger(other.value))
3364
- ) {
3365
- throw new Error(
3366
- "Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
3367
- );
3368
- }
3369
-
3370
- let solved: number;
3371
- switch (arithOp) {
3372
- case "add":
3373
- solved = other.value - constant;
3374
- break;
3375
- case "sub":
3376
- if (fieldIsLeft) {
3377
- // f - c CMP v ⇔ f CMP v + c
3378
- solved = other.value + constant;
3379
- } else {
3380
- // c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
3381
- solved = constant - other.value;
3382
- effectiveOperator =
3383
- MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
3384
- }
3385
- break;
3386
- case "mult":
3387
- if (constant === 0) {
3388
- throw new Error(
3389
- "Multiplication by a constant zero must be folded by the Cerbos planner"
3390
- );
3391
- }
3392
- solved = other.value / constant;
3393
- if (constant < 0) {
3394
- effectiveOperator =
3395
- MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
3396
- }
3397
- break;
3398
- case "div":
3399
- if (!fieldIsLeft) {
3400
- throw new Error(
3401
- "Division by a column is not supported: the comparison cannot be solved to a plain column filter"
3402
- );
3403
- }
3404
- if (constant === 0) {
3405
- throw new Error("Division by a constant zero is not supported");
3406
- }
3407
- solved = other.value * constant;
3408
- if (constant < 0) {
3409
- effectiveOperator =
3410
- MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
3411
- }
3412
- break;
3413
- default:
3414
- throw new Error(`Unsupported operator: ${arithOp}`);
3415
- }
3416
-
3417
- return buildComparisonFilter(fieldRef, effectiveOperator, solved);
3418
- }
3419
-
3420
- function foldArithmetic(operator: string, left: Value, right: Value): Value {
3421
- if (
3422
- operator === "add" &&
3423
- (typeof left === "string" || typeof right === "string")
3424
- ) {
3425
- return String(left) + String(right);
3426
- }
3427
- if (typeof left !== "number" || typeof right !== "number") {
3428
- throw new Error(`${operator} operator requires string or number operands`);
3429
- }
3430
- switch (operator) {
3431
- case "add":
3432
- return left + right;
3433
- case "sub":
3434
- return left - right;
3435
- case "mult":
3436
- return left * right;
3437
- case "div":
3438
- return left / right;
3439
- default:
3440
- throw new Error(`Unsupported operator: ${operator}`);
3441
- }
3442
- }
3443
-
3444
- function solveAdd(
3445
- comparisonValue: Value,
3446
- addConstant: Value,
3447
- fieldIsLeft: boolean
3448
- ): Value | null {
3449
- if (typeof comparisonValue === "string" && typeof addConstant === "string") {
3450
- if (fieldIsLeft) {
3451
- if (!comparisonValue.endsWith(addConstant)) return null;
3452
- return comparisonValue.slice(
3453
- 0,
3454
- comparisonValue.length - addConstant.length
3455
- );
3456
- }
3457
- if (!comparisonValue.startsWith(addConstant)) return null;
3458
- return comparisonValue.slice(addConstant.length);
3459
- }
3460
- if (typeof comparisonValue === "number" && typeof addConstant === "number") {
3461
- return comparisonValue - addConstant;
3462
- }
3463
- throw new Error("Type mismatch in add comparison");
3464
- }
3465
-
3466
- type ConstantSegment = { type: "constant"; value: string };
3467
- type FieldSegment = { type: "field"; fieldRef: ResolvedFieldReference };
3468
- type HierarchySegment = ConstantSegment | FieldSegment;
3469
-
3470
- type ConstantHierarchy = {
3471
- type: "constant";
3472
- segments: string[];
3473
- raw: string;
3474
- delimiter: string;
3475
- };
3476
-
3477
- type FieldHierarchy = {
3478
- type: "field";
3479
- fieldRef: ResolvedFieldReference;
3480
- delimiter: string;
3481
- };
3482
-
3483
- type SegmentedHierarchy = {
3484
- type: "segmented";
3485
- segments: HierarchySegment[];
3486
- };
3487
-
3488
- type ResolvedHierarchy = ConstantHierarchy | FieldHierarchy | SegmentedHierarchy;
3489
-
3490
- function resolveHierarchy(
3491
- expr: OperatorOperand,
3492
- mapper: Mapper
3493
- ): ResolvedHierarchy {
3494
- const operands = expr.operands;
3495
-
3496
- if (operands.length === 2) {
3497
- const strOperand = assertDefined(operands[0], "hierarchy requires operands");
3498
- const delimOperand = assertDefined(
3499
- operands[1],
3500
- "hierarchy requires a delimiter"
3501
- );
3502
- if (!isValueOperand(delimOperand)) {
3503
- throw new Error("hierarchy delimiter must be a value");
3504
- }
3505
- const delimiter = String(delimOperand.value);
3506
-
3507
- if (isValueOperand(strOperand)) {
3508
- const raw = String(strOperand.value);
3509
- return { type: "constant", segments: raw.split(delimiter), raw, delimiter };
3510
- }
3511
- if (isNamedOperand(strOperand)) {
3512
- return {
3513
- type: "field",
3514
- fieldRef: resolveFieldReference(strOperand.name, mapper),
3515
- delimiter,
3516
- };
3517
- }
3518
- throw new Error("hierarchy(string, delimiter) requires a value or field operand");
3519
- }
3520
-
3521
- if (operands.length === 1) {
3522
- const inner = assertDefined(operands[0], "hierarchy requires an operand");
3523
-
3524
- if (isValueOperand(inner)) {
3525
- const raw = String(inner.value);
3526
- return { type: "constant", segments: raw.split("."), raw, delimiter: "." };
3527
- }
3528
-
3529
- if (isNamedOperand(inner)) {
3530
- return {
3531
- type: "field",
3532
- fieldRef: resolveFieldReference(inner.name, mapper),
3533
- delimiter: ".",
3534
- };
3535
- }
3536
-
3537
- if (isOperatorOperand(inner) && inner.operator === "list") {
3538
- const segments = inner.operands.map((op): HierarchySegment => {
3539
- const resolved = resolveOperand(op, mapper);
3540
- if (isResolvedValue(resolved)) {
3541
- return { type: "constant", value: String(resolved.value) };
3542
- }
3543
- return { type: "field", fieldRef: resolved };
3544
- });
3545
- return { type: "segmented", segments };
3546
- }
3547
-
3548
- throw new Error("hierarchy requires a value, field, or list operand");
3549
- }
3550
-
3551
- throw new Error("hierarchy requires 1 or 2 operands");
3552
- }
3553
-
3554
- function toSegments(resolved: ResolvedHierarchy): HierarchySegment[] {
3555
- switch (resolved.type) {
3556
- case "constant":
3557
- return resolved.segments.map((s) => ({ type: "constant" as const, value: s }));
3558
- case "segmented":
3559
- return resolved.segments;
3560
- case "field":
3561
- throw new Error(
3562
- "Cannot get segments from a field-reference hierarchy"
3563
- );
3564
- }
3565
- }
3566
-
3567
- function normalizeHierarchy(
3568
- h: ResolvedHierarchy,
3569
- defaultDelimiter = "."
3570
- ): ResolvedHierarchy {
3571
- if (h.type !== "segmented") return h;
3572
- const allConstant = h.segments.every(
3573
- (s): s is ConstantSegment => s.type === "constant"
3574
- );
3575
- if (!allConstant) return h;
3576
- const segments = (h.segments as ConstantSegment[]).map((s) => s.value);
3577
- return {
3578
- type: "constant",
3579
- segments,
3580
- raw: segments.join(defaultDelimiter),
3581
- delimiter: defaultDelimiter,
3582
- };
3583
- }
3584
-
3585
- function checkPrefixConditions(
3586
- shorter: HierarchySegment[],
3587
- longer: HierarchySegment[]
3588
- ): PrismaFilter | null {
3589
- if (shorter.length > longer.length) return null;
3590
-
3591
- const conditions: PrismaFilter[] = [];
3592
-
3593
- for (let i = 0; i < shorter.length; i++) {
3594
- const s = shorter[i]!;
3595
- const l = longer[i]!;
3596
-
3597
- if (s.type === "constant" && l.type === "constant") {
3598
- if (s.value !== l.value) return null;
3599
- } else if (s.type === "field" && l.type === "constant") {
3600
- conditions.push(buildFieldFilter(s.fieldRef, "equals", l.value));
3601
- } else if (s.type === "constant" && l.type === "field") {
3602
- conditions.push(buildFieldFilter(l.fieldRef, "equals", s.value));
3603
- } else {
3604
- throw new Error(
3605
- "Cannot compare two field references in hierarchy overlap"
3606
- );
3607
- }
3608
- }
3609
-
3610
- if (conditions.length === 0) return {};
3611
- if (conditions.length === 1) return conditions[0]!;
3612
- return { AND: conditions };
3613
- }
3614
-
3615
- function handleOverlapsOperator(
3616
- operands: PlanExpressionOperand[],
3617
- mapper: Mapper
3618
- ): PrismaFilter {
3619
- const [left, right] = extractHierarchyOperands("overlaps", operands, mapper);
3620
-
3621
- if (left.type === "field" || right.type === "field") {
3622
- return handleFieldOverlaps(left, right);
3623
- }
3624
-
3625
- const leftSegs = toSegments(left);
3626
- const rightSegs = toSegments(right);
3627
-
3628
- const leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs);
3629
- const rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs);
3630
-
3631
- const validConditions = [leftPrefixOfRight, rightPrefixOfLeft].filter(
3632
- (c): c is PrismaFilter => c !== null
3633
- );
3634
-
3635
- if (validConditions.length === 0) {
3636
- const allSegs = [...leftSegs, ...rightSegs];
3637
- const fieldSeg = allSegs.find(
3638
- (s): s is FieldSegment => s.type === "field"
3639
- );
3640
- if (fieldSeg) return buildImpossibleFilter(fieldSeg.fieldRef);
3641
- throw new Error("Cannot determine overlap: no field references found");
3642
- }
3643
-
3644
- if (validConditions.some((c) => Object.keys(c).length === 0)) return {};
3645
-
3646
- // When both directions are valid (equal-length hierarchies), they produce
3647
- // identical conditions since the same segment pairs are compared in both.
3648
- return validConditions[0]!;
3649
- }
3650
-
3651
- /**
3652
- * Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
3653
- *
3654
- * `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
3655
- * match as wildcards instead of literally and widen the filter past what the PDP allows.
3656
- * `\` is unsafe because PostgreSQL and MySQL treat it as the DEFAULT escape character with no
3657
- * `ESCAPE` clause present, where SQLite has none at all — so the same prefix means different
3658
- * things per provider. `[` is unsafe for a third reason: SQL Server opens a character class on
3659
- * `[` even when an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly
3660
- * rather than emit a filter that admits denied rows.
3661
- */
3662
- function assertLikeSafePrefix(prefix: string): void {
3663
- if (/[%_\[\\]/.test(prefix)) {
3664
- throw new Error(
3665
- "Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _, \\ or [): " +
3666
- "Prisma emits LIKE without an ESCAPE clause, \\ is the default escape character on " +
3667
- "PostgreSQL and MySQL, and [ opens a character class on SQL Server even with one"
3668
- );
3669
- }
3670
- }
3671
-
3672
- function handleFieldOverlaps(
3673
- left: ResolvedHierarchy,
3674
- right: ResolvedHierarchy
3675
- ): PrismaFilter {
3676
- if (left.type === "field" && right.type === "field") {
3677
- throw new Error("overlaps: cannot compare two field-reference hierarchies");
3678
- }
3679
-
3680
- const field = (left.type === "field" ? left : right) as FieldHierarchy;
3681
- const other = left.type === "field" ? right : left;
3682
-
3683
- if (other.type !== "constant") {
3684
- throw new Error("overlaps: segmented hierarchies with field hierarchies are not supported");
3685
- }
3686
-
3687
- const delimiter = field.delimiter;
3688
- const otherRaw = other.segments.join(delimiter);
3689
- const strictPrefixes = getStrictPrefixes(other.segments, delimiter);
3690
-
3691
- const conditions: PrismaFilter[] = [];
3692
- if (strictPrefixes.length > 0) {
3693
- conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
3694
- }
3695
- conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
3696
- const prefix = otherRaw + delimiter;
3697
- assertLikeSafePrefix(prefix);
3698
- conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
3699
-
3700
- if (conditions.length === 1) return conditions[0]!;
3701
- return { OR: conditions };
3702
- }
3703
-
3704
- function extractHierarchyOperands(
3705
- operatorName: string,
3706
- operands: PlanExpressionOperand[],
3707
- mapper: Mapper
3708
- ): [ResolvedHierarchy, ResolvedHierarchy] {
3709
- if (operands.length !== 2) {
3710
- throw new Error(`${operatorName} requires exactly two operands`);
3711
- }
3712
- const leftOp = assertDefined(operands[0], `${operatorName} requires a left operand`);
3713
- const rightOp = assertDefined(operands[1], `${operatorName} requires a right operand`);
3714
-
3715
- if (
3716
- !isOperatorOperand(leftOp) || leftOp.operator !== "hierarchy" ||
3717
- !isOperatorOperand(rightOp) || rightOp.operator !== "hierarchy"
3718
- ) {
3719
- throw new Error(`${operatorName} requires two hierarchy operands`);
3720
- }
3721
-
3722
- return [
3723
- normalizeHierarchy(resolveHierarchy(leftOp, mapper)),
3724
- normalizeHierarchy(resolveHierarchy(rightOp, mapper)),
3725
- ];
3726
- }
3727
-
3728
- function getStrictPrefixes(segments: string[], delimiter: string): string[] {
3729
- if (segments.length <= 1) return [];
3730
- const prefixes: string[] = [];
3731
- let current = segments[0]!;
3732
- prefixes.push(current);
3733
- for (let i = 1; i < segments.length - 1; i++) {
3734
- current = current + delimiter + segments[i]!;
3735
- prefixes.push(current);
3736
- }
3737
- return prefixes;
3738
- }
3739
-
3740
- function handleAncestorDescendantOperator(
3741
- operands: PlanExpressionOperand[],
3742
- mapper: Mapper,
3743
- direction: "ancestor" | "descendant"
3744
- ): PrismaFilter {
3745
- const operatorName = direction === "ancestor" ? "ancestorOf" : "descendentOf";
3746
- const [left, right] = extractHierarchyOperands(operatorName, operands, mapper);
3747
-
3748
- // ancestorOf(A, B) = A is strict prefix of B
3749
- // descendentOf(A, B) = B is strict prefix of A
3750
- const ancestor = direction === "ancestor" ? left : right;
3751
- const descendant = direction === "ancestor" ? right : left;
3752
-
3753
- if (ancestor.type === "constant" && descendant.type === "field") {
3754
- const prefix =
3755
- ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
3756
- assertLikeSafePrefix(prefix);
3757
- return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
3758
- }
3759
-
3760
- if (ancestor.type === "field" && descendant.type === "constant") {
3761
- const delimiter = ancestor.delimiter;
3762
- const prefixes = getStrictPrefixes(descendant.segments, delimiter);
3763
- if (prefixes.length === 0) {
3764
- return buildImpossibleFilter(ancestor.fieldRef);
3765
- }
3766
- if (prefixes.length === 1) {
3767
- return buildFieldFilter(ancestor.fieldRef, "equals", prefixes[0]!);
3768
- }
3769
- return buildFieldFilter(ancestor.fieldRef, "in", prefixes);
3770
- }
3771
-
3772
- if (ancestor.type === "constant" && descendant.type === "constant") {
3773
- const ancestorSegs = ancestor.segments;
3774
- const descendantSegs = descendant.segments;
3775
- if (
3776
- descendantSegs.length > ancestorSegs.length &&
3777
- ancestorSegs.every((seg, i) => seg === descendantSegs[i])
3778
- ) {
3779
- return {};
3780
- }
3781
- throw new Error(`${operatorName}: constants do not satisfy ${direction} relationship`);
3782
- }
3783
-
3784
- throw new Error(`${operatorName}: unsupported hierarchy type combination`);
3785
- }
3786
-
3787
- function buildFieldFilter(
3788
- fieldRef: ResolvedFieldReference,
3789
- prismaOp: string,
3790
- value: Value
3791
- ): PrismaFilter {
3792
- const fieldName = getLeafField(fieldRef.path);
3793
- return wrapRelations(fieldRef.relations, {
3794
- [fieldName]: { [prismaOp]: value },
3795
- });
3796
216
  }