@cerbos/orm-prisma 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +598 -603
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +23 -13
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2555
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +27 -17
  95. package/src/adversarial.test.ts +376 -1071
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +433 -250
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +10 -0
  104. package/src/generated/prisma-adversarial/client.ts +10 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
  114. package/src/generated/prisma-adversarial/models.ts +2 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
  117. package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
  118. package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  121. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  130. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  131. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  132. package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
  133. package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
  134. package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
  135. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  136. package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
  137. package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
  138. package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
  139. package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
  140. package/src/generated/prisma-adversarial-pg/client.ts +10 -0
  141. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
  142. package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
  143. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
  144. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  148. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
  149. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
  150. package/src/generated/prisma-adversarial-pg/models.ts +2 -0
  151. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
  152. package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
  153. package/src/hierarchy.ts +405 -0
  154. package/src/index.ts +66 -3646
  155. package/src/literals.ts +363 -0
  156. package/src/mapping.ts +474 -0
  157. package/src/narrowing.ts +33 -0
  158. package/src/plan.ts +125 -0
  159. package/src/presence.ts +114 -0
  160. package/src/regex.ts +596 -0
  161. package/src/relations.ts +226 -0
  162. package/src/rewrite.ts +524 -0
  163. package/src/solve.ts +96 -0
  164. package/src/strings.ts +152 -0
  165. package/src/ternary.ts +359 -0
  166. package/src/test-setup.adversarial-url.ts +6 -3
  167. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  168. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  169. package/src/test-setup.adversarial.v7.ts +1 -0
  170. package/src/timestamp.ts +308 -0
  171. package/src/translate.ts +419 -0
  172. package/src/translator.test.ts +497 -1317
  173. package/src/types.ts +1310 -0
  174. package/src/value.ts +16 -0
