@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/mapping.ts ADDED
@@ -0,0 +1,474 @@
1
+ // Resolving plan references through the caller's mapper, and the per-translation state that
2
+ // resolution reads: the mapper in scope, the root model, the null convention and the lambda scopes.
3
+
4
+ import type { PlanExpressionOperand } from "@cerbos/core";
5
+
6
+ import type {
7
+ Mapper,
8
+ MapperConfig,
9
+ NullAttributeRepresentation,
10
+ PrismaFilter,
11
+ } from "./index";
12
+ import { isNamedOperand, isOperatorOperand, isValueOperand } from "./plan";
13
+ import { UnsupportedQueryPlanError } from "./errors";
14
+ import { isFunction } from "./narrowing";
15
+
16
+ // Each translation owns its lambda scopes, including when a function mapper re-enters the
17
+ // adapter. Scopes collect nullable element fields for three-valued-logic guards.
18
+ export type LambdaScope = {
19
+ variableName: string;
20
+ relationModel: string | undefined;
21
+ nullableFields: Set<string>;
22
+ unknownFilters: PrismaFilter[];
23
+ };
24
+
25
+ export type TranslationContext = {
26
+ readonly mapper: Mapper;
27
+ readonly rootModel: string | undefined;
28
+ readonly nullRepresentation: NullAttributeRepresentation;
29
+ readonly scopes: readonly LambdaScope[];
30
+ };
31
+
32
+ /** The innermost lambda scope, if the translation is inside one. */
33
+ export function currentScope(context: TranslationContext): LambdaScope | undefined {
34
+ return context.scopes[context.scopes.length - 1];
35
+ }
36
+
37
+ export type RelationConfig = {
38
+ name: string;
39
+ type: "one" | "many";
40
+ model?: string;
41
+ field?: string;
42
+ subqueryFilter?: PrismaFilter;
43
+ };
44
+
45
+ export type ResolvedFieldReference = {
46
+ path: string[];
47
+ nullable?: boolean;
48
+ timestampWrapped?: boolean;
49
+ relations?: RelationConfig[];
50
+ valueType?: MapperConfig["valueType"];
51
+ nullAttributeRepresentation?: NullAttributeRepresentation;
52
+ };
53
+
54
+ export type ResolvedValue = {
55
+ value: any;
56
+ /**
57
+ * The value is a timestamp literal between two milliseconds, bound as the next millisecond: a
58
+ * comparison against it must go through roundSubMillisecond.
59
+ */
60
+ subMillisecond?: boolean;
61
+ };
62
+
63
+ export type ResolvedOperand = ResolvedFieldReference | ResolvedValue;
64
+
65
+ export function isResolvedFieldReference(
66
+ operand: ResolvedOperand
67
+ ): operand is ResolvedFieldReference {
68
+ return "path" in operand;
69
+ }
70
+
71
+ export function isResolvedValue(
72
+ operand: ResolvedOperand
73
+ ): operand is ResolvedValue {
74
+ return "value" in operand;
75
+ }
76
+
77
+ /** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
78
+ export function isExplicitNullReference(
79
+ fieldRef: ResolvedFieldReference
80
+ ): boolean {
81
+ return fieldRef.nullAttributeRepresentation === "explicit";
82
+ }
83
+
84
+ export function getLeafField(path: string[]): string {
85
+ const fieldName = path[path.length - 1];
86
+
87
+ if (!fieldName) {
88
+ throw new Error("Field path cannot be empty");
89
+ }
90
+
91
+ return fieldName;
92
+ }
93
+
94
+ /** The mapping the caller declared for `key`, through either mapper form. */
95
+ export function lookupMapping(
96
+ mapper: Mapper,
97
+ key: string
98
+ ): MapperConfig | undefined {
99
+ return isFunction(mapper) ? mapper(key) : mapper[key];
100
+ }
101
+
102
+ /**
103
+ * The element field mappings of the collection at `collectionPath`. A chained collection
104
+ * (`R.attr.a.b`) has no mapping of its own, so it is found by descending from the longest mapped
105
+ * prefix through the nested `relation.fields`, the same walk resolveFieldReference performs.
106
+ */
107
+ export function lookupElementFields(
108
+ mapper: Mapper,
109
+ collectionPath: string
110
+ ): Record<string, MapperConfig> | undefined {
111
+ const direct = lookupMapping(mapper, collectionPath);
112
+
113
+ if (direct) {
114
+ return direct.relation?.fields;
115
+ }
116
+
117
+ const parts = collectionPath.split(".");
118
+
119
+ for (let i = parts.length - 1; i > 0; i--) {
120
+ let relation = lookupMapping(mapper, parts.slice(0, i).join("."))?.relation;
121
+
122
+ if (!relation) {
123
+ continue;
124
+ }
125
+
126
+ for (const part of parts.slice(i)) {
127
+ relation = relation?.fields?.[part]?.relation;
128
+ }
129
+
130
+ return relation?.fields;
131
+ }
132
+
133
+ return undefined;
134
+ }
135
+
136
+ /**
137
+ * Whether `reference` names a to-one relation itself, rather than a column reached through it.
138
+ * CEL reads such a relation as a map whose keys are the attribute names present on the related
139
+ * row, and no Prisma filter enumerates those.
140
+ */
141
+ export function namesToOneRelation(mapper: Mapper, reference: string): boolean {
142
+ const direct = lookupMapping(mapper, reference)?.relation;
143
+
144
+ if (direct) return direct.type === "one" && direct.field === undefined;
145
+ const parts = reference.split(".");
146
+
147
+ for (let i = parts.length - 1; i > 0; i--) {
148
+ let relation = lookupMapping(mapper, parts.slice(0, i).join("."))?.relation;
149
+
150
+ if (!relation) {
151
+ continue;
152
+ }
153
+
154
+ for (const part of parts.slice(i)) {
155
+ relation = relation?.fields?.[part]?.relation;
156
+ }
157
+
158
+ return relation?.type === "one" && relation.field === undefined;
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ function toRelationConfig(
165
+ relation: NonNullable<MapperConfig["relation"]>
166
+ ): RelationConfig {
167
+ return {
168
+ name: relation.name,
169
+ type: relation.type,
170
+ model: relation.model,
171
+ field: relation.field,
172
+ subqueryFilter: relation.subqueryFilter,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Resolves a field reference considering relations and nested fields.
178
+ */
179
+ export function resolveFieldReference(
180
+ reference: string,
181
+ context: TranslationContext
182
+ ): ResolvedFieldReference {
183
+ const parts = reference.split(".");
184
+ const config = lookupMapping(context.mapper, reference);
185
+
186
+ // Without a direct match, the longest dotted prefix that has a mapping applies.
187
+ let matchedPrefix = "";
188
+ let matchedConfig: MapperConfig | undefined;
189
+
190
+ if (!config) {
191
+ for (let i = parts.length - 1; i >= 0; i--) {
192
+ const prefix = parts.slice(0, i + 1).join(".");
193
+ const prefixConfig = lookupMapping(context.mapper, prefix);
194
+
195
+ if (prefixConfig) {
196
+ matchedPrefix = prefix;
197
+ matchedConfig = prefixConfig;
198
+ break;
199
+ }
200
+ }
201
+ }
202
+
203
+ const activeConfig = config ?? matchedConfig;
204
+
205
+ // A scalar column has no keys: reading one beneath it would compare the column itself.
206
+ if (!config && matchedConfig && !matchedConfig.relation) {
207
+ throw new UnsupportedQueryPlanError(
208
+ `"${reference}" reads "${reference.slice(matchedPrefix.length + 1)}" beneath "${matchedPrefix}", which is mapped to a scalar field; map the full reference`,
209
+ );
210
+ }
211
+
212
+ if (!activeConfig?.relation) {
213
+ return {
214
+ path: [activeConfig?.field || reference],
215
+ valueType: activeConfig?.valueType,
216
+ nullable: activeConfig?.nullable,
217
+ nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
218
+ };
219
+ }
220
+
221
+ const { fields } = activeConfig.relation;
222
+
223
+ const remainingParts = matchedPrefix
224
+ ? parts.slice(matchedPrefix.split(".").length)
225
+ : [];
226
+
227
+ const lastPart = parts[parts.length - 1];
228
+
229
+ // A function mapper may return a relation config for the full leaf reference.
230
+ // Resolve that leaf here too, before any filter is constructed.
231
+ let field =
232
+ activeConfig.relation.field ??
233
+ (config && fields && lastPart
234
+ ? (fields[lastPart]?.field ?? lastPart)
235
+ : undefined);
236
+
237
+ const relations = [toRelationConfig(activeConfig.relation)];
238
+
239
+ // Walk the remaining segments through nested relation mappings; the first segment that is
240
+ // not a relation names the leaf column.
241
+ let leafConfig: MapperConfig | undefined;
242
+
243
+ if (fields) {
244
+ let currentMapper: Record<string, MapperConfig> = fields;
245
+ let currentParts = remainingParts;
246
+
247
+ while (currentParts.length > 0) {
248
+ const currentPart = currentParts[0];
249
+
250
+ if (!currentPart) {
251
+ break;
252
+ }
253
+
254
+ const nextConfig: MapperConfig | undefined = currentMapper[currentPart];
255
+
256
+ if (!nextConfig?.relation) {
257
+ const leafPart = currentParts[currentParts.length - 1];
258
+
259
+ if (leafPart) {
260
+ field = nextConfig?.field || leafPart;
261
+ }
262
+
263
+ if (currentParts.length === 1) leafConfig = nextConfig;
264
+ break;
265
+ }
266
+
267
+ relations.push(toRelationConfig(nextConfig.relation));
268
+ field = nextConfig.relation.field;
269
+ currentMapper = nextConfig.relation.fields || {};
270
+ currentParts = currentParts.slice(1);
271
+ }
272
+ }
273
+
274
+ return {
275
+ path: field ? [field] : remainingParts,
276
+ relations,
277
+ // A column reached through a relation is typed by its own entry in `relation.fields`.
278
+ valueType: leafConfig?.valueType ?? activeConfig.valueType,
279
+ nullable: leafConfig ? leafConfig.nullable : activeConfig.nullable,
280
+ nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Records that the lambda body being built touches a nullable element column, so the
286
+ * enclosing collection operator can add its three-valued-logic guard.
287
+ *
288
+ * An element column is nullable unless its mapping says `nullable: false`. Omitting the guard is
289
+ * what over-grants (a negated `exists`, an `all` or a `hasIntersection` over `map` would admit
290
+ * rows holding a NULL element the PDP denies), so silence has to mean the safe reading. A
291
+ * nested relation is not a column and never gets a NULL guard.
292
+ */
293
+ function recordNullableElementField(
294
+ context: TranslationContext,
295
+ config: MapperConfig,
296
+ defaultField: string
297
+ ): void {
298
+ if (config.nullable === false || config.relation) {
299
+ return;
300
+ }
301
+
302
+ currentScope(context)?.nullableFields.add(config.field || defaultField);
303
+ }
304
+
305
+ /**
306
+ * Records that `operand`, the bare variable of a projected scalar list (`tagNames.all(t, ...)`),
307
+ * is read by a function that raises an error on a null element (`t.startsWith(...)`,
308
+ * `t.upperAscii()`). A null element of such a list is a VALUE, so resolving the variable records
309
+ * nothing; it is the function that errors, and the enclosing macro needs the same guard as for a
310
+ * missing element column.
311
+ */
312
+ export function recordErroringNullElement(
313
+ context: TranslationContext,
314
+ operand: PlanExpressionOperand,
315
+ resolved: ResolvedOperand
316
+ ): void {
317
+ if (!isNamedOperand(operand) || !isResolvedFieldReference(resolved)) return;
318
+
319
+ if (resolved.nullable === false || (resolved.relations?.length ?? 0) > 0) return;
320
+
321
+ const scope = [...context.scopes]
322
+ .reverse()
323
+ .find((candidate) => candidate.variableName === operand.name);
324
+
325
+ scope?.nullableFields.add(getLeafField(resolved.path));
326
+ }
327
+
328
+ /**
329
+ * The context for a lambda body iterating `collectionPath`: `scope` is pushed, and references
330
+ * to the lambda variable resolve against the collection's element mapping.
331
+ */
332
+ export function enterLambdaScope(
333
+ context: TranslationContext,
334
+ collectionPath: string,
335
+ scope: LambdaScope
336
+ ): TranslationContext {
337
+ const scopedContext: TranslationContext = {
338
+ ...context,
339
+ scopes: [...context.scopes, scope],
340
+ };
341
+
342
+ const { variableName } = scope;
343
+ const fullMapper = context.mapper;
344
+
345
+ const scopedMapper: Mapper = (key: string) => {
346
+ if (key === variableName) {
347
+ const { relations } = resolveFieldReference(collectionPath, scopedContext);
348
+ const projection = relations?.[relations.length - 1]?.field;
349
+
350
+ if (projection) {
351
+ return {
352
+ field: projection,
353
+ valueType:
354
+ lookupMapping(fullMapper, collectionPath)?.valueType ??
355
+ lookupElementFields(fullMapper, collectionPath)?.[projection]?.valueType,
356
+ // A projected list carries null values, unlike missing fields on object elements.
357
+ nullAttributeRepresentation: "explicit",
358
+ nullable: lookupElementFields(fullMapper, collectionPath)?.[projection]?.nullable,
359
+ };
360
+ }
361
+ }
362
+
363
+ // A key that starts with the variable name accesses the collection element.
364
+ if (key.startsWith(variableName + ".")) {
365
+ const strippedKey = key.replace(variableName + ".", "");
366
+ const baseConfig = lookupElementFields(fullMapper, collectionPath);
367
+
368
+ if (!baseConfig) {
369
+ const fieldConfig = { field: strippedKey };
370
+
371
+ if (!strippedKey.includes(".")) {
372
+ recordNullableElementField(scopedContext, fieldConfig, strippedKey);
373
+ }
374
+
375
+ return fieldConfig;
376
+ }
377
+
378
+ // For nested paths, traverse the fields configuration.
379
+ const parts = strippedKey.split(".");
380
+ let currentConfig = baseConfig;
381
+ let field = parts[0] || strippedKey;
382
+
383
+ for (let i = 0; i < parts.length - 1; i++) {
384
+ const part = parts[i];
385
+ const nextPart = parts[i + 1];
386
+
387
+ if (!part || !nextPart) {
388
+ break;
389
+ }
390
+
391
+ const nextFields = currentConfig[part]?.relation?.fields;
392
+
393
+ if (!nextFields) {
394
+ break;
395
+ }
396
+
397
+ currentConfig = nextFields;
398
+ field = nextPart;
399
+ }
400
+
401
+ // The field config if it exists, otherwise a default one.
402
+ const fieldConfig = currentConfig[field] || { field };
403
+ recordNullableElementField(scopedContext, fieldConfig, field);
404
+
405
+ return fieldConfig;
406
+ }
407
+
408
+ // Keys not referencing the collection element resolve through the enclosing mapper.
409
+ if (isFunction(fullMapper)) {
410
+ return fullMapper(key);
411
+ }
412
+
413
+ return fullMapper[key] || { field: key };
414
+ };
415
+
416
+ return { ...scopedContext, mapper: scopedMapper };
417
+ }
418
+
419
+ /**
420
+ * Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
421
+ * operand.
422
+ *
423
+ * Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
424
+ * missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
425
+ * the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
426
+ * null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
427
+ * than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
428
+ * `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
429
+ * under any nesting; narrowing it requires negation-parity tracking.
430
+ */
431
+ export function assertNullOperandTranslatable(
432
+ translation: TranslationContext,
433
+ context: string,
434
+ declared?: NullAttributeRepresentation
435
+ ): void {
436
+ if ((declared ?? translation.nullRepresentation) === "omitted") {
437
+ throw new UnsupportedQueryPlanError(
438
+ `Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
439
+ "sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
440
+ "(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
441
+ 'explicit nulls and use "explicit", or keep this shape out of the policy.'
442
+ );
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Applies the null-operand guard to struct literals (`set-field` members) anywhere in the plan,
448
+ * under the convention of the attribute each literal is compared with.
449
+ */
450
+ export function assertStructuralNulls(
451
+ operand: PlanExpressionOperand,
452
+ context: TranslationContext,
453
+ declared?: NullAttributeRepresentation
454
+ ): void {
455
+ if (!isOperatorOperand(operand)) return;
456
+ const sibling = operand.operands.find(isNamedOperand);
457
+ const mapping = sibling && lookupMapping(context.mapper, sibling.name);
458
+ const convention = mapping?.nullAttributeRepresentation ?? declared;
459
+
460
+ if (
461
+ operand.operator === "set-field" &&
462
+ operand.operands.some((part) => isValueOperand(part) && part.value === null)
463
+ ) {
464
+ assertNullOperandTranslatable(
465
+ context,
466
+ "a null member in a struct literal",
467
+ convention
468
+ );
469
+ }
470
+
471
+ operand.operands.forEach((part) =>
472
+ assertStructuralNulls(part, context, convention)
473
+ );
474
+ }
@@ -0,0 +1,33 @@
1
+ import type { Value } from "@cerbos/core";
2
+
3
+ export const isString = <T>(value: T): value is T & string => typeof value === "string";
4
+
5
+ export const isNumber = <T>(value: T): value is T & number => typeof value === "number";
6
+
7
+ export const isBoolean = <T>(value: T): value is T & boolean => typeof value === "boolean";
8
+
9
+ export const isFunction = <T>(value: T): value is Extract<T, Function> => typeof value === "function";
10
+
11
+ export const isObject = <T>(value: T): value is T & object => typeof value === "object" && value !== null;
12
+
13
+ export const typeofValue = (value: Value): "string" | "number" | "boolean" | "object" =>
14
+ isString(value) ? "string" : isNumber(value) ? "number" : isBoolean(value) ? "boolean" : "object";
15
+
16
+ const hasKey = <T extends object>(table: T, key: PropertyKey): key is keyof T => Object.hasOwn(table, key);
17
+
18
+ export const lookup = <T extends object>(table: T, key: PropertyKey): T[keyof T] | undefined =>
19
+ hasKey(table, key) ? table[key] : undefined;
20
+
21
+ const missingOperand = (): never => {
22
+ throw new Error("An expression is missing an operand");
23
+ };
24
+
25
+ export const single = <T>([a]: readonly T[]): [T] => [a ?? missingOperand()];
26
+
27
+ export const pair = <T>([a, b]: readonly T[]): [T, T] => [a ?? missingOperand(), b ?? missingOperand()];
28
+
29
+ export const triple = <T>([a, b, c]: readonly T[]): [T, T, T] => [
30
+ a ?? missingOperand(),
31
+ b ?? missingOperand(),
32
+ c ?? missingOperand(),
33
+ ];
package/src/plan.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Reading the planner's expression tree: operand shapes, and the comparison-operator tables every
2
+ // handler shares.
3
+
4
+ import type { PlanExpressionOperand, Value } from "@cerbos/core";
5
+ import { UnsupportedQueryPlanError } from "./errors";
6
+ import { lookup } from "./narrowing";
7
+
8
+ export interface NamedOperand {
9
+ name: string;
10
+ }
11
+
12
+ export interface ValueOperand {
13
+ value: Value;
14
+ }
15
+
16
+ export interface OperatorOperand {
17
+ operator: string;
18
+ operands: PlanExpressionOperand[];
19
+ }
20
+
21
+ export function isNamedOperand(
22
+ operand: PlanExpressionOperand
23
+ ): operand is NamedOperand {
24
+ return "name" in operand && typeof operand.name === "string";
25
+ }
26
+
27
+ export function isValueOperand(
28
+ operand: PlanExpressionOperand
29
+ ): operand is ValueOperand {
30
+ return "value" in operand && operand.value !== undefined;
31
+ }
32
+
33
+ export function isOperatorOperand(
34
+ operand: PlanExpressionOperand
35
+ ): operand is OperatorOperand {
36
+ return (
37
+ "operator" in operand &&
38
+ typeof operand.operator === "string" &&
39
+ "operands" in operand &&
40
+ Array.isArray(operand.operands)
41
+ );
42
+ }
43
+
44
+ export function assertDefined<T>(value: T | undefined, message: string): T {
45
+ if (value === undefined) {
46
+ throw new UnsupportedQueryPlanError(message);
47
+ }
48
+
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Refuses an `and`/`or` with no operands. The planner never emits one, and neither empty form has
54
+ * a safe rendering: `{ AND: [] }` is true in Prisma and matches every row, and the constant folder
55
+ * would reduce an empty `and` to `true` (an unconditional filter) before the translator saw it. A
56
+ * malformed plan is a bug report, never a filter.
57
+ */
58
+ export function assertLogicalOperands(
59
+ operator: string,
60
+ operands: PlanExpressionOperand[]
61
+ ): void {
62
+ if ((operator === "and" || operator === "or") && operands.length === 0) {
63
+ throw new UnsupportedQueryPlanError(
64
+ `${operator} requires at least one operand: an empty ${operator} is not something the ` +
65
+ "planner emits, and translating it would produce an unconditional filter " +
66
+ "({ AND: [] } matches every row)"
67
+ );
68
+ }
69
+ }
70
+
71
+ /** The Prisma scalar filter each Cerbos comparison operator lowers to. */
72
+ export const CERBOS_TO_PRISMA_OPERATOR = {
73
+ eq: "equals",
74
+ ne: "not",
75
+ lt: "lt",
76
+ le: "lte",
77
+ gt: "gt",
78
+ ge: "gte",
79
+ } satisfies Record<string, string>;
80
+
81
+ /** The Cerbos comparison operators: exactly the keys of CERBOS_TO_PRISMA_OPERATOR. */
82
+ export const COMPARISON_OPERATORS: ReadonlySet<string> = new Set(
83
+ Object.keys(CERBOS_TO_PRISMA_OPERATOR)
84
+ );
85
+
86
+ // Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
87
+ const MIRRORED_OPERATOR = {
88
+ lt: "gt",
89
+ gt: "lt",
90
+ le: "ge",
91
+ ge: "le",
92
+ } satisfies Record<string, string>;
93
+
94
+ /** The operator that holds when the two operands of `operator` swap sides. */
95
+ export function mirrorOperator(operator: string): string {
96
+ return lookup(MIRRORED_OPERATOR, operator) ?? operator;
97
+ }
98
+
99
+ type BinaryComparison = { operator: string; operands: PlanExpressionOperand[] };
100
+
101
+ /**
102
+ * Normalize a binary comparison to field-side-first. The planner preserves policy source
103
+ * order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
104
+ * `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
105
+ * handlers can assume the field/expression side is first. Without this, value-first
106
+ * comparisons are silently inverted (#256).
107
+ */
108
+ export function normalizeBinaryOperands(
109
+ operator: string,
110
+ operands: PlanExpressionOperand[]
111
+ ): BinaryComparison {
112
+ const [first, second] = operands;
113
+
114
+ if (
115
+ operands.length === 2 &&
116
+ first !== undefined &&
117
+ second !== undefined &&
118
+ isValueOperand(first) &&
119
+ !isValueOperand(second)
120
+ ) {
121
+ return { operator: mirrorOperator(operator), operands: [second, first] };
122
+ }
123
+
124
+ return { operator, operands };
125
+ }