@cerbos/orm-prisma 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +598 -603
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +23 -13
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2555
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +27 -17
  95. package/src/adversarial.test.ts +376 -1071
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +433 -250
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +10 -0
  104. package/src/generated/prisma-adversarial/client.ts +10 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
  114. package/src/generated/prisma-adversarial/models.ts +2 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
  117. package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
  118. package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  121. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  130. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  131. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  132. package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
  133. package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
  134. package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
  135. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  136. package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
  137. package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
  138. package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
  139. package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
  140. package/src/generated/prisma-adversarial-pg/client.ts +10 -0
  141. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
  142. package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
  143. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
  144. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  148. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
  149. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
  150. package/src/generated/prisma-adversarial-pg/models.ts +2 -0
  151. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
  152. package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
  153. package/src/hierarchy.ts +405 -0
  154. package/src/index.ts +66 -3646
  155. package/src/literals.ts +363 -0
  156. package/src/mapping.ts +474 -0
  157. package/src/narrowing.ts +33 -0
  158. package/src/plan.ts +125 -0
  159. package/src/presence.ts +114 -0
  160. package/src/regex.ts +596 -0
  161. package/src/relations.ts +226 -0
  162. package/src/rewrite.ts +524 -0
  163. package/src/solve.ts +96 -0
  164. package/src/strings.ts +152 -0
  165. package/src/ternary.ts +359 -0
  166. package/src/test-setup.adversarial-url.ts +6 -3
  167. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  168. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  169. package/src/test-setup.adversarial.v7.ts +1 -0
  170. package/src/timestamp.ts +308 -0
  171. package/src/translate.ts +419 -0
  172. package/src/translator.test.ts +497 -1317
  173. package/src/types.ts +1310 -0
  174. package/src/value.ts +16 -0