@@ -0,0 +1,1157 @@
1
+ // Comparisons (`eq`, `ne`, `lt`, `le`, `gt`, `ge`) and membership (`in`): against a constant,
2
+ // between two columns, over size(), and over linear arithmetic solved for the column.
3
+
4
+ import type { PlanExpressionOperand, Value } from "@cerbos/core";
5
+
6
+ import { ARITHMETIC_OPERATORS, evaluateConstantComparison, foldArithmetic } from "./evaluate";
7
+ import {
8
+ assertStringField,
9
+ buildComparisonFilter,
10
+ buildFieldFilter,
11
+ buildMembershipFilter,
12
+ rejectConstantFalse,
13
+ } from "./fields";
14
+ import { literalValue } from "./literals";
15
+ import type { PrismaFilter } from "./index";
16
+ import {
17
+ currentScope,
18
+ getLeafField,
19
+ isExplicitNullReference,
20
+ isResolvedFieldReference,
21
+ isResolvedValue,
22
+ namesToOneRelation,
23
+ recordErroringNullElement,
24
+ resolveFieldReference,
25
+ } from "./mapping";
26
+ import type {
27
+ ResolvedFieldReference,
28
+ ResolvedValue,
29
+ TranslationContext,
30
+ } from "./mapping";
31
+ import {
32
+ CERBOS_TO_PRISMA_OPERATOR,
33
+ assertDefined,
34
+ isNamedOperand,
35
+ isOperatorOperand,
36
+ isValueOperand,
37
+ mirrorOperator,
38
+ normalizeBinaryOperands,
39
+ } from "./plan";
40
+ import type { OperatorOperand } from "./plan";
41
+ import { relationFilter, wrapInRelations } from "./relations";
42
+ import { negateInterval, solveMonotone } from "./solve";
43
+ import type { DoubleInterval } from "./solve";
44
+ import { tryHandleTernaryComparison } from "./ternary";
45
+ import { roundSubMillisecond } from "./timestamp";
46
+ import {
47
+ buildPrismaFilterFromCerbosExpression,
48
+ resolveOperand,
49
+ tryFoldValueExpression,
50
+ } from "./translate";
51
+ import { presence } from "./types";
52
+ import { UnsupportedQueryPlanError } from "./errors";
53
+ import { isNumber, isObject, isString, lookup, pair, typeofValue } from "./narrowing";
54
+ import { isPrismaFilter } from "./value";
55
+
56
+ /**
57
+ * Helper function to process relational operators (eq, ne, lt, etc.)
58
+ */
59
+ export function handleRelationalOperator(
60
+ operator: string,
61
+ operands: PlanExpressionOperand[],
62
+ context: TranslationContext
63
+ ): PrismaFilter {
64
+ const ternaryFilter = tryHandleTernaryComparison(operator, operands, context);
65
+
66
+ if (ternaryFilter) {
67
+ return ternaryFilter;
68
+ }
69
+
70
+ const lookupFilter =
71
+ tryHandleMapLiteralLookup(operator, operands, context) ??
72
+ tryHandleUpperAsciiComparison(operator, operands, context);
73
+
74
+ if (lookupFilter) {
75
+ return lookupFilter;
76
+ }
77
+
78
+ ({ operator, operands } = normalizeBinaryOperands(operator, operands));
79
+
80
+ if (!lookup(CERBOS_TO_PRISMA_OPERATOR, operator)) {
81
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${operator}`);
82
+ }
83
+
84
+ const leftOperand = operands.find(
85
+ (o) => isNamedOperand(o) || isOperatorOperand(o)
86
+ );
87
+
88
+ if (!leftOperand) throw new UnsupportedQueryPlanError("No valid left operand found");
89
+
90
+ const rightOperand = operands.find((o) => o !== leftOperand);
91
+
92
+ if (!rightOperand) throw new UnsupportedQueryPlanError("No valid right operand found");
93
+
94
+ if (isOperatorOperand(leftOperand) && leftOperand.operator === "size") {
95
+ return handleSizeComparison(operator, leftOperand, rightOperand, context);
96
+ }
97
+
98
+ const arithOperand = [leftOperand, rightOperand].find(
99
+ (o): o is OperatorOperand =>
100
+ isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator)
101
+ );
102
+
103
+ if (arithOperand) {
104
+ const arithIsLeft = arithOperand === leftOperand;
105
+
106
+ return handleArithmeticComparison(
107
+ operator,
108
+ arithOperand,
109
+ arithIsLeft ? rightOperand : leftOperand,
110
+ arithIsLeft,
111
+ context
112
+ );
113
+ }
114
+
115
+ if (
116
+ [leftOperand, rightOperand].some(
117
+ (o) => isOperatorOperand(o) && o.operator === "map"
118
+ )
119
+ ) {
120
+ throw new UnsupportedQueryPlanError(
121
+ `Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
122
+ `Wrap the map() expression in hasIntersection(map(...), [...]) instead.`
123
+ );
124
+ }
125
+
126
+ assertNoRawTemporalAgainstTimestamp(leftOperand, rightOperand, context);
127
+
128
+ // Only a comparison between a column and one constant can absorb a sub-millisecond instant.
129
+ const oneColumn = isColumnOperand(leftOperand) !== isColumnOperand(rightOperand);
130
+ const left = resolveOperand(leftOperand, context, oneColumn);
131
+ const right = resolveOperand(rightOperand, context, oneColumn);
132
+
133
+ if (isResolvedValue(left)) {
134
+ if (isResolvedFieldReference(right)) {
135
+ return buildValueComparisonFilter(
136
+ context,
137
+ right,
138
+ mirrorOperator(operator),
139
+ left
140
+ );
141
+ }
142
+
143
+ return evaluateConstantComparison(operator, left.value, right.value)
144
+ ? {}
145
+ : rejectConstantFalse();
146
+ }
147
+
148
+ if (isResolvedFieldReference(right)) {
149
+ if (
150
+ left.valueType === "dateTime" &&
151
+ right.valueType === "dateTime" &&
152
+ (!left.timestampWrapped || !right.timestampWrapped)
153
+ ) {
154
+ throw new UnsupportedQueryPlanError(
155
+ "Raw temporal column comparison loses RFC-3339 string spelling; wrap both operands in timestamp()"
156
+ );
157
+ }
158
+
159
+ if (
160
+ left.valueType !== undefined &&
161
+ right.valueType !== undefined &&
162
+ left.valueType !== right.valueType
163
+ ) {
164
+ throw new UnsupportedQueryPlanError("Cannot compare fields with different mapped value types");
165
+ }
166
+
167
+ return buildFieldToFieldFilter(
168
+ context,
169
+ operator,
170
+ leftOperand,
171
+ left,
172
+ rightOperand,
173
+ right
174
+ );
175
+ }
176
+
177
+ return buildValueComparisonFilter(context, left, operator, right);
178
+ }
179
+
180
+ /** `{"k": v, ...}[x]`: an index into a map literal. */
181
+ function isMapLiteralLookup(operand: PlanExpressionOperand): operand is OperatorOperand {
182
+ if (!isOperatorOperand(operand) || operand.operator !== "index" || operand.operands.length !== 2) {
183
+ return false;
184
+ }
185
+
186
+ const [map] = operand.operands;
187
+
188
+ return isOperatorOperand(map!) && map.operator === "struct";
189
+ }
190
+
191
+ /** Whether a map-literal lookup appears anywhere in `operand`. */
192
+ export function containsMapLiteralLookup(operand: PlanExpressionOperand): boolean {
193
+ if (!isOperatorOperand(operand)) return false;
194
+
195
+ return operand.operands.some(containsMapLiteralLookup) || isMapLiteralLookup(operand);
196
+ }
197
+
198
+ /**
199
+ * `{"k1": v1, "k2": v2}[x] == v` (and `!=`, either side first), with `x` a string column: the keys
200
+ * whose value equals `v` are exactly the values of `x` that make it true, and every other key the
201
+ * values that make it false. Any `x` that is not a key is an error, true under neither polarity,
202
+ * so the comparison and its negation are two membership tests, never one test and its NOT:
203
+ * `negated` asks for the negation's filter, and buildNegatedFilter pushes every `not` above a
204
+ * lookup down to it. Inside a lambda body a macro negates its body with a plain NOT, so the
205
+ * lookup is refused there.
206
+ */
207
+ export function tryHandleMapLiteralLookup(
208
+ operator: string,
209
+ operands: PlanExpressionOperand[],
210
+ context: TranslationContext,
211
+ negated = false
212
+ ): PrismaFilter | null {
213
+ if ((operator !== "eq" && operator !== "ne") || operands.length !== 2) return null;
214
+ const lookupIndex = operands.findIndex(isMapLiteralLookup);
215
+
216
+ if (lookupIndex === -1) return null;
217
+ const lookup = operands[lookupIndex];
218
+
219
+ if (lookup === undefined || !isOperatorOperand(lookup)) return null;
220
+ const other = operands[1 - lookupIndex]!;
221
+ const [mapOperand, key] = pair(lookup.operands);
222
+ const map = literalValue(mapOperand);
223
+
224
+ if (
225
+ map === undefined ||
226
+ !isObject(map) ||
227
+ Array.isArray(map) ||
228
+ !isValueOperand(other) ||
229
+ !isNamedOperand(key)
230
+ ) {
231
+ throw new UnsupportedQueryPlanError(
232
+ "An index into a map literal is translated only as a literal map indexed by a column " +
233
+ "and compared with a literal: a Prisma filter has no map lookup"
234
+ );
235
+ }
236
+
237
+ if (context.scopes.length > 0) {
238
+ throw new UnsupportedQueryPlanError(
239
+ "An index into a map literal inside a lambda body is not supported: a missing key is an " +
240
+ "error under both polarities, and the macro negates its body with a plain NOT"
241
+ );
242
+ }
243
+
244
+ const fieldRef = resolveFieldReference(key.name, context);
245
+
246
+ if (fieldRef.valueType !== "string") {
247
+ throw new UnsupportedQueryPlanError(
248
+ `An index into a map literal requires a string key column, and ${key.name} is not mapped ` +
249
+ 'with valueType: "string"'
250
+ );
251
+ }
252
+
253
+ const keys = Object.keys(map);
254
+ const matching = keys.filter((k) => evaluateConstantComparison("eq", map[k]!, other.value));
255
+ const holds = (operator === "eq") !== negated;
256
+
257
+ return buildFieldFilter(
258
+ fieldRef,
259
+ "in",
260
+ holds ? matching : keys.filter((k) => !matching.includes(k))
261
+ );
262
+ }
263
+
264
+ /** Past this many spellings, `upperAscii(x) == "LIT"` is refused rather than enumerated. */
265
+ const MAX_UPPER_ASCII_SPELLINGS = 1024;
266
+
267
+ /**
268
+ * `x.upperAscii() == "LIT"` (and `!=`, either side first), with `x` a string column: upperAscii
269
+ * maps exactly the ASCII letters a-z to A-Z and leaves every other code point alone, so the
270
+ * strings it maps to "LIT" are its spellings with each ASCII capital in either case — none at all
271
+ * when the literal holds a lowercase ASCII letter. An IN over those spellings compares the bytes
272
+ * exactly, where a store's UPPER would also fold non-ASCII letters. A NULL column stays UNKNOWN,
273
+ * as upperAscii of a missing (or null) value is an error.
274
+ */
275
+ function tryHandleUpperAsciiComparison(
276
+ operator: string,
277
+ operands: PlanExpressionOperand[],
278
+ context: TranslationContext
279
+ ): PrismaFilter | null {
280
+ if ((operator !== "eq" && operator !== "ne") || operands.length !== 2) return null;
281
+
282
+ const callIndex = operands.findIndex(
283
+ (operand) => isOperatorOperand(operand) && operand.operator === "upperAscii"
284
+ );
285
+
286
+ if (callIndex === -1) return null;
287
+ const call = operands[callIndex];
288
+
289
+ if (call === undefined || !isOperatorOperand(call)) return null;
290
+ const other = operands[1 - callIndex]!;
291
+ const [subject] = call.operands;
292
+
293
+ if (
294
+ call.operands.length !== 1 ||
295
+ subject === undefined ||
296
+ !isNamedOperand(subject) ||
297
+ !isValueOperand(other) ||
298
+ !isString(other.value)
299
+ ) {
300
+ throw new UnsupportedQueryPlanError(
301
+ "upperAscii() is translated only as a column compared with a string literal: a Prisma " +
302
+ "filter has no ASCII-only case mapping"
303
+ );
304
+ }
305
+
306
+ const fieldRef = resolveFieldReference(subject.name, context);
307
+ assertStringField(fieldRef, "upperAscii");
308
+ recordErroringNullElement(context, subject, fieldRef);
309
+ let spellings = [""];
310
+
311
+ for (const character of other.value) {
312
+ if (character >= "a" && character <= "z") {
313
+ spellings = [];
314
+ break;
315
+ }
316
+
317
+ const cases =
318
+ character >= "A" && character <= "Z" ? [character, character.toLowerCase()] : [character];
319
+
320
+ spellings = spellings.flatMap((prefix) => cases.map((c) => prefix + c));
321
+
322
+ if (spellings.length > MAX_UPPER_ASCII_SPELLINGS) {
323
+ throw new UnsupportedQueryPlanError(
324
+ `Cannot translate upperAscii() == ${JSON.stringify(other.value)}: its spellings exceed ` +
325
+ `the ${MAX_UPPER_ASCII_SPELLINGS}-entry limit`
326
+ );
327
+ }
328
+ }
329
+
330
+ const filter = buildFieldFilter(fieldRef, "in", spellings);
331
+
332
+ return operator === "eq" ? filter : { NOT: filter };
333
+ }
334
+
335
+ /**
336
+ * Refuses a bare `dateTime` column compared with a `timestamp(...)` operand. The attribute the
337
+ * application sends for that column is its RFC 3339 STRING (a Cerbos attribute has no timestamp
338
+ * type), and CEL has no overload ordering a string against a timestamp: `check()` raises an error,
339
+ * which decides the row under neither polarity the way any column filter would. Wrapping the
340
+ * attribute in timestamp() is what the policy means.
341
+ */
342
+ function assertNoRawTemporalAgainstTimestamp(
343
+ first: PlanExpressionOperand,
344
+ second: PlanExpressionOperand,
345
+ context: TranslationContext
346
+ ): void {
347
+ for (const [column, other] of [
348
+ [first, second],
349
+ [second, first],
350
+ ] as const) {
351
+ if (
352
+ isNamedOperand(column) &&
353
+ isOperatorOperand(other) &&
354
+ other.operator === "timestamp" &&
355
+ resolveFieldReference(column.name, context).valueType === "dateTime"
356
+ ) {
357
+ throw new UnsupportedQueryPlanError(
358
+ `Cannot compare ${column.name} with a timestamp: the attribute holds an RFC 3339 ` +
359
+ "string, and CEL has no overload comparing a string with a timestamp, so check() " +
360
+ "raises an error that no column filter reproduces under both polarities; wrap the " +
361
+ "attribute in timestamp()"
362
+ );
363
+ }
364
+ }
365
+ }
366
+
367
+ /** A column reference, bare or wrapped in timestamp(). */
368
+ function isColumnOperand(operand: PlanExpressionOperand): boolean {
369
+ if (isNamedOperand(operand)) return true;
370
+
371
+ return (
372
+ isOperatorOperand(operand) &&
373
+ operand.operator === "timestamp" &&
374
+ operand.operands.length === 1 &&
375
+ isNamedOperand(operand.operands[0]!)
376
+ );
377
+ }
378
+
379
+ /** buildComparisonFilter, for a resolved constant that may be a sub-millisecond timestamp. */
380
+ function buildValueComparisonFilter(
381
+ context: TranslationContext,
382
+ fieldRef: ResolvedFieldReference,
383
+ operator: string,
384
+ constant: ResolvedValue
385
+ ): PrismaFilter {
386
+ if (!constant.subMillisecond) {
387
+ return buildComparisonFilter(context, fieldRef, operator, constant.value);
388
+ }
389
+
390
+ const rounded = roundSubMillisecond(operator);
391
+
392
+ if (rounded !== null) {
393
+ return buildComparisonFilter(context, fieldRef, rounded, constant.value);
394
+ }
395
+
396
+ // No whole millisecond equals the instant. Spelled as a contradiction on the column, so a NULL
397
+ // stays UNKNOWN under both polarities — timestamp() of a missing attribute is an error.
398
+ const never: PrismaFilter = {
399
+ AND: [
400
+ buildComparisonFilter(context, fieldRef, "lt", constant.value),
401
+ buildComparisonFilter(context, fieldRef, "ge", constant.value),
402
+ ],
403
+ };
404
+
405
+ return operator === "eq" ? never : { NOT: never };
406
+ }
407
+
408
+ /**
409
+ * A size is a non-negative integer, so every `size(x) CMP n` — fractional, negative or huge `n`
410
+ * included — is one of: at least `k`, exactly `k`, or the negation of either. `at least 0` is
411
+ * true whenever `x` can be evaluated at all.
412
+ */
413
+ type CountPredicate = {
414
+ kind: "atLeast" | "exactly";
415
+ count: number;
416
+ negated: boolean;
417
+ };
418
+
419
+ function countPredicate(operator: string, n: number): CountPredicate {
420
+ const atLeast = (count: number, negated = false): CountPredicate => ({
421
+ kind: "atLeast",
422
+ count: Math.max(count, 0),
423
+ negated,
424
+ });
425
+
426
+ switch (operator) {
427
+ case "gt":
428
+ return atLeast(Math.floor(n) + 1);
429
+ case "ge":
430
+ return atLeast(Math.ceil(n));
431
+ case "lt":
432
+ return atLeast(Math.ceil(n), true);
433
+ case "le":
434
+ return atLeast(Math.floor(n) + 1, true);
435
+ case "eq":
436
+ case "ne": {
437
+ const negated = operator === "ne";
438
+
439
+ // A fractional size never equals anything; a negative one never exists.
440
+ return Number.isInteger(n) && n >= 0
441
+ ? { kind: "exactly", count: n, negated }
442
+ : atLeast(0, !negated);
443
+ }
444
+
445
+ default:
446
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${operator}`);
447
+ }
448
+ }
449
+
450
+ /**
451
+ * No store holds a string of 2^32 characters: PostgreSQL caps a value at 1 GB, SQLite at
452
+ * 2^31 - 1 bytes and MySQL's LONGTEXT at 2^32 - 1 bytes. A length threshold at or past it is
453
+ * decided without a pattern.
454
+ */
455
+ const STRING_LENGTH_CEILING = 2 ** 32;
456
+
457
+ /** Past this, a length threshold short of the ceiling is refused rather than spelled out. */
458
+ const MAX_LENGTH_PATTERN = 1024;
459
+
460
+ /**
461
+ * `size(column) CMP n` over a string column, as `LIKE` patterns of `_`: Prisma does not escape
462
+ * the wildcards in a `startsWith` needle, so `startsWith: "_____"` is `LIKE '_____%'`, true exactly
463
+ * when the value holds at least five characters. Each store's `_` matches one character — a code
464
+ * point in a UTF-8 database — which is what CEL's size() counts. `startsWith: ""` is `LIKE '%'`,
465
+ * true for every non-NULL value. Every filter built here is therefore UNKNOWN on a NULL column
466
+ * under both polarities, as size() of a missing attribute is an error in CEL.
467
+ */
468
+ function buildStringLengthFilter(
469
+ fieldRef: ResolvedFieldReference,
470
+ predicate: CountPredicate
471
+ ): PrismaFilter {
472
+ const atLeast = (count: number): PrismaFilter => {
473
+ if (count >= STRING_LENGTH_CEILING) {
474
+ return { NOT: buildFieldFilter(fieldRef, "startsWith", "") };
475
+ }
476
+
477
+ if (count > MAX_LENGTH_PATTERN) {
478
+ throw new UnsupportedQueryPlanError(
479
+ `Cannot translate a string length threshold of ${count}: the LIKE pattern it needs ` +
480
+ `exceeds the ${MAX_LENGTH_PATTERN}-character limit`
481
+ );
482
+ }
483
+
484
+ return buildFieldFilter(fieldRef, "startsWith", "_".repeat(count));
485
+ };
486
+
487
+ const filter =
488
+ predicate.kind === "atLeast"
489
+ ? atLeast(predicate.count)
490
+ : { AND: [atLeast(predicate.count), { NOT: atLeast(predicate.count + 1) }] };
491
+
492
+ return predicate.negated ? { NOT: filter } : filter;
493
+ }
494
+
495
+ /**
496
+ * `size(collection) CMP n`. Over a relation, only the thresholds that mean "is empty", "is
497
+ * non-empty" or "the chain reaching it exists" — the only counts a relation filter can express.
498
+ * Over a string column, any threshold (see buildStringLengthFilter).
499
+ */
500
+ function handleSizeComparison(
501
+ operator: string,
502
+ sizeOperand: OperatorOperand,
503
+ valueOperand: PlanExpressionOperand,
504
+ context: TranslationContext
505
+ ): PrismaFilter {
506
+ const collectionOperand = sizeOperand.operands[0];
507
+
508
+ if (collectionOperand && isOperatorOperand(collectionOperand)) {
509
+ throw new UnsupportedQueryPlanError(
510
+ `size() of ${collectionOperand.operator}(...) counts the elements of a derived list, and a ` +
511
+ "Prisma relation filter can only test whether some, every or no related row matches, " +
512
+ "never count them"
513
+ );
514
+ }
515
+
516
+ if (!collectionOperand || !isNamedOperand(collectionOperand)) {
517
+ throw new UnsupportedQueryPlanError("size operator requires a named collection operand");
518
+ }
519
+
520
+ if (!isValueOperand(valueOperand)) {
521
+ throw new UnsupportedQueryPlanError("size comparison requires a numeric value operand");
522
+ }
523
+
524
+ const count = valueOperand.value;
525
+
526
+ if (!isNumber(count) || !Number.isFinite(count)) {
527
+ throw new UnsupportedQueryPlanError("size comparison requires a numeric value");
528
+ }
529
+
530
+ const predicate = countPredicate(operator, count);
531
+
532
+ const fieldRef = resolveFieldReference(collectionOperand.name, context);
533
+ const { relations } = fieldRef;
534
+
535
+ // A string column reached through to-one hops is measured on the hop's row, never counted as
536
+ // one: `size(parent.aString) >= 1` read as "has a parent" returned a parent whose aString is
537
+ // empty. The length's own negation sits inside the hops, so a missing hop is unmatched under
538
+ // both polarities, the missing-path error CEL denies.
539
+ if (fieldRef.valueType === "string") {
540
+ return wrapInRelations(
541
+ relations,
542
+ buildStringLengthFilter({ ...fieldRef, relations: [] }, predicate)
543
+ );
544
+ }
545
+
546
+ if (!relations || relations.length === 0) {
547
+ throw new UnsupportedQueryPlanError("size operator requires a relation mapping");
548
+ }
549
+
550
+ // The count applies to the deepest relation; every relation before it is a hop to reach it.
551
+ const hops = relations.slice(0, -1);
552
+ const deepest = relations[relations.length - 1]!;
553
+ const { kind, count: k, negated } = predicate;
554
+
555
+ // `size >= 0` holds whenever the collection can be reached. Only a chain can fail to reach it;
556
+ // a direct relation would be an unconditional `{}`, which no negation could then express.
557
+ if (kind === "atLeast" && k === 0 && !negated && hops.length > 0) {
558
+ return wrapInRelations(hops, {});
559
+ }
560
+
561
+ const isNonEmpty =
562
+ (kind === "atLeast" && k === 1 && !negated) ||
563
+ (kind === "exactly" && k === 0 && negated);
564
+
565
+ const isEmpty =
566
+ (kind === "atLeast" && k === 1 && negated) ||
567
+ (kind === "exactly" && k === 0 && !negated);
568
+
569
+ if (!isNonEmpty && !isEmpty) {
570
+ throw new UnsupportedQueryPlanError(
571
+ `Unsupported size comparison: size(...) ${operator} ${count}`
572
+ );
573
+ }
574
+
575
+ return wrapInRelations(
576
+ hops,
577
+ relationFilter(deepest, isNonEmpty ? "some" : "none", {})
578
+ );
579
+ }
580
+
581
+ /**
582
+ * Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
583
+ * references between fields of the SAME model, so the container is the root model for
584
+ * root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
585
+ * (an element column against an outer column) are cross-model and must fail loudly.
586
+ */
587
+ function buildFieldToFieldFilter(
588
+ context: TranslationContext,
589
+ operator: string,
590
+ leftOperand: PlanExpressionOperand,
591
+ left: ResolvedFieldReference,
592
+ rightOperand: PlanExpressionOperand,
593
+ right: ResolvedFieldReference
594
+ ): PrismaFilter {
595
+ const prismaOperator = assertDefined(
596
+ lookup(CERBOS_TO_PRISMA_OPERATOR, operator),
597
+ `Unsupported operator: ${operator}`
598
+ );
599
+
600
+ if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
601
+ throw new UnsupportedQueryPlanError("Field-to-field comparison requires two named operands");
602
+ }
603
+
604
+ const container = fieldReferenceContainer(
605
+ context,
606
+ leftOperand.name,
607
+ left,
608
+ rightOperand.name,
609
+ right
610
+ );
611
+
612
+ const leftField = getLeafField(left.path);
613
+ const rightField = getLeafField(right.path);
614
+ const fieldReference = { _ref: rightField, _container: container };
615
+
616
+ // Two explicit nulls are EQUAL in CEL, and a null against a value is definitely NOT equal.
617
+ // A bare field reference gives UNKNOWN for both, which drops the row under either polarity.
618
+ const leftExplicit = isExplicitNullReference(left);
619
+ const rightExplicit = isExplicitNullReference(right);
620
+
621
+ if (operator === "eq" || operator === "ne") {
622
+ // Mixing the two conventions across one comparison has no faithful rendering. The declared
623
+ // side needs a definite answer for its NULL (CEL holds a null VALUE); the undeclared side
624
+ // needs UNKNOWN for its NULL (a missing attribute, which CEL denies under both polarities). A
625
+ // definite predicate returns rows the PDP refuses; a plain one drops rows the PDP allows.
626
+ // Refuse it rather than pick a direction — declare both attributes, or neither.
627
+ // Mixing the two conventions across one comparison: the explicit side's NULL is a null VALUE,
628
+ // which `!=` answers TRUE against any present value, while the other side's NULL is a missing
629
+ // attribute, an error under both polarities. `other = other` is TRUE for a present value and
630
+ // UNKNOWN for a NULL one, so it carries exactly that error into the explicit-null arm:
631
+ //
632
+ // explicit NULL, other present -> TRUE explicit NULL, other NULL -> UNKNOWN
633
+ // explicit present, other NULL -> UNKNOWN both present -> the plain comparison
634
+ //
635
+ // and `==` is its negation.
636
+ if (leftExplicit !== rightExplicit) {
637
+ const [explicitField, otherField] = leftExplicit
638
+ ? [leftField, rightField]
639
+ : [rightField, leftField];
640
+
641
+ const otherReference = { _ref: otherField, _container: container };
642
+
643
+ const inequality: PrismaFilter = {
644
+ OR: [
645
+ {
646
+ AND: [
647
+ { [explicitField]: null },
648
+ { [otherField]: { equals: otherReference } },
649
+ ],
650
+ },
651
+ { NOT: { [explicitField]: { equals: otherReference } } },
652
+ ],
653
+ };
654
+
655
+ return operator === "ne" ? inequality : { NOT: inequality };
656
+ }
657
+
658
+ if (leftExplicit) {
659
+ const equality: PrismaFilter = {
660
+ OR: [
661
+ { AND: [{ [leftField]: null }, { [rightField]: null }] },
662
+ {
663
+ AND: [
664
+ { [leftField]: { not: null } },
665
+ { [rightField]: { not: null } },
666
+ { [leftField]: { equals: fieldReference } },
667
+ ],
668
+ },
669
+ ],
670
+ };
671
+
672
+ return operator === "eq" ? equality : { NOT: equality };
673
+ }
674
+ }
675
+
676
+ // `ne` is the one operator whose Prisma spelling cannot carry a field reference: `not` nests a
677
+ // NestedStringFilter, which has no `_ref` form, so `{ col: { not: { _ref } } }` is rejected by
678
+ // the client rather than executed. Hoisting the negation to the top-level `NOT` combinator says
679
+ // the same thing with a shape that does accept one — the same rewrite the explicit-null branch
680
+ // above already performs. Three-valued logic is unchanged by the hoist: a NULL on either side
681
+ // makes the inner equality UNKNOWN, and `NOT UNKNOWN` is still UNKNOWN, so the row stays out
682
+ // under both polarities, which is what a missing attribute does on the check side.
683
+ if (operator === "ne") {
684
+ return { NOT: { [leftField]: { equals: fieldReference } } };
685
+ }
686
+
687
+ return { [leftField]: { [prismaOperator]: fieldReference } };
688
+ }
689
+
690
+ /**
691
+ * The Prisma model both columns of a field-to-field comparison belong to: the relation model
692
+ * inside a lambda when both are element columns, otherwise the root model.
693
+ */
694
+ function fieldReferenceContainer(
695
+ context: TranslationContext,
696
+ leftName: string,
697
+ left: ResolvedFieldReference,
698
+ rightName: string,
699
+ right: ResolvedFieldReference
700
+ ): string {
701
+ const scope = currentScope(context);
702
+
703
+ if (scope) {
704
+ const prefix = scope.variableName + ".";
705
+ const leftInScope = leftName.startsWith(prefix);
706
+ const rightInScope = rightName.startsWith(prefix);
707
+
708
+ if (leftInScope !== rightInScope) {
709
+ throw new UnsupportedQueryPlanError(
710
+ `Cannot compare a collection element column with an outer column (${leftName} vs ${rightName}): ` +
711
+ "Prisma field references only work between fields of the same model"
712
+ );
713
+ }
714
+
715
+ if (leftInScope) {
716
+ if (!scope.relationModel) {
717
+ throw new Error(
718
+ "Field-to-field comparison inside a collection requires `relation.model` in the mapper"
719
+ );
720
+ }
721
+
722
+ return scope.relationModel;
723
+ }
724
+ }
725
+
726
+ if (
727
+ (left.relations && left.relations.length > 0) ||
728
+ (right.relations && right.relations.length > 0)
729
+ ) {
730
+ throw new UnsupportedQueryPlanError(
731
+ "Cannot compare columns across relations: Prisma field references only work between fields of the same model"
732
+ );
733
+ }
734
+
735
+ if (!context.rootModel) {
736
+ throw new Error(
737
+ "Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
738
+ );
739
+ }
740
+
741
+ return context.rootModel;
742
+ }
743
+
744
+ /**
745
+ * Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
746
+ * column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
747
+ * one constant side always rewrites to a plain comparison. Multiplying/dividing by a
748
+ * negative constant mirrors directional operators. The rewrite happens in IEEE double space,
749
+ * matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
750
+ * (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
751
+ * exactly as CEL evaluates it).
752
+ */
753
+ function handleArithmeticComparison(
754
+ operator: string,
755
+ arithExpr: OperatorOperand,
756
+ otherOperand: PlanExpressionOperand,
757
+ arithIsLeft: boolean,
758
+ context: TranslationContext
759
+ ): PrismaFilter {
760
+ const arithOp = arithExpr.operator;
761
+
762
+ const arithLeftOp = assertDefined(
763
+ arithExpr.operands[0],
764
+ `${arithOp} operator requires a left operand`
765
+ );
766
+
767
+ const arithRightOp = assertDefined(
768
+ arithExpr.operands[1],
769
+ `${arithOp} operator requires a right operand`
770
+ );
771
+
772
+ if (
773
+ isOperatorOperand(otherOperand) &&
774
+ ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
775
+ tryFoldValueExpression(otherOperand, context) === null
776
+ ) {
777
+ throw new UnsupportedQueryPlanError(
778
+ "Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter"
779
+ );
780
+ }
781
+
782
+ const arithLeft = resolveOperand(arithLeftOp, context);
783
+ const arithRight = resolveOperand(arithRightOp, context);
784
+ const other = resolveOperand(otherOperand, context);
785
+
786
+ // Fully constant arithmetic: fold and compare the other side against the result. The
787
+ // arithmetic side keeps its source position, so when the folded constant was written
788
+ // FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
789
+ if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
790
+ const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
791
+
792
+ if (!isResolvedFieldReference(other)) {
793
+ throw new UnsupportedQueryPlanError(
794
+ `${arithOp} with two values requires a field reference on the other side`
795
+ );
796
+ }
797
+
798
+ return buildComparisonFilter(
799
+ context,
800
+ other,
801
+ arithIsLeft ? mirrorOperator(operator) : operator,
802
+ folded
803
+ );
804
+ }
805
+
806
+ if (!isResolvedValue(other)) {
807
+ throw new UnsupportedQueryPlanError(
808
+ `${arithOp} operator with field references requires a value on the other side of the comparison`
809
+ );
810
+ }
811
+
812
+ let fieldRef: ResolvedFieldReference;
813
+ let constant: Value;
814
+ let fieldIsLeft: boolean;
815
+
816
+ if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
817
+ fieldRef = arithLeft;
818
+ constant = arithRight.value;
819
+ fieldIsLeft = true;
820
+ } else if (
821
+ isResolvedValue(arithLeft) &&
822
+ isResolvedFieldReference(arithRight)
823
+ ) {
824
+ fieldRef = arithRight;
825
+ constant = arithLeft.value;
826
+ fieldIsLeft = false;
827
+ } else {
828
+ throw new UnsupportedQueryPlanError(
829
+ `${arithOp} operator requires exactly one field reference and one value, or two values`
830
+ );
831
+ }
832
+
833
+ // The comparison operator was normalized arithmetic-side-first by the caller; if the
834
+ // arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
835
+ let effectiveOperator = arithIsLeft ? operator : mirrorOperator(operator);
836
+
837
+ const declared = fieldRef.valueType;
838
+ const declaredString = declared === "string";
839
+
840
+ if (
841
+ (isString(constant) && declared !== undefined && !declaredString) ||
842
+ (isNumber(constant) &&
843
+ (declaredString || declared === "boolean" || declared === "dateTime"))
844
+ ) {
845
+ throw new UnsupportedQueryPlanError(
846
+ `${arithOp} over a ${declared} field and a ${typeofValue(constant)} is a CEL no-overload error`
847
+ );
848
+ }
849
+
850
+ // String concatenation solving (eq/ne only).
851
+ if (arithOp === "add" && isString(constant)) {
852
+ if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
853
+ throw new UnsupportedQueryPlanError(
854
+ `Operator ${effectiveOperator} is not supported with string concatenation`
855
+ );
856
+ }
857
+
858
+ const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
859
+
860
+ if (solvedValue === null) {
861
+ // A contradictory / exhaustive pair remains SQL UNKNOWN for a missing field.
862
+ // An empty IN or an empty filter loses that error when an outer NOT is applied.
863
+ const equality = buildFieldFilter(fieldRef, "equals", other.value);
864
+ const inequality = buildFieldFilter(fieldRef, "not", other.value);
865
+
866
+ return effectiveOperator === "eq"
867
+ ? { AND: [equality, inequality] }
868
+ : { OR: [equality, inequality] };
869
+ }
870
+
871
+ // null + "x" is a CEL error, so the solved comparison stays UNKNOWN for a NULL column even
872
+ // where the field is sent as an explicit null.
873
+ const erroring = { ...fieldRef, nullAttributeRepresentation: "omitted" as const };
874
+
875
+ return buildComparisonFilter(context, erroring, effectiveOperator, solvedValue);
876
+ }
877
+
878
+ if (!isNumber(constant) || !isNumber(other.value)) {
879
+ throw new UnsupportedQueryPlanError(`${arithOp} comparison requires numeric operands`);
880
+ }
881
+
882
+ if (arithOp === "mult" && constant === 0) {
883
+ throw new UnsupportedQueryPlanError(
884
+ "Multiplication by a constant zero must be folded by the Cerbos planner"
885
+ );
886
+ }
887
+
888
+ if (arithOp === "div" && !fieldIsLeft) {
889
+ throw new UnsupportedQueryPlanError(
890
+ "Division by a column is not supported: the comparison cannot be solved to a plain column filter"
891
+ );
892
+ }
893
+
894
+ if (arithOp === "div" && constant === 0) {
895
+ throw new UnsupportedQueryPlanError("Division by a constant zero is not supported");
896
+ }
897
+
898
+ if (!ARITHMETIC_OPERATORS.has(arithOp)) {
899
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${arithOp}`);
900
+ }
901
+
902
+ // The column side as a non-decreasing function of `y`, where `y` is the column or, when the
903
+ // arithmetic reverses the order (`c - x`, a negative multiplier or divisor), its negation —
904
+ // negating a double is exact, so `c - x` is `c + y` and `x * c` is `y * -c`.
905
+ const c = constant;
906
+
907
+ const decreasing =
908
+ (arithOp === "sub" && !fieldIsLeft) || ((arithOp === "mult" || arithOp === "div") && c < 0);
909
+
910
+ const f = (y: number): number => {
911
+ switch (arithOp) {
912
+ case "add":
913
+ return y + c;
914
+ case "sub":
915
+ return fieldIsLeft ? y - c : c + y;
916
+ case "mult":
917
+ return decreasing ? y * -c : y * c;
918
+ default:
919
+ return decreasing ? y / -c : y / c;
920
+ }
921
+ };
922
+
923
+ const solvedOperator = effectiveOperator === "ne" ? "eq" : effectiveOperator;
924
+
925
+ if (
926
+ solvedOperator !== "eq" &&
927
+ solvedOperator !== "lt" &&
928
+ solvedOperator !== "le" &&
929
+ solvedOperator !== "gt" &&
930
+ solvedOperator !== "ge"
931
+ ) {
932
+ throw new UnsupportedQueryPlanError(`Operator ${effectiveOperator} is not supported with arithmetic`);
933
+ }
934
+
935
+ const solved = solveMonotone(f, solvedOperator, other.value);
936
+
937
+ const interval = decreasing ? negateInterval(solved) : solved;
938
+ const filter = buildIntervalFilter(context, fieldRef, interval);
939
+
940
+ return effectiveOperator === "ne" ? { NOT: filter } : filter;
941
+ }
942
+
943
+ /**
944
+ * `lo <= column <= hi`. An empty interval is a contradiction and an unbounded one a tautology,
945
+ * both spelled on the column, so a NULL keeps them UNKNOWN as the arithmetic's error requires.
946
+ */
947
+ function buildIntervalFilter(
948
+ context: TranslationContext,
949
+ fieldRef: ResolvedFieldReference,
950
+ interval: DoubleInterval
951
+ ): PrismaFilter {
952
+ if (interval.empty) {
953
+ return {
954
+ AND: [
955
+ buildComparisonFilter(context, fieldRef, "gt", 0),
956
+ buildComparisonFilter(context, fieldRef, "le", 0),
957
+ ],
958
+ };
959
+ }
960
+
961
+ const bounds: PrismaFilter[] = [];
962
+
963
+ if (interval.lo !== undefined) {
964
+ bounds.push(buildComparisonFilter(context, fieldRef, "ge", interval.lo));
965
+ }
966
+
967
+ if (interval.hi !== undefined) {
968
+ bounds.push(buildComparisonFilter(context, fieldRef, "le", interval.hi));
969
+ }
970
+
971
+ if (bounds.length === 0) {
972
+ return {
973
+ OR: [
974
+ buildComparisonFilter(context, fieldRef, "gt", 0),
975
+ buildComparisonFilter(context, fieldRef, "le", 0),
976
+ ],
977
+ };
978
+ }
979
+
980
+ // Each bound may itself be a bracket (see fractionalBracket); one flat AND reads better.
981
+ const flat = bounds.flatMap((bound) => {
982
+ const conjuncts = bound["AND"];
983
+
984
+ return Object.keys(bound).length === 1 && Array.isArray(conjuncts) ? conjuncts.filter(isPrismaFilter) : [bound];
985
+ });
986
+
987
+ return flat.length === 1 ? flat[0]! : { AND: flat };
988
+ }
989
+
990
+ /**
991
+ * The column value that makes `column + addConstant` (or `addConstant + column`) equal
992
+ * `comparisonValue`, or null when no string does.
993
+ */
994
+ function solveAdd(
995
+ comparisonValue: Value,
996
+ addConstant: Value,
997
+ fieldIsLeft: boolean
998
+ ): Value | null {
999
+ if (isString(comparisonValue) && isString(addConstant)) {
1000
+ if (fieldIsLeft) {
1001
+ if (!comparisonValue.endsWith(addConstant)) return null;
1002
+
1003
+ return comparisonValue.slice(
1004
+ 0,
1005
+ comparisonValue.length - addConstant.length
1006
+ );
1007
+ }
1008
+
1009
+ if (!comparisonValue.startsWith(addConstant)) return null;
1010
+
1011
+ return comparisonValue.slice(addConstant.length);
1012
+ }
1013
+
1014
+ if (isNumber(comparisonValue) && isNumber(addConstant)) {
1015
+ return comparisonValue - addConstant;
1016
+ }
1017
+
1018
+ throw new UnsupportedQueryPlanError("Type mismatch in add comparison");
1019
+ }
1020
+
1021
+ /**
1022
+ * Helper function to handle "in" operator
1023
+ */
1024
+ export function handleInOperator(
1025
+ operands: PlanExpressionOperand[],
1026
+ context: TranslationContext
1027
+ ): PrismaFilter {
1028
+ if (operands.length !== 2) {
1029
+ throw new UnsupportedQueryPlanError("in requires exactly two operands");
1030
+ }
1031
+
1032
+ const ternaryFilter = tryHandleTernaryComparison("in", operands, context);
1033
+
1034
+ if (ternaryFilter) {
1035
+ return ternaryFilter;
1036
+ }
1037
+
1038
+ const member = assertDefined(operands[0], "in requires a member operand");
1039
+
1040
+ const collection = assertDefined(
1041
+ operands[1],
1042
+ "in requires a collection operand"
1043
+ );
1044
+
1045
+ if (isNamedOperand(member) && isValueOperand(collection)) {
1046
+ if (!Array.isArray(collection.value)) throw scalarMembership();
1047
+
1048
+ return buildMembershipFilter(context, resolveFieldReference(member.name, context), collection.value);
1049
+ }
1050
+
1051
+ if (isValueOperand(member) && isNamedOperand(collection)) {
1052
+ if (namesToOneRelation(context.mapper, collection.name)) {
1053
+ throw new UnsupportedQueryPlanError(
1054
+ `in over ${collection.name}: it is a to-one relation, which CEL reads as a map, and ` +
1055
+ "`in` over a map tests its keys (the attribute names present on the related row); " +
1056
+ "a Prisma relation filter tests rows, not attribute names"
1057
+ );
1058
+ }
1059
+
1060
+ const fieldRef = resolveFieldReference(collection.name, context);
1061
+
1062
+ if (!fieldRef.relations?.length && fieldRef.valueType !== undefined) throw scalarMembership();
1063
+
1064
+ return buildMembershipFilter(context, fieldRef, [member.value]);
1065
+ }
1066
+
1067
+ if (isOperatorOperand(collection) && collection.operator === "list") {
1068
+ return handleListMembership(member, collection.operands, context);
1069
+ }
1070
+
1071
+ if (isOperatorOperand(collection) && collection.operator === "add") {
1072
+ throw new UnsupportedQueryPlanError(
1073
+ `Membership in a list built by ${collection.operator}(...) from columns: each element is ` +
1074
+ "compared with the member, and a Prisma field reference compares two plain columns of " +
1075
+ "one model, never a column with an expression over another column or with the " +
1076
+ "elements of a related list"
1077
+ );
1078
+ }
1079
+
1080
+ if (isNamedOperand(member) && isNamedOperand(collection)) {
1081
+ throw new UnsupportedQueryPlanError(
1082
+ "Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column"
1083
+ );
1084
+ }
1085
+
1086
+ throw new UnsupportedQueryPlanError(
1087
+ "in requires a field member and literal list, or a literal member and field collection"
1088
+ );
1089
+ }
1090
+
1091
+ function scalarMembership(): UnsupportedQueryPlanError {
1092
+ return new UnsupportedQueryPlanError(
1093
+ "in over a scalar is a CEL error, which a Prisma filter cannot express"
1094
+ );
1095
+ }
1096
+
1097
+ /**
1098
+ * `x in [a, b, ...]` over a list the planner could not fold because an element is a column. CEL
1099
+ * builds the list first, which raises an error as soon as one element is a missing attribute, and
1100
+ * then tests `x` against each element for equality, so the membership is the OR of the comparisons
1101
+ * `x == e`, each translated as that comparison would be. With several elements, an element column
1102
+ * the caller omits when NULL gets its presence test ANDed on, TRUE when the value is there and
1103
+ * UNKNOWN when the column is NULL, so a missing element leaves the whole membership UNKNOWN even
1104
+ * where another element matches. One element needs no test: its equality is already UNKNOWN on a
1105
+ * NULL column. An element that is neither a literal nor a column has no presence test, so the
1106
+ * membership is refused rather than let its error be absorbed by a matching sibling.
1107
+ */
1108
+ function handleListMembership(
1109
+ member: PlanExpressionOperand,
1110
+ elements: PlanExpressionOperand[],
1111
+ context: TranslationContext
1112
+ ): PrismaFilter {
1113
+ if (elements.length === 0) {
1114
+ throw new UnsupportedQueryPlanError("Membership in an empty list must be folded by the Cerbos planner");
1115
+ }
1116
+
1117
+ const equalities = elements.map((element) =>
1118
+ buildPrismaFilterFromCerbosExpression(
1119
+ { operator: "eq", operands: [member, element] },
1120
+ context
1121
+ )
1122
+ );
1123
+
1124
+ const filter = equalities.length === 1 ? equalities[0]! : { OR: equalities };
1125
+
1126
+ if (elements.length === 1) return filter;
1127
+
1128
+ const presences: PrismaFilter[] = [];
1129
+
1130
+ for (const element of elements) {
1131
+ if (isValueOperand(element)) continue;
1132
+
1133
+ if (!isNamedOperand(element)) {
1134
+ throw new UnsupportedQueryPlanError(
1135
+ "Membership in a list holding an expression beside other elements: building the list " +
1136
+ "raises the expression's error even where another element matches, and no Prisma " +
1137
+ "filter carries that error for an expression"
1138
+ );
1139
+ }
1140
+
1141
+ const fieldRef = resolveFieldReference(element.name, context);
1142
+
1143
+ if (isExplicitNullReference(fieldRef)) continue;
1144
+ const test = presence(element, fieldRef);
1145
+
1146
+ if (test === undefined) {
1147
+ throw new UnsupportedQueryPlanError(
1148
+ `Membership in a list holding ${element.name} beside other elements requires the ` +
1149
+ "column's valueType in the mapper, to spell the presence test a missing element needs"
1150
+ );
1151
+ }
1152
+
1153
+ presences.push(buildPrismaFilterFromCerbosExpression(test, context));
1154
+ }
1155
+
1156
+ return presences.length === 0 ? filter : { AND: [...presences, filter] };
1157
+ }