@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/strings.ts ADDED
@@ -0,0 +1,152 @@
1
+ // The receiver-sensitive string operators: `contains`, `startsWith`, `endsWith`.
2
+
3
+ import type { PlanExpressionOperand } from "@cerbos/core";
4
+
5
+ import { assertStringField, buildFieldFilter, buildPrefixFilter } from "./fields";
6
+ import type { PrismaFilter } from "./index";
7
+ import {
8
+ isResolvedFieldReference,
9
+ isResolvedValue,
10
+ recordErroringNullElement,
11
+ } from "./mapping";
12
+ import type { TranslationContext } from "./mapping";
13
+ import { assertDefined } from "./plan";
14
+ import { resolveOperand } from "./translate";
15
+ import { UnsupportedQueryPlanError } from "./errors";
16
+ import { isString } from "./narrowing";
17
+
18
+ // Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
19
+ const MAX_ENUMERATED_NEEDLES = 1000;
20
+
21
+ /**
22
+ * Every needle `haystack.<operator>(needle)` is true for: its prefixes for startsWith, its
23
+ * suffixes for endsWith, its substrings for contains — and the empty string for all three.
24
+ */
25
+ function candidateNeedles(operator: string, haystack: string): Set<string> {
26
+ const candidates = new Set<string>([""]);
27
+
28
+ if (operator === "startsWith") {
29
+ for (let i = 1; i <= haystack.length; i++) {
30
+ candidates.add(haystack.slice(0, i));
31
+ }
32
+ } else if (operator === "endsWith") {
33
+ for (let i = 0; i < haystack.length; i++) {
34
+ candidates.add(haystack.slice(i));
35
+ }
36
+ } else {
37
+ for (let start = 0; start < haystack.length; start++) {
38
+ for (let end = start + 1; end <= haystack.length; end++) {
39
+ candidates.add(haystack.slice(start, end));
40
+ }
41
+ }
42
+ }
43
+
44
+ return candidates;
45
+ }
46
+
47
+ /**
48
+ * Helper function to handle string operators (contains, startsWith, endsWith).
49
+ *
50
+ * These operators are receiver-sensitive and the planner preserves policy source order:
51
+ * operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
52
+ * inverts the match direction, so unlike symmetric comparisons they are never normalized.
53
+ */
54
+ export function handleStringOperator(
55
+ operator: string,
56
+ operands: PlanExpressionOperand[],
57
+ context: TranslationContext
58
+ ): PrismaFilter {
59
+ if (operands.length !== 2) {
60
+ throw new UnsupportedQueryPlanError(`${operator} requires exactly two operands`);
61
+ }
62
+
63
+ const receiver = resolveOperand(
64
+ assertDefined(operands[0], `${operator} requires a receiver operand`),
65
+ context
66
+ );
67
+
68
+ const needle = resolveOperand(
69
+ assertDefined(operands[1], `${operator} requires a needle operand`),
70
+ context
71
+ );
72
+
73
+ if (isResolvedFieldReference(receiver)) assertStringField(receiver, operator);
74
+
75
+ if (isResolvedFieldReference(needle)) assertStringField(needle, operator);
76
+ // A null element of a projected list is a value, and a string function over it is an error.
77
+ recordErroringNullElement(context, operands[0]!, receiver);
78
+ recordErroringNullElement(context, operands[1]!, needle);
79
+
80
+ // Column receiver, constant needle: Prisma's LIKE-based filter.
81
+ if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
82
+ const { value } = needle;
83
+
84
+ if (!isString(value)) {
85
+ throw new UnsupportedQueryPlanError(`${operator} operator requires string value`);
86
+ }
87
+
88
+ if (value.includes("\u0000")) {
89
+ throw new UnsupportedQueryPlanError(
90
+ `${operator} with a needle holding a NUL character: PostgreSQL text cannot hold one, and SQLite's LIKE stops reading the pattern at it, so %NUL% matched every row`,
91
+ );
92
+ }
93
+
94
+ // A prefix is matched by the range of strings it bounds, so startsWith needs no LIKE escaping
95
+ // (see buildPrefixFilter). Prisma emits LIKE without an ESCAPE clause and does not escape
96
+ // wildcard characters, so a contains or endsWith needle containing % or _ would match as a
97
+ // pattern instead of literally (contains("a_b") wrongly matches "axb"), and a suffix or
98
+ // substring bounds no range. Fail loudly rather than filter wrong.
99
+ //
100
+ // A backslash is the same hazard reached from the other direction, and only an executed
101
+ // PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
102
+ // character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
103
+ // DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
104
+ // ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
105
+ // silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
106
+ if (operator === "startsWith") {
107
+ return buildPrefixFilter(receiver, value);
108
+ }
109
+
110
+ if (/[%_\\]/.test(value)) {
111
+ throw new UnsupportedQueryPlanError(
112
+ `Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
113
+ "Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
114
+ "escape character on PostgreSQL and MySQL"
115
+ );
116
+ }
117
+
118
+ return buildFieldFilter(receiver, operator, value);
119
+ }
120
+
121
+ // Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
122
+ // candidate needle the constant admits into an exact IN filter. No LIKE, so no escaping
123
+ // hazards; a NULL needle column stays excluded (CEL missing-attribute deny).
124
+ if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
125
+ if (!isString(receiver.value)) {
126
+ throw new UnsupportedQueryPlanError(`${operator} operator requires a string receiver`);
127
+ }
128
+
129
+ const candidates = candidateNeedles(operator, receiver.value);
130
+
131
+ if (candidates.size > MAX_ENUMERATED_NEEDLES) {
132
+ throw new UnsupportedQueryPlanError(
133
+ `Cannot translate ${operator} with a constant receiver of this length: ` +
134
+ `enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
135
+ );
136
+ }
137
+
138
+ return buildFieldFilter(needle, "in", [...candidates]);
139
+ }
140
+
141
+ if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
142
+ // A column-valued needle would need its LIKE metacharacters escaped per row, which
143
+ // Prisma's filters cannot do (and Prisma leaks wildcards from field references).
144
+ throw new UnsupportedQueryPlanError(
145
+ `Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
146
+ );
147
+ }
148
+
149
+ throw new UnsupportedQueryPlanError(
150
+ `${operator} between two constants must be folded by the Cerbos planner`
151
+ );
152
+ }
package/src/ternary.ts ADDED
@@ -0,0 +1,359 @@
1
+ // `cond ? a : b`, in boolean position and as an operand of a comparison. Both lower to the same
2
+ // guarded disjunction, which keeps CEL's error semantics when the condition is UNKNOWN.
3
+
4
+ import type { PlanExpressionOperand, Value } from "@cerbos/core";
5
+
6
+ import { handleRelationalOperator } from "./comparison";
7
+ import { evaluateConstantComparison } from "./evaluate";
8
+ import { rejectConstantFalse } from "./fields";
9
+ import type { PrismaFilter } from "./index";
10
+ import type { TranslationContext } from "./mapping";
11
+ import {
12
+ CERBOS_TO_PRISMA_OPERATOR,
13
+ assertDefined,
14
+ isOperatorOperand,
15
+ isValueOperand,
16
+ normalizeBinaryOperands,
17
+ } from "./plan";
18
+ import {
19
+ buildNegatedFilter,
20
+ buildPrismaFilterFromCerbosExpression,
21
+ } from "./translate";
22
+ import { normalizeRfc3339Milliseconds } from "./timestamp";
23
+ import { UnsupportedQueryPlanError } from "./errors";
24
+ import { isBoolean, isString, lookup } from "./narrowing";
25
+
26
+ /** What one ternary branch contributes: a known constant, or a filter to be guarded. */
27
+ type TernaryBranchPredicate =
28
+ | { kind: "constant"; value: boolean }
29
+ | { kind: "filter"; filter: PrismaFilter };
30
+
31
+ // The comparison that holds exactly when `operator` does not, over two definite operands.
32
+ const COMPLEMENT_OPERATOR = {
33
+ eq: "ne",
34
+ ne: "eq",
35
+ lt: "ge",
36
+ le: "gt",
37
+ gt: "le",
38
+ ge: "lt",
39
+ } satisfies Record<string, string>;
40
+
41
+ type TernaryOperands = {
42
+ condition: PlanExpressionOperand;
43
+ thenBranch: PlanExpressionOperand;
44
+ elseBranch: PlanExpressionOperand;
45
+ };
46
+
47
+ function getTernaryOperands(operands: PlanExpressionOperand[]): TernaryOperands {
48
+ if (operands.length !== 3) {
49
+ throw new UnsupportedQueryPlanError(
50
+ `if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
51
+ );
52
+ }
53
+
54
+ return {
55
+ condition: assertDefined(
56
+ operands[0],
57
+ "if (ternary) requires a condition operand"
58
+ ),
59
+ thenBranch: assertDefined(
60
+ operands[1],
61
+ "if (ternary) requires a then operand"
62
+ ),
63
+ elseBranch: assertDefined(
64
+ operands[2],
65
+ "if (ternary) requires an else operand"
66
+ ),
67
+ };
68
+ }
69
+
70
+ function getConstantBooleanCondition(
71
+ condition: PlanExpressionOperand
72
+ ): boolean | undefined {
73
+ if (!isValueOperand(condition)) {
74
+ return undefined;
75
+ }
76
+
77
+ if (!isBoolean(condition.value)) {
78
+ throw new UnsupportedQueryPlanError("if (ternary) condition must be a boolean expression");
79
+ }
80
+
81
+ return condition.value;
82
+ }
83
+
84
+ function buildBooleanBranchFilter(
85
+ branch: PlanExpressionOperand,
86
+ context: TranslationContext
87
+ ): TernaryBranchPredicate {
88
+ if (!isValueOperand(branch)) {
89
+ return {
90
+ kind: "filter",
91
+ filter: buildPrismaFilterFromCerbosExpression(branch, context),
92
+ };
93
+ }
94
+
95
+ if (!isBoolean(branch.value)) {
96
+ throw new UnsupportedQueryPlanError(
97
+ "if (ternary) branch in boolean position must be a boolean"
98
+ );
99
+ }
100
+
101
+ return { kind: "constant", value: branch.value };
102
+ }
103
+
104
+ /**
105
+ * `(cond AND then) OR (NOT cond AND else) OR (cond AND NOT cond)`, dropping any arm a constant
106
+ * branch makes redundant.
107
+ *
108
+ * "The condition is definitively FALSE" is buildNegatedFilter's contract — three-valued, so a
109
+ * condition that is UNKNOWN (a NULL column, an erroring lambda body, an absent to-one parent)
110
+ * selects NEITHER branch. It delegates rather than spelling a second negation. A private copy is
111
+ * how the ternary missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is
112
+ * TRUE when the to-one parent is absent, so the else-branch was selected for rows CEL denies
113
+ * outright — the missing path is an evaluation error, not a false condition
114
+ * (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
115
+ * which a chained `and`/`or` condition needs for CEL's error absorption to survive.
116
+ */
117
+ function buildGuardedTernaryFilter(
118
+ condition: PlanExpressionOperand,
119
+ thenFilter: TernaryBranchPredicate,
120
+ elseFilter: TernaryBranchPredicate,
121
+ context: TranslationContext
122
+ ): PrismaFilter {
123
+ const conditionTrue = buildPrismaFilterFromCerbosExpression(
124
+ condition,
125
+ context
126
+ );
127
+
128
+ const conditionFalse = buildNegatedFilter(condition, context);
129
+ const guardedBranches: PrismaFilter[] = [];
130
+
131
+ if (thenFilter.kind === "filter") {
132
+ guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
133
+ } else if (thenFilter.value) {
134
+ guardedBranches.push(conditionTrue);
135
+ }
136
+
137
+ if (elseFilter.kind === "filter") {
138
+ guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
139
+ } else if (elseFilter.value) {
140
+ guardedBranches.push(conditionFalse);
141
+ }
142
+
143
+ // For a known condition this contradiction is false. If the condition is
144
+ // NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
145
+ // ternary UNKNOWN under an outer NOT instead of authorizing the row.
146
+ guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
147
+
148
+ return { OR: guardedBranches };
149
+ }
150
+
151
+ function finalizeTernaryBranchPredicate(
152
+ predicate: TernaryBranchPredicate
153
+ ): PrismaFilter {
154
+ if (predicate.kind === "filter") {
155
+ return predicate.filter;
156
+ }
157
+
158
+ return predicate.value ? {} : rejectConstantFalse();
159
+ }
160
+
161
+ export function handleBooleanTernaryOperator(
162
+ operands: PlanExpressionOperand[],
163
+ context: TranslationContext
164
+ ): PrismaFilter {
165
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
166
+ const constantCondition = getConstantBooleanCondition(condition);
167
+
168
+ if (constantCondition !== undefined) {
169
+ return finalizeTernaryBranchPredicate(
170
+ buildBooleanBranchFilter(
171
+ constantCondition ? thenBranch : elseBranch,
172
+ context
173
+ )
174
+ );
175
+ }
176
+
177
+ return buildGuardedTernaryFilter(
178
+ condition,
179
+ buildBooleanBranchFilter(thenBranch, context),
180
+ buildBooleanBranchFilter(elseBranch, context),
181
+ context
182
+ );
183
+ }
184
+
185
+ /** The comparison with its ternary operand replaced by `branch`, as a branch predicate. */
186
+ // A timestamp() literal compares as its canonical instant, never against a bare string.
187
+ function constantOf(
188
+ operand: PlanExpressionOperand | undefined
189
+ ): { value: Value; timestamp: boolean } | undefined {
190
+ if (operand === undefined) return undefined;
191
+
192
+ if (isValueOperand(operand)) return { value: operand.value, timestamp: false };
193
+
194
+ if (
195
+ isOperatorOperand(operand) &&
196
+ operand.operator === "timestamp" &&
197
+ operand.operands.length === 1
198
+ ) {
199
+ const literal = operand.operands[0];
200
+
201
+ if (literal !== undefined && isValueOperand(literal) && isString(literal.value)) {
202
+ return { value: normalizeRfc3339Milliseconds(literal.value), timestamp: true };
203
+ }
204
+ }
205
+
206
+ return undefined;
207
+ }
208
+
209
+ function buildTernaryComparisonBranch(
210
+ operator: string,
211
+ operands: PlanExpressionOperand[],
212
+ ternaryIndex: number,
213
+ branch: PlanExpressionOperand,
214
+ context: TranslationContext,
215
+ negated: boolean
216
+ ): TernaryBranchPredicate {
217
+ const substitutedOperands = operands.map((operand, index) =>
218
+ index === ternaryIndex ? branch : operand
219
+ );
220
+
221
+ assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
222
+ assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
223
+
224
+ // Membership is not symmetric: its member stays first even when it is the constant.
225
+ const normalized =
226
+ operator === "in"
227
+ ? { operator, operands: substitutedOperands }
228
+ : normalizeBinaryOperands(operator, substitutedOperands);
229
+
230
+ const [normalizedFirst, normalizedSecond] = normalized.operands;
231
+ const first = constantOf(normalizedFirst);
232
+ const second = constantOf(normalizedSecond);
233
+
234
+ if (
235
+ normalized.operands.length === 2 &&
236
+ first !== undefined &&
237
+ second !== undefined &&
238
+ first.timestamp === second.timestamp
239
+ ) {
240
+ return {
241
+ kind: "constant",
242
+ value:
243
+ evaluateConstantComparison(
244
+ normalized.operator,
245
+ first.value,
246
+ second.value
247
+ ) !== negated,
248
+ };
249
+ }
250
+
251
+ if (!negated) {
252
+ return {
253
+ kind: "filter",
254
+ filter: buildPrismaFilterFromCerbosExpression(normalized, context),
255
+ };
256
+ }
257
+
258
+ const nested = tryHandleTernaryComparison(
259
+ normalized.operator,
260
+ normalized.operands,
261
+ context,
262
+ true
263
+ );
264
+
265
+ if (nested !== null) return { kind: "filter", filter: nested };
266
+
267
+ if (normalized.operator === "in") {
268
+ return {
269
+ kind: "filter",
270
+ filter: buildNegatedFilter(
271
+ { operator: "in", operands: normalized.operands },
272
+ context
273
+ ),
274
+ };
275
+ }
276
+
277
+ // Keep missing-value and relation guards inside the selected branch. Constants
278
+ // are negated above, since !(NaN <= n) is true but NaN > n is false in CEL 0.30.
279
+ return {
280
+ kind: "filter",
281
+ filter: handleRelationalOperator(
282
+ assertDefined(
283
+ lookup(COMPLEMENT_OPERATOR, normalized.operator),
284
+ "Unsupported negated comparison"
285
+ ),
286
+ normalized.operands,
287
+ context
288
+ ),
289
+ };
290
+ }
291
+
292
+ /**
293
+ * A comparison or membership test with a ternary operand (`(c ? a : b) == x`,
294
+ * `x in (c ? a : b)`), translated by distributing the
295
+ * comparison into both branches; or null when `operator` is not a comparison or no operand is a
296
+ * ternary. `negated` builds the filter for the comparison's negation instead.
297
+ */
298
+ export function tryHandleTernaryComparison(
299
+ operator: string,
300
+ operands: PlanExpressionOperand[],
301
+ context: TranslationContext,
302
+ negated = false
303
+ ): PrismaFilter | null {
304
+ if (lookup(CERBOS_TO_PRISMA_OPERATOR, operator) === undefined && operator !== "in") {
305
+ return null;
306
+ }
307
+
308
+ const ternaryIndex = operands.findIndex(
309
+ (operand) => isOperatorOperand(operand) && operand.operator === "if"
310
+ );
311
+
312
+ if (ternaryIndex === -1) {
313
+ return null;
314
+ }
315
+
316
+ if (operands.length !== 2) {
317
+ throw new UnsupportedQueryPlanError(
318
+ `${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
319
+ );
320
+ }
321
+
322
+ const ternary = assertDefined(
323
+ operands[ternaryIndex],
324
+ "Ternary comparison operand is missing"
325
+ );
326
+
327
+ if (!isOperatorOperand(ternary)) {
328
+ throw new UnsupportedQueryPlanError("Ternary comparison operand must be an expression");
329
+ }
330
+
331
+ const { condition, thenBranch, elseBranch } = getTernaryOperands(
332
+ ternary.operands
333
+ );
334
+
335
+ const branchFor = (branch: PlanExpressionOperand) =>
336
+ buildTernaryComparisonBranch(
337
+ operator,
338
+ operands,
339
+ ternaryIndex,
340
+ branch,
341
+ context,
342
+ negated
343
+ );
344
+
345
+ const constantCondition = getConstantBooleanCondition(condition);
346
+
347
+ if (constantCondition !== undefined) {
348
+ return finalizeTernaryBranchPredicate(
349
+ branchFor(constantCondition ? thenBranch : elseBranch)
350
+ );
351
+ }
352
+
353
+ return buildGuardedTernaryFilter(
354
+ condition,
355
+ branchFor(thenBranch),
356
+ branchFor(elseBranch),
357
+ context
358
+ );
359
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The connection string a container-backed adversarial leg runs against — PostgreSQL or MySQL.
3
+ *
4
+ * `jest.globalSetup.adversarial.js` starts the container and exports `DATABASE_URL` before any
5
+ * test module loads. Throwing when it is missing is the point: a client that silently connected
6
+ * somewhere else — a developer's local PostgreSQL, an empty SQLite file — would replay the corpus
7
+ * against rows nobody seeded and report a passing differential over an empty store.
8
+ */
9
+ export function adversarialDatabaseUrl(): string {
10
+ const url = process.env["DATABASE_URL"];
11
+
12
+ if (!url) {
13
+ throw new Error(
14
+ "DATABASE_URL is not set: a container-backed adversarial leg is started by " +
15
+ "jest.globalSetup.adversarial.js, so run it through " +
16
+ "`npm run test:adversarial:postgres` or `npm run test:adversarial:mysql`"
17
+ );
18
+ }
19
+
20
+ return url;
21
+ }
@@ -0,0 +1,13 @@
1
+ import { Prisma, PrismaClient } from "./generated/prisma-adversarial-mysql-v6";
2
+
3
+ import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
4
+
5
+ // Prisma 6 reads the datasource URL from the schema's `env("DATABASE_URL")`, which the jest
6
+ // globalSetup exports once the container is up. Passing it explicitly as well keeps the failure
7
+ // mode loud: `adversarialDatabaseUrl` throws when the variable is missing, rather than letting the
8
+ // client fall back to whatever a developer happens to have in their environment.
9
+ const prisma = new PrismaClient({
10
+ datasources: { db: { url: adversarialDatabaseUrl() } },
11
+ });
12
+
13
+ export { prisma, Prisma };
@@ -0,0 +1,12 @@
1
+ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
2
+ import { Prisma, PrismaClient } from "./generated/prisma-adversarial-mysql/client";
3
+
4
+ import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
5
+
6
+ // Prisma 7 reaches a database only through a driver adapter, and `@prisma/adapter-mariadb` is the
7
+ // one Prisma ships for the MySQL provider — the MariaDB connector speaks the MySQL protocol,
8
+ // including MySQL 8.4's caching_sha2_password default.
9
+ const adapter = new PrismaMariaDb(adversarialDatabaseUrl());
10
+ const prisma = new PrismaClient({ adapter });
11
+
12
+ export { prisma, Prisma };
@@ -0,0 +1,13 @@
1
+ import { Prisma, PrismaClient } from "./generated/prisma-adversarial-pg-v6";
2
+
3
+ import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
4
+
5
+ // Prisma 6 reads the datasource URL from the schema's `env("DATABASE_URL")`, which the jest
6
+ // globalSetup exports once the container is up. Passing it explicitly as well keeps the failure
7
+ // mode loud: `adversarialDatabaseUrl` throws when the variable is missing, rather than letting the
8
+ // client fall back to whatever a developer happens to have in their environment.
9
+ const prisma = new PrismaClient({
10
+ datasources: { db: { url: adversarialDatabaseUrl() } },
11
+ });
12
+
13
+ export { prisma, Prisma };
@@ -0,0 +1,9 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { Prisma, PrismaClient } from "./generated/prisma-adversarial-pg/client";
3
+
4
+ import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
5
+
6
+ const adapter = new PrismaPg({ connectionString: adversarialDatabaseUrl() });
7
+ const prisma = new PrismaClient({ adapter });
8
+
9
+ export { prisma, Prisma };
@@ -7,6 +7,7 @@ import {
7
7
  const adapter = new PrismaBetterSqlite3({
8
8
  url: "./prisma/dev-adversarial.db",
9
9
  });
10
+
10
11
  const prisma = new PrismaClient({ adapter });
11
12
 
12
13
  export { prisma, Prisma };