package/src/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The error `queryPlanToPrisma` raises when a plan holds a shape this adapter cannot translate
3
+ * without returning rows the PDP would deny: a CEL operator with no faithful Prisma filter, a
4
+ * literal it cannot bind exactly, a construct CEL itself rejects, or a plan that is malformed.
5
+ *
6
+ * It extends `Error`, so existing `catch` blocks keep working. Mapper misconfiguration — a
7
+ * missing `model` option or `relation.model`, an empty field path — is not a refusal and stays a
8
+ * plain `Error`: that is a bug in the caller's mapping, not a limitation of the adapter.
9
+ */
10
+ export class UnsupportedQueryPlanError extends Error {
11
+ override readonly name = "UnsupportedQueryPlanError";
12
+ }
@@ -0,0 +1,170 @@
1
+ // Evaluating operators whose operands are all constants, with CEL's semantics.
2
+
3
+ import type { Value } from "@cerbos/core";
4
+ import { UnsupportedQueryPlanError } from "./errors";
5
+ import { isNumber, isObject, isString } from "./narrowing";
6
+
7
+ export const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
8
+
9
+ export function evaluateConstantComparison(
10
+ operator: string,
11
+ left: Value,
12
+ right: Value
13
+ ): boolean {
14
+ switch (operator) {
15
+ case "in":
16
+ if (!Array.isArray(right)) {
17
+ throw new UnsupportedQueryPlanError("in constant comparison requires a list");
18
+ }
19
+
20
+ return right.some((element) => areValuesEqual(left, element));
21
+ case "eq":
22
+ return areValuesEqual(left, right);
23
+ case "ne":
24
+ return !areValuesEqual(left, right);
25
+ case "lt":
26
+ case "le":
27
+ case "gt":
28
+ case "ge": {
29
+ if (
30
+ isNumber(left) &&
31
+ isNumber(right) &&
32
+ (Number.isNaN(left) || Number.isNaN(right))
33
+ ) {
34
+ return false;
35
+ }
36
+
37
+ if (
38
+ !(
39
+ (isNumber(left) && isNumber(right)) ||
40
+ (isString(left) && isString(right))
41
+ )
42
+ ) {
43
+ throw new UnsupportedQueryPlanError(
44
+ `${operator} constant comparison requires two numbers or two strings`
45
+ );
46
+ }
47
+
48
+ const order = compareConstantValues(left, right);
49
+
50
+ switch (operator) {
51
+ case "lt":
52
+ return order < 0;
53
+ case "le":
54
+ return order <= 0;
55
+ case "gt":
56
+ return order > 0;
57
+ case "ge":
58
+ return order >= 0;
59
+ }
60
+ }
61
+ }
62
+
63
+ throw new UnsupportedQueryPlanError(`Unsupported constant comparison operator: ${operator}`);
64
+ }
65
+
66
+ /** Orders numbers numerically and strings by Unicode code point, as CEL does. */
67
+ function compareConstantValues(
68
+ left: number | string,
69
+ right: number | string
70
+ ): number {
71
+ if (isNumber(left) && isNumber(right)) {
72
+ return left === right ? 0 : left < right ? -1 : 1;
73
+ }
74
+
75
+ if (!isString(left) || !isString(right)) {
76
+ throw new UnsupportedQueryPlanError("Cannot order constant values of different types");
77
+ }
78
+
79
+ const leftCodePoints = Array.from(left, (character) =>
80
+ character.codePointAt(0)
81
+ );
82
+
83
+ const rightCodePoints = Array.from(right, (character) =>
84
+ character.codePointAt(0)
85
+ );
86
+
87
+ const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
88
+
89
+ for (let index = 0; index < sharedLength; index++) {
90
+ const leftCodePoint = leftCodePoints[index]!;
91
+ const rightCodePoint = rightCodePoints[index]!;
92
+
93
+ if (leftCodePoint !== rightCodePoint) {
94
+ return leftCodePoint < rightCodePoint ? -1 : 1;
95
+ }
96
+ }
97
+
98
+ return leftCodePoints.length === rightCodePoints.length
99
+ ? 0
100
+ : leftCodePoints.length < rightCodePoints.length
101
+ ? -1
102
+ : 1;
103
+ }
104
+
105
+ function areValuesEqual(left: Value, right: Value): boolean {
106
+ if (Array.isArray(left)) {
107
+ return (
108
+ Array.isArray(right) &&
109
+ left.length === right.length &&
110
+ left.every((value, index) => areValuesEqual(value, right[index]!))
111
+ );
112
+ }
113
+
114
+ if (Array.isArray(right)) {
115
+ return false;
116
+ }
117
+
118
+ if (isObject(left)) {
119
+ if (!isObject(right)) {
120
+ return false;
121
+ }
122
+
123
+ const leftKeys = Object.keys(left);
124
+ const rightKeys = Object.keys(right);
125
+
126
+ return (
127
+ leftKeys.length === rightKeys.length &&
128
+ leftKeys.every(
129
+ (key) => key in right && areValuesEqual(left[key]!, right[key]!)
130
+ )
131
+ );
132
+ }
133
+
134
+ return left === right;
135
+ }
136
+
137
+ export function foldArithmetic(
138
+ operator: string,
139
+ left: Value,
140
+ right: Value
141
+ ): Value {
142
+ if (operator === "add" && Array.isArray(left) && Array.isArray(right)) {
143
+ return [...left, ...right];
144
+ }
145
+
146
+ if (
147
+ operator === "add" &&
148
+ isString(left) &&
149
+ isString(right)
150
+ ) {
151
+ return left + right;
152
+ }
153
+
154
+ if (!isNumber(left) || !isNumber(right)) {
155
+ throw new UnsupportedQueryPlanError(`${operator} operator requires string or number operands`);
156
+ }
157
+
158
+ switch (operator) {
159
+ case "add":
160
+ return left + right;
161
+ case "sub":
162
+ return left - right;
163
+ case "mult":
164
+ return left * right;
165
+ case "div":
166
+ return left / right;
167
+ default:
168
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${operator}`);
169
+ }
170
+ }
package/src/fields.ts ADDED
@@ -0,0 +1,373 @@
1
+ // Predicates on one resolved column: the leaves every operator handler bottoms out in.
2
+
3
+ import type { Value } from "@cerbos/core";
4
+
5
+ import type { PrismaFilter } from "./index";
6
+ import {
7
+ assertNullOperandTranslatable,
8
+ getLeafField,
9
+ isExplicitNullReference,
10
+ } from "./mapping";
11
+ import type { ResolvedFieldReference, TranslationContext } from "./mapping";
12
+ import { CERBOS_TO_PRISMA_OPERATOR, assertDefined } from "./plan";
13
+ import { wrapInRelations } from "./relations";
14
+ import { UnsupportedQueryPlanError } from "./errors";
15
+ import { isBoolean, isNumber, isObject, lookup, typeofValue } from "./narrowing";
16
+
17
+ /**
18
+ * Prisma has no model-agnostic always-false `where` shape — empty logical arrays are ignored —
19
+ * so a predicate that folds to constant false is refused. Real PDP plans fold it to
20
+ * ALWAYS_DENIED before it reaches the adapter.
21
+ */
22
+ export function rejectConstantFalse(): never {
23
+ throw new UnsupportedQueryPlanError(
24
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
25
+ );
26
+ }
27
+
28
+ /** `{ field: { [prismaOp]: value } }`, nested through the field's relations. */
29
+ export function buildFieldFilter(
30
+ fieldRef: ResolvedFieldReference,
31
+ prismaOp: string,
32
+ value: Value
33
+ ): PrismaFilter {
34
+ const fieldName = getLeafField(fieldRef.path);
35
+
36
+ return wrapInRelations(fieldRef.relations, {
37
+ [fieldName]: { [prismaOp]: value },
38
+ });
39
+ }
40
+
41
+ const LIKE_METACHARACTERS = /[%_\\]/;
42
+
43
+ /**
44
+ * `column.startsWith(prefix)`, or, when `strict`, the prefix followed by at least one character.
45
+ *
46
+ * Prisma's `startsWith` is a LIKE with no ESCAPE clause, so a prefix holding `%`, `_` or `\` is
47
+ * spelled as the range of strings it bounds instead: every string with that prefix is at least the
48
+ * prefix and below the prefix with its last code point stepped up, and no other string is, under
49
+ * the code-point order CEL's own string comparisons already require of the collation. A NULL
50
+ * column is UNKNOWN under either spelling, as the missing attribute's error requires.
51
+ */
52
+ export function buildPrefixFilter(
53
+ fieldRef: ResolvedFieldReference,
54
+ prefix: string,
55
+ strict = false
56
+ ): PrismaFilter {
57
+ if (!LIKE_METACHARACTERS.test(prefix)) {
58
+ return buildFieldFilter(fieldRef, "startsWith", strict ? `${prefix}_` : prefix);
59
+ }
60
+
61
+ const fieldName = getLeafField(fieldRef.path);
62
+ const lower = { [fieldName]: { [strict ? "gt" : "gte"]: prefix } };
63
+ const bound = firstStringPastPrefix(prefix);
64
+
65
+ return wrapInRelations(
66
+ fieldRef.relations,
67
+ bound === undefined ? lower : { AND: [lower, { [fieldName]: { lt: bound } }] }
68
+ );
69
+ }
70
+
71
+ /**
72
+ * The least string, in code-point order, above every string that starts with `prefix`: the prefix
73
+ * with its last code point stepped up, skipping the surrogate range no string holds. A trailing
74
+ * U+10FFFF has no successor, so it is dropped and the step applied before it; undefined when
75
+ * nothing is left, where no string is above the prefix.
76
+ */
77
+ function firstStringPastPrefix(prefix: string): string | undefined {
78
+ const codePoints = Array.from(prefix, (character) => character.codePointAt(0)!);
79
+
80
+ while (codePoints.length > 0) {
81
+ const last = codePoints.pop()!;
82
+
83
+ if (last >= 0x10ffff) continue;
84
+ const next = last + 1 === 0xd800 ? 0xe000 : last + 1;
85
+
86
+ return String.fromCodePoint(...codePoints, next);
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ /** A filter no row satisfies, spelled on a column the model is known to have. */
93
+ export function buildImpossibleFilter(
94
+ fieldRef: ResolvedFieldReference
95
+ ): PrismaFilter {
96
+ return buildFieldFilter(fieldRef, "in", []);
97
+ }
98
+
99
+ function buildDirectOrInLeaf(
100
+ fieldRef: ResolvedFieldReference,
101
+ values: Value[]
102
+ ): PrismaFilter {
103
+ const fieldName = getLeafField(fieldRef.path);
104
+
105
+ return values.length === 1
106
+ ? { [fieldName]: values[0] }
107
+ : { [fieldName]: { in: values } };
108
+ }
109
+
110
+ /**
111
+ * `field IS NOT NULL AND <leaf>`, so a NULL column makes the whole thing definitely FALSE rather
112
+ * than merely unmatched — which is what lets an enclosing `NOT` include the row.
113
+ *
114
+ * Prisma has no three-valued logic to expose: a record either matches a `where` or it does not.
115
+ * That is exactly why the guard has to be explicit — `{ NOT: { field: { equals: "x" } } }` leans on
116
+ * the generated SQL's UNKNOWN, which excludes the record under both polarities, and an
117
+ * explicit-null attribute needs the negation to include it.
118
+ *
119
+ * The guard is ANDed with the leaf BEFORE the relation wrapping, so both halves are evaluated
120
+ * against the same element rather than against two independently-matched ones.
121
+ */
122
+ function guardedFieldFilter(
123
+ fieldRef: ResolvedFieldReference,
124
+ leaf: PrismaFilter
125
+ ): PrismaFilter {
126
+ const fieldName = getLeafField(fieldRef.path);
127
+
128
+ return wrapInRelations(fieldRef.relations, {
129
+ AND: [{ [fieldName]: { not: null } }, leaf],
130
+ });
131
+ }
132
+
133
+ function assertScalarValue(
134
+ fieldRef: ResolvedFieldReference,
135
+ value: Value,
136
+ operator: string
137
+ ): void {
138
+ if (isObject(value)) {
139
+ throw new UnsupportedQueryPlanError(
140
+ `${operator} requires scalar values: Prisma cannot compare list or map elements`
141
+ );
142
+ }
143
+
144
+ if (
145
+ value !== null &&
146
+ fieldRef.valueType !== undefined &&
147
+ fieldRef.valueType !== "dateTime" &&
148
+ typeofValue(value) !== fieldRef.valueType
149
+ ) {
150
+ throw new UnsupportedQueryPlanError(
151
+ `${operator} value type does not match mapped ${fieldRef.valueType} field`
152
+ );
153
+ }
154
+ }
155
+
156
+ export function assertStringField(
157
+ fieldRef: ResolvedFieldReference,
158
+ operator: string
159
+ ): void {
160
+ if (fieldRef.valueType !== undefined && fieldRef.valueType !== "string") {
161
+ throw new UnsupportedQueryPlanError(
162
+ `${operator} requires a string field, got ${fieldRef.valueType}`
163
+ );
164
+ }
165
+ }
166
+
167
+ /** `field IN values`, with CEL's semantics for a null element and for an explicit-null column. */
168
+ export function buildMembershipFilter(
169
+ context: TranslationContext,
170
+ fieldRef: ResolvedFieldReference,
171
+ listed: Value[]
172
+ ): PrismaFilter {
173
+ // A column declared NOT NULL never equals a null element, and Prisma rejects `{ field: null }` on one.
174
+ const values = fieldRef.nullable === false ? listed.filter((value) => value !== null) : listed;
175
+
176
+ for (const value of values) assertScalarValue(fieldRef, value, "in");
177
+ const nonNullValues = values.filter((value) => value !== null);
178
+ const carriesNull = nonNullValues.length !== values.length;
179
+
180
+ if (carriesNull) {
181
+ assertNullOperandTranslatable(
182
+ context,
183
+ "a null element in an `in` list",
184
+ fieldRef.nullAttributeRepresentation
185
+ );
186
+ }
187
+
188
+ const filters: PrismaFilter[] = [];
189
+
190
+ if (nonNullValues.length > 0 || values.length === 0) {
191
+ // Without a null element nothing has made the membership definite yet: a NULL column is
192
+ // dropped by `{ in: [...] }` under BOTH polarities, while CEL compares a null VALUE against
193
+ // each element and gets a definite false, so its negation is TRUE. With a null element the
194
+ // `[null]` disjunct below already settles it.
195
+ const leaf = buildDirectOrInLeaf(fieldRef, nonNullValues);
196
+ filters.push(
197
+ carriesNull || !isExplicitNullReference(fieldRef)
198
+ ? wrapInRelations(fieldRef.relations, leaf)
199
+ : guardedFieldFilter(fieldRef, leaf)
200
+ );
201
+ }
202
+
203
+ if (carriesNull) {
204
+ filters.push(
205
+ wrapInRelations(fieldRef.relations, buildDirectOrInLeaf(fieldRef, [null]))
206
+ );
207
+ }
208
+
209
+ return filters.length === 1 ? filters[0]! : { OR: filters };
210
+ }
211
+
212
+ /**
213
+ * The two-clause bracket a fractional constant needs, or undefined for an operator that has
214
+ * none. Prisma coerces filter values to the column's type: on an Int column, `gte: 1.5` is
215
+ * silently bound as `gte: 1`, inverting rows like aNumber == 1. Each bracket pairs the exact
216
+ * constant with its integer neighbor so the combination is correct BOTH for Float columns (the
217
+ * extra clause is redundant) and Int columns (whichever way Prisma truncates or floors the
218
+ * fraction).
219
+ */
220
+ function fractionalBracket(
221
+ fieldName: string,
222
+ operator: string,
223
+ value: number
224
+ ): PrismaFilter | undefined {
225
+ const lower = Math.floor(value);
226
+ const upper = Math.ceil(value);
227
+
228
+ const equalsBracket = {
229
+ AND: [
230
+ { [fieldName]: { equals: value } },
231
+ { [fieldName]: { gt: lower } },
232
+ { [fieldName]: { lt: upper } },
233
+ ],
234
+ };
235
+
236
+ switch (operator) {
237
+ case "gt":
238
+ return {
239
+ OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
240
+ };
241
+ case "ge":
242
+ return {
243
+ AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
244
+ };
245
+ case "lt":
246
+ return {
247
+ OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
248
+ };
249
+ case "le":
250
+ return {
251
+ AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
252
+ };
253
+ case "eq":
254
+ return equalsBracket;
255
+ case "ne":
256
+ return { NOT: equalsBracket };
257
+ default:
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * An ordering against a boolean constant. CEL orders bools, `false < true`, but Prisma's Boolean
264
+ * filter has only `equals` and `not`, so the ordering becomes the set of bools that satisfy it,
265
+ * spelled with `equals` alone. Every spelling reads the column, so a NULL stays UNKNOWN under both
266
+ * polarities, as the missing-attribute error requires: an empty set is a contradiction on the
267
+ * column and the full set a tautology on it. Plain `equals` rather than the explicit-null guard,
268
+ * because ordering a null VALUE is a no-overload error in CEL, not a definite answer.
269
+ */
270
+ function buildBooleanOrderingFilter(
271
+ fieldRef: ResolvedFieldReference,
272
+ operator: string,
273
+ value: boolean
274
+ ): PrismaFilter {
275
+ const satisfied = [false, true].filter((candidate) => {
276
+ const [a, b] = [Number(candidate), Number(value)];
277
+
278
+ switch (operator) {
279
+ case "lt":
280
+ return a < b;
281
+ case "le":
282
+ return a <= b;
283
+ case "gt":
284
+ return a > b;
285
+ case "ge":
286
+ return a >= b;
287
+ default:
288
+ throw new UnsupportedQueryPlanError(`Unsupported operator: ${operator}`);
289
+ }
290
+ });
291
+
292
+ if (satisfied.length === 1) return buildFieldFilter(fieldRef, "equals", satisfied[0]!);
293
+ const fieldName = getLeafField(fieldRef.path);
294
+ const both = [{ [fieldName]: { equals: false } }, { [fieldName]: { equals: true } }];
295
+
296
+ return wrapInRelations(
297
+ fieldRef.relations,
298
+ satisfied.length === 0 ? { AND: both } : { OR: both }
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Builds a field-vs-constant comparison. Fractional constants are emitted as a two-clause
304
+ * bracket (see fractionalBracket).
305
+ */
306
+ export function buildComparisonFilter(
307
+ context: TranslationContext,
308
+ fieldRef: ResolvedFieldReference,
309
+ operator: string,
310
+ value: Value
311
+ ): PrismaFilter {
312
+ const prismaOperator = assertDefined(
313
+ lookup(CERBOS_TO_PRISMA_OPERATOR, operator),
314
+ `Unsupported operator: ${operator}`
315
+ );
316
+
317
+ assertScalarValue(fieldRef, value, operator);
318
+
319
+ if (isBoolean(value) && operator !== "eq" && operator !== "ne") {
320
+ return buildBooleanOrderingFilter(fieldRef, operator, value);
321
+ }
322
+
323
+ if (value === null) {
324
+ assertNullOperandTranslatable(
325
+ context,
326
+ `\`${operator}\` against a null operand`,
327
+ fieldRef.nullAttributeRepresentation
328
+ );
329
+ }
330
+
331
+ // An attribute the caller sends as an explicit null holds a null VALUE in CEL, so equality
332
+ // against a non-null operand is a definite FALSE and inequality a definite TRUE. Prisma leans
333
+ // on SQL's UNKNOWN for both, which drops the row under either polarity, so the presence of the
334
+ // column has to become part of the predicate. Confined to the equality family: an ordering
335
+ // comparison against a null receiver is a no-overload error in CEL, which denies exactly as the
336
+ // dropped row does.
337
+ if (
338
+ value !== null &&
339
+ isExplicitNullReference(fieldRef) &&
340
+ (operator === "eq" || operator === "ne")
341
+ ) {
342
+ const fieldName = getLeafField(fieldRef.path);
343
+
344
+ const equality = guardedFieldFilter(fieldRef, {
345
+ [fieldName]: { equals: value },
346
+ });
347
+
348
+ return operator === "eq" ? equality : { NOT: equality };
349
+ }
350
+
351
+ if (isNumber(value) && !Number.isInteger(value)) {
352
+ const bracket = fractionalBracket(
353
+ getLeafField(fieldRef.path),
354
+ operator,
355
+ value
356
+ );
357
+
358
+ if (bracket) {
359
+ return wrapInRelations(fieldRef.relations, bracket);
360
+ }
361
+ }
362
+
363
+ return buildFieldFilter(fieldRef, prismaOperator, value);
364
+ }
365
+
366
+ /** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
367
+ export function buildNullWitnessFilter(
368
+ nullableFields: Set<string>
369
+ ): PrismaFilter {
370
+ const checks = [...nullableFields].map((field) => ({ [field]: null }));
371
+
372
+ return checks.length === 1 ? checks[0]! : { OR: checks };
373
+ }