@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,419 @@
1
+ // The translator's core: one dispatch from a plan operator to its handler, the negation that
2
+ // every handler shares, and the resolution of an operand into a column or a constant.
3
+
4
+ import type { PlanExpressionOperand, Value } from "@cerbos/core";
5
+
6
+ import {
7
+ buildNegatedCollectionFilter,
8
+ handleCollectionOperator,
9
+ handleHasIntersectionOperator,
10
+ handleLambdaOperator,
11
+ handleMapOperator,
12
+ } from "./collections";
13
+ import {
14
+ containsMapLiteralLookup,
15
+ handleInOperator,
16
+ handleRelationalOperator,
17
+ tryHandleMapLiteralLookup,
18
+ } from "./comparison";
19
+ import { ARITHMETIC_OPERATORS, foldArithmetic } from "./evaluate";
20
+ import { buildFieldFilter } from "./fields";
21
+ import {
22
+ handleAncestorDescendantOperator,
23
+ handleOverlapsOperator,
24
+ } from "./hierarchy";
25
+ import type { PrismaFilter } from "./index";
26
+ import { isResolvedValue, lookupMapping, resolveFieldReference } from "./mapping";
27
+ import type { ResolvedOperand, TranslationContext } from "./mapping";
28
+ import {
29
+ assertDefined,
30
+ assertLogicalOperands,
31
+ isNamedOperand,
32
+ isOperatorOperand,
33
+ isValueOperand,
34
+ } from "./plan";
35
+ import type { OperatorOperand } from "./plan";
36
+ import { handleHasOperator } from "./presence";
37
+ import { negateRequiringHops, referencesRequiredHop } from "./relations";
38
+ import { containsCollectionOperator } from "./rewrite";
39
+ import { handleMatchesOperator } from "./regex";
40
+ import { handleStringOperator } from "./strings";
41
+ import { handleBooleanTernaryOperator, tryHandleTernaryComparison } from "./ternary";
42
+ import { normalizeRfc3339Milliseconds, parseRfc3339Instant } from "./timestamp";
43
+ import { UnsupportedQueryPlanError } from "./errors";
44
+ import { isString, lookup, triple } from "./narrowing";
45
+
46
+ /**
47
+ * Builds a Prisma filter from a Cerbos expression.
48
+ */
49
+ export function buildPrismaFilterFromCerbosExpression(
50
+ expression: PlanExpressionOperand,
51
+ context: TranslationContext
52
+ ): PrismaFilter {
53
+ // A bare named operand represents a boolean field reference (e.g. `R.attr.booleanAttr`)
54
+ if (isNamedOperand(expression)) {
55
+ return buildFieldFilter(
56
+ booleanFieldReference(expression.name, context),
57
+ "equals",
58
+ true
59
+ );
60
+ }
61
+
62
+ if (!isOperatorOperand(expression)) {
63
+ throw new UnsupportedQueryPlanError("Invalid Cerbos expression structure");
64
+ }
65
+
66
+ // Every plan operator this adapter translates. Adding an operator is adding a case here;
67
+ // anything else is refused as unsupported.
68
+ const { operator, operands } = expression;
69
+ assertLogicalOperands(operator, operands);
70
+
71
+ switch (operator) {
72
+ case "and":
73
+ return {
74
+ AND: operands.map((operand) =>
75
+ buildPrismaFilterFromCerbosExpression(operand, context)
76
+ ),
77
+ };
78
+ case "or":
79
+ return {
80
+ OR: operands.map((operand) =>
81
+ buildPrismaFilterFromCerbosExpression(operand, context)
82
+ ),
83
+ };
84
+ case "not": {
85
+ const operand = operands[0];
86
+
87
+ if (!operand) {
88
+ throw new UnsupportedQueryPlanError("not operator requires an operand");
89
+ }
90
+
91
+ return buildNegatedFilter(operand, context);
92
+ }
93
+
94
+ case "if":
95
+ return handleBooleanTernaryOperator(operands, context);
96
+ case "eq":
97
+ case "ne":
98
+ case "lt":
99
+ case "le":
100
+ case "gt":
101
+ case "ge":
102
+ return handleRelationalOperator(operator, operands, context);
103
+ case "in":
104
+ return handleInOperator(operands, context);
105
+ case "contains":
106
+ case "startsWith":
107
+ case "endsWith":
108
+ return handleStringOperator(operator, operands, context);
109
+ case "matches":
110
+ return handleMatchesOperator(operands, context);
111
+ case "has":
112
+ return handleHasOperator(operands, context, false);
113
+ case "hasIntersection":
114
+ return handleHasIntersectionOperator(operands, context);
115
+ case "lambda":
116
+ return handleLambdaOperator(operands, context);
117
+ case "exists":
118
+ case "exists_one":
119
+ case "all":
120
+ case "except":
121
+ case "filter":
122
+ return handleCollectionOperator(operator, operands, context);
123
+ case "map":
124
+ return handleMapOperator(operands, context);
125
+ case "overlaps":
126
+ return handleOverlapsOperator(operands, context);
127
+ case "ancestorOf":
128
+ return handleAncestorDescendantOperator(operands, context, "ancestor");
129
+ case "descendentOf":
130
+ return handleAncestorDescendantOperator(operands, context, "descendant");
131
+ default:
132
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${operator}`);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
138
+ * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
139
+ * subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
140
+ * wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
141
+ * attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
142
+ * therefore pushed down to the collection operators, which encode CEL's exact error
143
+ * semantics:
144
+ *
145
+ * - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
146
+ * and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
147
+ * UNKNOWN-element (the nullable-field guard).
148
+ * - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
149
+ * elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
150
+ */
151
+ /**
152
+ * A field read where CEL needs a boolean. A field declared another type is a no-overload error,
153
+ * which no Prisma filter keeps out under both polarities, so it is refused.
154
+ */
155
+ function booleanFieldReference(name: string, context: TranslationContext) {
156
+ if (lookupMapping(context.mapper, name)?.relation?.type === "many") {
157
+ throw new UnsupportedQueryPlanError("A list in boolean position is a CEL type error");
158
+ }
159
+
160
+ const fieldRef = resolveFieldReference(name, context);
161
+
162
+ if (fieldRef.valueType !== undefined && fieldRef.valueType !== "boolean") {
163
+ throw new UnsupportedQueryPlanError(
164
+ `A ${fieldRef.valueType} field in boolean position is a CEL type error`
165
+ );
166
+ }
167
+
168
+ return fieldRef;
169
+ }
170
+
171
+ export function buildNegatedFilter(
172
+ operand: PlanExpressionOperand,
173
+ context: TranslationContext
174
+ ): PrismaFilter {
175
+ if (isOperatorOperand(operand)) {
176
+ if (operand.operator === "not") {
177
+ return buildPrismaFilterFromCerbosExpression(
178
+ assertDefined(operand.operands[0], "not requires an operand"),
179
+ context
180
+ );
181
+ }
182
+
183
+ if (operand.operator === "has") {
184
+ return handleHasOperator(operand.operands, context, true);
185
+ }
186
+
187
+ const ternary = tryHandleTernaryComparison(
188
+ operand.operator,
189
+ operand.operands,
190
+ context,
191
+ true
192
+ );
193
+
194
+ if (ternary !== null) return ternary;
195
+
196
+ const lookup = tryHandleMapLiteralLookup(
197
+ operand.operator,
198
+ operand.operands,
199
+ context,
200
+ true
201
+ );
202
+
203
+ if (lookup !== null) return lookup;
204
+ }
205
+
206
+ if (isNamedOperand(operand)) {
207
+ const { relations, ...fieldRef } = booleanFieldReference(operand.name, context);
208
+
209
+ if (!relations || relations.length === 0) {
210
+ return buildFieldFilter(fieldRef, "equals", false);
211
+ }
212
+
213
+ return negateRequiringHops(
214
+ operand,
215
+ buildPrismaFilterFromCerbosExpression(operand, context),
216
+ context
217
+ );
218
+ }
219
+
220
+ // `and`/`or` must be pushed through with De Morgan whenever a branch can be UNKNOWN, so
221
+ // CEL's error absorption survives: `!(A && B)` with A erroring and B false is TRUE in CEL,
222
+ // and `OR[!A, !B]` reproduces that where a single outer NOT over the conjunction — with the
223
+ // hop requirement ANDed outside it — would deny. A to-one hop is the second source of UNKNOWN
224
+ // besides the collection macros, so it opens the same push-down, and each leaf requires only
225
+ // the hops it reads itself. (A nested `not` was already unwrapped above.) A lookup into a map literal
226
+ // is the third: a missing key is an error, which the comparison's own negation leaves out.
227
+ if (
228
+ isOperatorOperand(operand) &&
229
+ (containsCollectionOperator(operand) ||
230
+ containsMapLiteralLookup(operand) ||
231
+ referencesRequiredHop(operand, context))
232
+ ) {
233
+ switch (operand.operator) {
234
+ case "and":
235
+ return {
236
+ OR: operand.operands.map((o) => buildNegatedFilter(o, context)),
237
+ };
238
+ case "or":
239
+ return {
240
+ AND: operand.operands.map((o) => buildNegatedFilter(o, context)),
241
+ };
242
+ // `!(c ? a : b)` is `c ? !a : !b`, errors included: a condition that errors errors either
243
+ // way. A NOT over the guarded-branch disjunction instead selects the rows where the
244
+ // condition is UNKNOWN, which neither guarded branch matched.
245
+ case "if":
246
+ if (operand.operands.length === 3) {
247
+ const [condition, thenBranch, elseBranch] = triple(operand.operands);
248
+
249
+ return handleBooleanTernaryOperator(
250
+ [
251
+ condition,
252
+ { operator: "not", operands: [thenBranch] },
253
+ { operator: "not", operands: [elseBranch] },
254
+ ],
255
+ context
256
+ );
257
+ }
258
+
259
+ break;
260
+ case "exists":
261
+ case "all":
262
+ case "except":
263
+ case "exists_one":
264
+ case "filter":
265
+ return buildNegatedCollectionFilter(
266
+ operand.operator,
267
+ operand.operands,
268
+ context
269
+ );
270
+ }
271
+
272
+ if (containsMapLiteralLookup(operand)) {
273
+ // A missing key is an error under both polarities, which a NOT over the positive filter
274
+ // would turn into a match; only and/or and the comparison itself push the negation down.
275
+ throw new UnsupportedQueryPlanError(
276
+ `Cannot negate ${operand.operator} over an index into a map literal: a missing key is an ` +
277
+ "error under both polarities, and a NOT over its filter would select it"
278
+ );
279
+ }
280
+ }
281
+
282
+ return negateRequiringHops(
283
+ operand,
284
+ buildPrismaFilterFromCerbosExpression(operand, context),
285
+ context
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Resolves a plan operand into a column reference or a constant. A nested expression that is
291
+ * neither a timestamp nor foldable arithmetic resolves to the filter it translates to.
292
+ *
293
+ * A timestamp literal between two milliseconds is refused unless `allowSubMillisecond` is set, by
294
+ * a caller that compares it against a column and applies roundSubMillisecond.
295
+ */
296
+ export function resolveOperand(
297
+ operand: PlanExpressionOperand,
298
+ context: TranslationContext,
299
+ allowSubMillisecond = false
300
+ ): ResolvedOperand {
301
+ if (isNamedOperand(operand)) {
302
+ return resolveFieldReference(operand.name, context);
303
+ }
304
+
305
+ if (isValueOperand(operand)) {
306
+ return { value: operand.value };
307
+ }
308
+
309
+ if (isOperatorOperand(operand)) {
310
+ if (operand.operator === "timestamp") {
311
+ return resolveTimestampOperand(operand, context, allowSubMillisecond);
312
+ }
313
+
314
+ const identity = resolveIdentityCast(operand, context);
315
+
316
+ if (identity !== undefined) return identity;
317
+ const folded = tryFoldValueExpression(operand, context);
318
+
319
+ if (folded !== null) return { value: folded };
320
+
321
+ return { value: buildPrismaFilterFromCerbosExpression(operand, context) };
322
+ }
323
+
324
+ throw new UnsupportedQueryPlanError("Operand must have name, value, or be an expression");
325
+ }
326
+
327
+ /**
328
+ * `string(column)` over a string column and `double(column)` over a number column convert nothing:
329
+ * the column itself, except that the cast of a null VALUE is an error. The reference is resolved
330
+ * as one whose NULL is missing, so no comparison against it can select a NULL row.
331
+ */
332
+ function resolveIdentityCast(
333
+ expression: OperatorOperand,
334
+ context: TranslationContext
335
+ ): ResolvedOperand | undefined {
336
+ const target = lookup(IDENTITY_CASTS, expression.operator);
337
+ const [operand] = expression.operands;
338
+
339
+ if (
340
+ target === undefined ||
341
+ expression.operands.length !== 1 ||
342
+ operand === undefined ||
343
+ !isNamedOperand(operand)
344
+ ) {
345
+ return undefined;
346
+ }
347
+
348
+ const fieldRef = resolveFieldReference(operand.name, context);
349
+
350
+ if (fieldRef.valueType !== target) return undefined;
351
+
352
+ return { ...fieldRef, nullAttributeRepresentation: "omitted" };
353
+ }
354
+
355
+ const IDENTITY_CASTS = { string: "string", double: "number" } satisfies Record<string, string>;
356
+
357
+ function resolveTimestampOperand(
358
+ expression: OperatorOperand,
359
+ context: TranslationContext,
360
+ allowSubMillisecond: boolean
361
+ ): ResolvedOperand {
362
+ if (expression.operands.length !== 1) {
363
+ throw new UnsupportedQueryPlanError("timestamp() requires exactly one operand");
364
+ }
365
+
366
+ const operand = assertDefined(
367
+ expression.operands[0],
368
+ "timestamp() requires an operand"
369
+ );
370
+
371
+ if (isNamedOperand(operand)) {
372
+ const fieldRef = resolveFieldReference(operand.name, context);
373
+
374
+ if (fieldRef.valueType !== "dateTime") {
375
+ throw new UnsupportedQueryPlanError(
376
+ `timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`
377
+ );
378
+ }
379
+
380
+ return { ...fieldRef, timestampWrapped: true };
381
+ }
382
+
383
+ if (!isValueOperand(operand) || !isString(operand.value)) {
384
+ throw new UnsupportedQueryPlanError("timestamp() requires a field reference or RFC 3339 string");
385
+ }
386
+
387
+ if (!allowSubMillisecond) {
388
+ return { value: normalizeRfc3339Milliseconds(operand.value) };
389
+ }
390
+
391
+ const { iso, subMillisecond } = parseRfc3339Instant(operand.value);
392
+
393
+ return subMillisecond ? { value: iso, subMillisecond } : { value: iso };
394
+ }
395
+
396
+ /** The value of arithmetic over two constant operands, or null when it cannot be folded. */
397
+ export function tryFoldValueExpression(
398
+ expr: OperatorOperand,
399
+ context: TranslationContext
400
+ ): Value | null {
401
+ if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
402
+ const leftOp = expr.operands[0];
403
+ const rightOp = expr.operands[1];
404
+
405
+ if (!leftOp || !rightOp) return null;
406
+
407
+ const left = resolveOperand(leftOp, context);
408
+
409
+ if (!isResolvedValue(left)) return null;
410
+ const right = resolveOperand(rightOp, context);
411
+
412
+ if (!isResolvedValue(right)) return null;
413
+
414
+ try {
415
+ return foldArithmetic(expr.operator, left.value, right.value);
416
+ } catch {
417
+ return null;
418
+ }
419
+ }