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