@cerbos/orm-prisma 3.3.0 → 4.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.
- package/README.md +83 -6
- package/lib/index.d.ts +29 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1459 -114
- package/lib/index.js.map +1 -1
- package/package.json +19 -13
- package/src/adversarial.test.ts +586 -0
- package/src/generated/prisma/commonInputTypes.ts +26 -0
- package/src/generated/prisma/internal/class.ts +9 -9
- package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
- package/src/generated/prisma/models/Category.ts +1 -1
- package/src/generated/prisma/models/Label.ts +1 -1
- package/src/generated/prisma/models/NestedResource.ts +1 -1
- package/src/generated/prisma/models/NextLevelNestedResource.ts +1 -1
- package/src/generated/prisma/models/Resource.ts +62 -2
- package/src/generated/prisma/models/SubCategory.ts +1 -1
- package/src/generated/prisma/models/Tag.ts +1 -1
- package/src/generated/prisma/models/User.ts +1 -1
- package/src/generated/prisma-adversarial/browser.ts +44 -0
- package/src/generated/prisma-adversarial/client.ts +66 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
- package/src/generated/prisma-adversarial/enums.ts +15 -0
- package/src/generated/prisma-adversarial/internal/class.ts +244 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
- package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
- package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
- package/src/generated/prisma-adversarial/models.ts +16 -0
- package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
- package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-v6/index.d.ts +90 -3
- package/src/index.test.ts +3199 -636
- package/src/index.ts +2055 -148
- package/src/test-setup.adversarial.v6.ts +5 -0
- package/src/test-setup.adversarial.v7.ts +12 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PlanKind } from "@cerbos/core";
|
|
2
|
+
import type {
|
|
2
3
|
PlanResourcesResponse,
|
|
3
4
|
PlanExpressionOperand,
|
|
4
|
-
PlanKind,
|
|
5
5
|
Value,
|
|
6
6
|
} from "@cerbos/core";
|
|
7
7
|
|
|
@@ -16,14 +16,123 @@ const CERBOS_TO_PRISMA_OPERATOR: Record<string, string> = {
|
|
|
16
16
|
ge: "gte",
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
|
|
20
|
+
const MIRRORED_OPERATOR: Record<string, string> = {
|
|
21
|
+
lt: "gt",
|
|
22
|
+
gt: "lt",
|
|
23
|
+
le: "ge",
|
|
24
|
+
ge: "le",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const RFC3339_MILLISECOND_TIMESTAMP =
|
|
28
|
+
/^((?!0000)\d{4})-(\d{2})-(\d{2})[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.(\d{1,9}))?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
29
|
+
const MIN_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
|
|
30
|
+
"0001-01-01T00:00:00.000Z"
|
|
31
|
+
);
|
|
32
|
+
const MAX_RFC3339_TIMESTAMP_MILLISECONDS = Date.parse(
|
|
33
|
+
"9999-12-31T23:59:59.999Z"
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function normalizeRfc3339Milliseconds(value: string): string {
|
|
37
|
+
const match = RFC3339_MILLISECOND_TIMESTAMP.exec(value);
|
|
38
|
+
const yearText = match?.[1];
|
|
39
|
+
const monthText = match?.[2];
|
|
40
|
+
const dayText = match?.[3];
|
|
41
|
+
const fraction = match?.[4] ?? "";
|
|
42
|
+
if (!yearText || !monthText || !dayText) {
|
|
43
|
+
throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
|
|
44
|
+
}
|
|
45
|
+
if ([...fraction.slice(3)].some((digit) => digit !== "0")) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Timestamp value exceeds millisecond precision: ${value}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const year = Number(yearText);
|
|
52
|
+
const month = Number(monthText);
|
|
53
|
+
const day = Number(dayText);
|
|
54
|
+
const calendarDate = new Date(0);
|
|
55
|
+
calendarDate.setUTCHours(0, 0, 0, 0);
|
|
56
|
+
calendarDate.setUTCFullYear(year, month - 1, day);
|
|
57
|
+
if (
|
|
58
|
+
calendarDate.getUTCFullYear() !== year ||
|
|
59
|
+
calendarDate.getUTCMonth() !== month - 1 ||
|
|
60
|
+
calendarDate.getUTCDate() !== day
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const milliseconds = Date.parse(value);
|
|
66
|
+
if (Number.isNaN(milliseconds)) {
|
|
67
|
+
throw new Error(`Invalid RFC 3339 timestamp value: ${value}`);
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
milliseconds < MIN_RFC3339_TIMESTAMP_MILLISECONDS ||
|
|
71
|
+
milliseconds > MAX_RFC3339_TIMESTAMP_MILLISECONDS
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Timestamp value is outside CEL's supported instant range: ${value}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return new Date(milliseconds).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a binary comparison to field-side-first. The planner preserves policy source
|
|
82
|
+
* order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
|
|
83
|
+
* `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
|
|
84
|
+
* handlers can assume the field/expression side is first. Without this, value-first
|
|
85
|
+
* comparisons are silently inverted (#256).
|
|
86
|
+
*/
|
|
87
|
+
function normalizeBinaryOperands(
|
|
88
|
+
operator: string,
|
|
89
|
+
operands: PlanExpressionOperand[]
|
|
90
|
+
): { operator: string; operands: PlanExpressionOperand[] } {
|
|
91
|
+
const first = operands[0];
|
|
92
|
+
const second = operands[1];
|
|
93
|
+
if (
|
|
94
|
+
operands.length === 2 &&
|
|
95
|
+
first !== undefined &&
|
|
96
|
+
second !== undefined &&
|
|
97
|
+
isValueOperand(first) &&
|
|
98
|
+
!isValueOperand(second)
|
|
99
|
+
) {
|
|
100
|
+
return {
|
|
101
|
+
operator: MIRRORED_OPERATOR[operator] ?? operator,
|
|
102
|
+
operands: [second, first],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { operator, operands };
|
|
106
|
+
}
|
|
107
|
+
|
|
19
108
|
// Type Definitions
|
|
20
109
|
export type PrismaFilter = Record<string, any>;
|
|
21
110
|
|
|
22
111
|
export type MapperConfig = {
|
|
23
112
|
field?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Declares the database value kind when the Cerbos expression wraps a field in a typed
|
|
115
|
+
* constructor. Date-time metadata is required for timestamp(field) so a string column is
|
|
116
|
+
* never silently treated as a temporal column merely because it happens to contain ISO text.
|
|
117
|
+
*/
|
|
118
|
+
valueType?: "dateTime";
|
|
119
|
+
/**
|
|
120
|
+
* Marks the mapped column as nullable in the database. Cerbos treats a missing attribute as
|
|
121
|
+
* an evaluation error (deny), which matches SQL three-valued logic for simple predicates —
|
|
122
|
+
* but relation subqueries (some/every/none) collapse UNKNOWN to false at the EXISTS boundary,
|
|
123
|
+
* so collection macros over elements with NULL fields need an explicit guard to stay
|
|
124
|
+
* deny-aligned. Declaring nullability here enables those guards.
|
|
125
|
+
*/
|
|
126
|
+
nullable?: boolean;
|
|
24
127
|
relation?: {
|
|
25
128
|
name: string;
|
|
26
129
|
type: "one" | "many";
|
|
130
|
+
/**
|
|
131
|
+
* The Prisma model name of the related record (e.g. "Tag"). Required only for
|
|
132
|
+
* field-to-field comparisons between two columns of the related model, which compile to
|
|
133
|
+
* Prisma field references and need the model name as their container.
|
|
134
|
+
*/
|
|
135
|
+
model?: string;
|
|
27
136
|
field?: string;
|
|
28
137
|
fields?: Record<string, MapperConfig>;
|
|
29
138
|
};
|
|
@@ -36,6 +145,12 @@ export type Mapper =
|
|
|
36
145
|
export interface QueryPlanToPrismaArgs {
|
|
37
146
|
queryPlan: PlanResourcesResponse;
|
|
38
147
|
mapper?: Mapper;
|
|
148
|
+
/**
|
|
149
|
+
* The Prisma model name the generated filter targets (e.g. "Resource"). Required only for
|
|
150
|
+
* field-to-field comparisons between two root columns, which compile to Prisma field
|
|
151
|
+
* references and need the model name as their container.
|
|
152
|
+
*/
|
|
153
|
+
model?: string;
|
|
39
154
|
}
|
|
40
155
|
|
|
41
156
|
export type QueryPlanToPrismaResult =
|
|
@@ -91,6 +206,203 @@ function assertDefined<T>(value: T | undefined, message: string): T {
|
|
|
91
206
|
return value;
|
|
92
207
|
}
|
|
93
208
|
|
|
209
|
+
// Operators whose second operand is a lambda that binds an iteration variable.
|
|
210
|
+
const LAMBDA_BINDING_OPERATORS = new Set([
|
|
211
|
+
"exists",
|
|
212
|
+
"exists_one",
|
|
213
|
+
"all",
|
|
214
|
+
"filter",
|
|
215
|
+
"map",
|
|
216
|
+
"except",
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Substitute a lambda iteration variable with a concrete collection element
|
|
221
|
+
* inside a lambda body. A bare reference to the variable becomes the element
|
|
222
|
+
* itself; a `variable.path.to.field` reference drills into the element. A
|
|
223
|
+
* nested collection macro whose lambda rebinds the same variable name shadows
|
|
224
|
+
* the outer variable, so substitution only descends into its collection
|
|
225
|
+
* operand.
|
|
226
|
+
*/
|
|
227
|
+
function substituteLambdaVariable(
|
|
228
|
+
operand: PlanExpressionOperand,
|
|
229
|
+
variableName: string,
|
|
230
|
+
element: Value
|
|
231
|
+
): PlanExpressionOperand {
|
|
232
|
+
if (isNamedOperand(operand)) {
|
|
233
|
+
if (operand.name === variableName) {
|
|
234
|
+
return { value: element };
|
|
235
|
+
}
|
|
236
|
+
if (operand.name.startsWith(`${variableName}.`)) {
|
|
237
|
+
let current: Value = element;
|
|
238
|
+
for (const segment of operand.name
|
|
239
|
+
.slice(variableName.length + 1)
|
|
240
|
+
.split(".")) {
|
|
241
|
+
if (
|
|
242
|
+
current === null ||
|
|
243
|
+
typeof current !== "object" ||
|
|
244
|
+
Array.isArray(current) ||
|
|
245
|
+
!(segment in current) ||
|
|
246
|
+
current[segment] === undefined
|
|
247
|
+
) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Cannot resolve "${operand.name}": collection element has no field "${segment}"`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
current = current[segment];
|
|
253
|
+
}
|
|
254
|
+
return { value: current };
|
|
255
|
+
}
|
|
256
|
+
return operand;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (isOperatorOperand(operand)) {
|
|
260
|
+
if (
|
|
261
|
+
LAMBDA_BINDING_OPERATORS.has(operand.operator) &&
|
|
262
|
+
operand.operands.length === 2
|
|
263
|
+
) {
|
|
264
|
+
const [nestedCollection, nestedLambda] = operand.operands;
|
|
265
|
+
if (
|
|
266
|
+
nestedCollection !== undefined &&
|
|
267
|
+
nestedLambda !== undefined &&
|
|
268
|
+
isOperatorOperand(nestedLambda) &&
|
|
269
|
+
nestedLambda.operator === "lambda"
|
|
270
|
+
) {
|
|
271
|
+
const nestedVariable = nestedLambda.operands[1];
|
|
272
|
+
if (
|
|
273
|
+
nestedVariable !== undefined &&
|
|
274
|
+
isNamedOperand(nestedVariable) &&
|
|
275
|
+
nestedVariable.name === variableName
|
|
276
|
+
) {
|
|
277
|
+
// The nested lambda shadows our variable: substitute only in the
|
|
278
|
+
// collection operand.
|
|
279
|
+
return {
|
|
280
|
+
operator: operand.operator,
|
|
281
|
+
operands: [
|
|
282
|
+
substituteLambdaVariable(nestedCollection, variableName, element),
|
|
283
|
+
nestedLambda,
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
operator: operand.operator,
|
|
291
|
+
operands: operand.operands.map((o) =>
|
|
292
|
+
substituteLambdaVariable(o, variableName, element)
|
|
293
|
+
),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return operand;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Fold a collection macro whose collection operand is a literal value list.
|
|
302
|
+
*
|
|
303
|
+
* The planner emits this shape when a known-value collection (typically a
|
|
304
|
+
* folded principal attribute) has more than 10 elements — at 10 or fewer it
|
|
305
|
+
* unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
|
|
306
|
+
* cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
|
|
307
|
+
* the same fold here so the translated filter does not depend on which side of
|
|
308
|
+
* that threshold the collection lands: substitute each element into the lambda
|
|
309
|
+
* body and combine the per-element filters with OR (`exists`) or AND (`all`).
|
|
310
|
+
*
|
|
311
|
+
* An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
|
|
312
|
+
* match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
|
|
313
|
+
* over an empty collection.
|
|
314
|
+
*/
|
|
315
|
+
function handleKnownValueCollectionOperator(
|
|
316
|
+
operator: string,
|
|
317
|
+
collection: ValueOperand,
|
|
318
|
+
lambda: PlanExpressionOperand,
|
|
319
|
+
mapper: Mapper,
|
|
320
|
+
negated: boolean
|
|
321
|
+
): PrismaFilter {
|
|
322
|
+
if (operator !== "exists" && operator !== "all") {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`${operator} over a literal collection value is not supported. ` +
|
|
325
|
+
"Only exists() and all() can be folded into a flat filter."
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const elements = collection.value;
|
|
330
|
+
if (!Array.isArray(elements)) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`${operator} over a literal collection requires a list value`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Second operand of ${operator} must be a lambda expression`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (lambda.operands.length !== 2) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${operator} over a literal collection supports single-variable lambdas only`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const body = assertDefined(
|
|
348
|
+
lambda.operands[0],
|
|
349
|
+
"Lambda expression must provide a condition"
|
|
350
|
+
);
|
|
351
|
+
const variable = assertDefined(
|
|
352
|
+
lambda.operands[1],
|
|
353
|
+
"Lambda variable must have a name"
|
|
354
|
+
);
|
|
355
|
+
if (!isNamedOperand(variable)) {
|
|
356
|
+
throw new Error("Lambda variable must have a name");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const filters = elements.map((element) => {
|
|
360
|
+
const substituted = substituteLambdaVariable(
|
|
361
|
+
body,
|
|
362
|
+
variable.name,
|
|
363
|
+
element
|
|
364
|
+
);
|
|
365
|
+
return negated
|
|
366
|
+
? buildNegatedFilter(substituted, mapper)
|
|
367
|
+
: buildPrismaFilterFromCerbosExpression(substituted, mapper);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const combinesWithOr = operator === "exists" ? !negated : negated;
|
|
371
|
+
return combinesWithOr ? { OR: filters } : { AND: filters };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function tryHandleKnownValueCollectionOperator(
|
|
375
|
+
operator: string,
|
|
376
|
+
operands: PlanExpressionOperand[],
|
|
377
|
+
mapper: Mapper,
|
|
378
|
+
negated: boolean
|
|
379
|
+
): PrismaFilter | undefined {
|
|
380
|
+
if (operands.length !== 2) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const collection = operands[0];
|
|
385
|
+
const lambda = operands[1];
|
|
386
|
+
if (
|
|
387
|
+
collection === undefined ||
|
|
388
|
+
lambda === undefined ||
|
|
389
|
+
!isValueOperand(collection)
|
|
390
|
+
) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A literal value list arrives when the planner could not unroll a macro
|
|
395
|
+
// over a known collection (more than 10 elements). Fold it before attempting
|
|
396
|
+
// relation resolution because there is no relation mapping for a literal.
|
|
397
|
+
return handleKnownValueCollectionOperator(
|
|
398
|
+
operator,
|
|
399
|
+
collection,
|
|
400
|
+
lambda,
|
|
401
|
+
mapper,
|
|
402
|
+
negated
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
94
406
|
function getLeafField(path: string[]): string {
|
|
95
407
|
const fieldName = path[path.length - 1];
|
|
96
408
|
if (!fieldName) {
|
|
@@ -111,6 +423,7 @@ function getFilterEntry(filter: Record<string, unknown>): [string, unknown] {
|
|
|
111
423
|
type RelationConfig = {
|
|
112
424
|
name: string;
|
|
113
425
|
type: "one" | "many";
|
|
426
|
+
model?: string;
|
|
114
427
|
field?: string;
|
|
115
428
|
nestedMapper?: Record<string, MapperConfig>;
|
|
116
429
|
};
|
|
@@ -118,6 +431,7 @@ type RelationConfig = {
|
|
|
118
431
|
type ResolvedFieldReference = {
|
|
119
432
|
path: string[];
|
|
120
433
|
relations?: RelationConfig[];
|
|
434
|
+
valueType?: "dateTime";
|
|
121
435
|
};
|
|
122
436
|
|
|
123
437
|
type ResolvedValue = {
|
|
@@ -208,31 +522,457 @@ function buildFieldDirectOrInFilter(
|
|
|
208
522
|
return wrapRelations(fieldRef.relations, baseFilter);
|
|
209
523
|
}
|
|
210
524
|
|
|
525
|
+
function buildMembershipFilter(
|
|
526
|
+
fieldRef: ResolvedFieldReference,
|
|
527
|
+
values: Value[]
|
|
528
|
+
): PrismaFilter {
|
|
529
|
+
const nonNullValues = values.filter((value) => value !== null);
|
|
530
|
+
const filters: PrismaFilter[] = [];
|
|
531
|
+
|
|
532
|
+
if (nonNullValues.length > 0 || values.length === 0) {
|
|
533
|
+
filters.push(buildFieldDirectOrInFilter(fieldRef, nonNullValues));
|
|
534
|
+
}
|
|
535
|
+
if (nonNullValues.length !== values.length) {
|
|
536
|
+
filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (filters.length === 1) {
|
|
540
|
+
return assertDefined(filters[0], "Membership filter is missing");
|
|
541
|
+
}
|
|
542
|
+
return { OR: filters };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function buildAlwaysTrueFilter(): PrismaFilter {
|
|
546
|
+
return {};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
type TernaryBranchPredicate =
|
|
550
|
+
| { kind: "constant"; value: boolean }
|
|
551
|
+
| { kind: "filter"; filter: PrismaFilter };
|
|
552
|
+
|
|
553
|
+
// Translation-scoped context. Set at queryPlanToPrisma entry; the lambda stack tracks which
|
|
554
|
+
// collection variable (and related Prisma model) encloses the expression currently being built,
|
|
555
|
+
// so leaf handlers can distinguish element columns from outer columns and collect nullable
|
|
556
|
+
// element fields for three-valued-logic guards. Translation is synchronous, so module scope is
|
|
557
|
+
// safe.
|
|
558
|
+
type LambdaScope = {
|
|
559
|
+
variableName: string;
|
|
560
|
+
relationModel: string | undefined;
|
|
561
|
+
nullableFields: Set<string>;
|
|
562
|
+
unknownFilters: PrismaFilter[];
|
|
563
|
+
};
|
|
564
|
+
let lambdaScopes: LambdaScope[] = [];
|
|
565
|
+
let rootModelName: string | undefined;
|
|
566
|
+
|
|
211
567
|
/**
|
|
212
568
|
* Converts a Cerbos query plan to a Prisma filter.
|
|
213
569
|
*/
|
|
214
570
|
export function queryPlanToPrisma({
|
|
215
571
|
queryPlan,
|
|
216
572
|
mapper = {},
|
|
573
|
+
model,
|
|
217
574
|
}: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
|
|
218
575
|
switch (queryPlan.kind) {
|
|
219
576
|
case PlanKind.ALWAYS_ALLOWED:
|
|
220
577
|
return { kind: PlanKind.ALWAYS_ALLOWED };
|
|
221
578
|
case PlanKind.ALWAYS_DENIED:
|
|
222
579
|
return { kind: PlanKind.ALWAYS_DENIED };
|
|
223
|
-
case PlanKind.CONDITIONAL:
|
|
580
|
+
case PlanKind.CONDITIONAL: {
|
|
581
|
+
lambdaScopes = [];
|
|
582
|
+
rootModelName = model;
|
|
583
|
+
const condition = constantFoldExpression(
|
|
584
|
+
hoistOuterScopeReferences(queryPlan.condition, [])
|
|
585
|
+
);
|
|
586
|
+
if (isValueOperand(condition)) {
|
|
587
|
+
// Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
|
|
588
|
+
// they reach the adapter; this can only surface with hand-crafted plans.
|
|
589
|
+
if (condition.value === true) {
|
|
590
|
+
return { kind: PlanKind.CONDITIONAL, filters: {} };
|
|
591
|
+
}
|
|
592
|
+
// Prisma has no model-agnostic always-false `where` shape: empty logical arrays
|
|
593
|
+
// are ignored.
|
|
594
|
+
throw new Error(
|
|
595
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
596
|
+
);
|
|
597
|
+
}
|
|
224
598
|
return {
|
|
225
599
|
kind: PlanKind.CONDITIONAL,
|
|
226
|
-
filters: buildPrismaFilterFromCerbosExpression(
|
|
227
|
-
queryPlan.condition,
|
|
228
|
-
mapper
|
|
229
|
-
),
|
|
600
|
+
filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
|
|
230
601
|
};
|
|
602
|
+
}
|
|
231
603
|
default:
|
|
232
604
|
throw Error(`Invalid query plan.`);
|
|
233
605
|
}
|
|
234
606
|
}
|
|
235
607
|
|
|
608
|
+
const COLLECTION_OPERATORS = new Set([
|
|
609
|
+
"exists",
|
|
610
|
+
"exists_one",
|
|
611
|
+
"all",
|
|
612
|
+
"except",
|
|
613
|
+
"filter",
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
function mentionsVariable(
|
|
617
|
+
expr: PlanExpressionOperand,
|
|
618
|
+
variableName: string
|
|
619
|
+
): boolean {
|
|
620
|
+
if (isNamedOperand(expr)) {
|
|
621
|
+
return (
|
|
622
|
+
expr.name === variableName || expr.name.startsWith(variableName + ".")
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (isOperatorOperand(expr)) {
|
|
626
|
+
return expr.operands.some((operand) =>
|
|
627
|
+
mentionsVariable(operand, variableName)
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isOuterScopeName(name: string, enclosingVariables: string[]): boolean {
|
|
634
|
+
return !enclosingVariables.some(
|
|
635
|
+
(variable) => name === variable || name.startsWith(variable + ".")
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
|
|
640
|
+
function substituteNamedOperand(
|
|
641
|
+
expr: PlanExpressionOperand,
|
|
642
|
+
name: string,
|
|
643
|
+
value: boolean
|
|
644
|
+
): PlanExpressionOperand {
|
|
645
|
+
if (isNamedOperand(expr)) {
|
|
646
|
+
return expr.name === name ? { value } : expr;
|
|
647
|
+
}
|
|
648
|
+
if (isOperatorOperand(expr)) {
|
|
649
|
+
return {
|
|
650
|
+
operator: expr.operator,
|
|
651
|
+
operands: expr.operands.map((operand) =>
|
|
652
|
+
substituteNamedOperand(operand, name, value)
|
|
653
|
+
),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return expr;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
|
|
661
|
+
* boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
|
|
662
|
+
* the body itself.
|
|
663
|
+
*/
|
|
664
|
+
function findOuterBooleanReference(
|
|
665
|
+
expr: PlanExpressionOperand,
|
|
666
|
+
enclosingVariables: string[]
|
|
667
|
+
): string | undefined {
|
|
668
|
+
if (isNamedOperand(expr)) {
|
|
669
|
+
return isOuterScopeName(expr.name, enclosingVariables)
|
|
670
|
+
? expr.name
|
|
671
|
+
: undefined;
|
|
672
|
+
}
|
|
673
|
+
if (!isOperatorOperand(expr)) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
const booleanPositions: PlanExpressionOperand[] = [];
|
|
677
|
+
switch (expr.operator) {
|
|
678
|
+
case "and":
|
|
679
|
+
case "or":
|
|
680
|
+
case "not":
|
|
681
|
+
booleanPositions.push(...expr.operands);
|
|
682
|
+
break;
|
|
683
|
+
case "if":
|
|
684
|
+
booleanPositions.push(...expr.operands);
|
|
685
|
+
break;
|
|
686
|
+
default:
|
|
687
|
+
// Comparisons wrapping a ternary keep the outer reference in the ternary condition.
|
|
688
|
+
booleanPositions.push(
|
|
689
|
+
...expr.operands.filter(
|
|
690
|
+
(operand) =>
|
|
691
|
+
isOperatorOperand(operand) &&
|
|
692
|
+
(operand.operator === "if" ||
|
|
693
|
+
operand.operator === "and" ||
|
|
694
|
+
operand.operator === "or" ||
|
|
695
|
+
operand.operator === "not")
|
|
696
|
+
)
|
|
697
|
+
);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
for (const operand of booleanPositions) {
|
|
701
|
+
const found = findOuterBooleanReference(operand, enclosingVariables);
|
|
702
|
+
if (found !== undefined) {
|
|
703
|
+
return found;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
|
|
711
|
+
* outer lambda's element) so that every filter lands on the model it belongs to. Without this,
|
|
712
|
+
* an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
|
|
713
|
+
* tag model. Two sound transforms, applied bottom-up:
|
|
714
|
+
*
|
|
715
|
+
* - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
|
|
716
|
+
* `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
|
|
717
|
+
* OR), including the empty-collection case (both sides are FALSE).
|
|
718
|
+
* - Case split (any collection op): a bare outer boolean reference Q in a boolean position
|
|
719
|
+
* becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
|
|
720
|
+
* keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
|
|
721
|
+
* guarded-ternary encoding.
|
|
722
|
+
*/
|
|
723
|
+
function hoistOuterScopeReferences(
|
|
724
|
+
expr: PlanExpressionOperand,
|
|
725
|
+
enclosingVariables: string[]
|
|
726
|
+
): PlanExpressionOperand {
|
|
727
|
+
if (!isOperatorOperand(expr)) {
|
|
728
|
+
return expr;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
|
|
732
|
+
const [collection, lambda] = expr.operands;
|
|
733
|
+
if (
|
|
734
|
+
collection !== undefined &&
|
|
735
|
+
lambda !== undefined &&
|
|
736
|
+
isOperatorOperand(lambda) &&
|
|
737
|
+
lambda.operator === "lambda" &&
|
|
738
|
+
lambda.operands.length === 2 &&
|
|
739
|
+
lambda.operands[1] !== undefined &&
|
|
740
|
+
isNamedOperand(lambda.operands[1])
|
|
741
|
+
) {
|
|
742
|
+
const variable = lambda.operands[1];
|
|
743
|
+
const body = hoistOuterScopeReferences(
|
|
744
|
+
assertDefined(lambda.operands[0], "Lambda requires a condition"),
|
|
745
|
+
[...enclosingVariables, variable.name]
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const rebuild = (newBody: PlanExpressionOperand): OperatorOperand => ({
|
|
749
|
+
operator: expr.operator,
|
|
750
|
+
operands: [
|
|
751
|
+
collection,
|
|
752
|
+
{ operator: "lambda", operands: [newBody, variable] },
|
|
753
|
+
],
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Conjunct hoisting out of exists().
|
|
757
|
+
if (
|
|
758
|
+
expr.operator === "exists" &&
|
|
759
|
+
isOperatorOperand(body) &&
|
|
760
|
+
body.operator === "and"
|
|
761
|
+
) {
|
|
762
|
+
const elementConjuncts = body.operands.filter((operand) =>
|
|
763
|
+
mentionsVariable(operand, variable.name)
|
|
764
|
+
);
|
|
765
|
+
const outerConjuncts = body.operands.filter(
|
|
766
|
+
(operand) => !mentionsVariable(operand, variable.name)
|
|
767
|
+
);
|
|
768
|
+
if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
|
|
769
|
+
const innerBody =
|
|
770
|
+
elementConjuncts.length === 1
|
|
771
|
+
? elementConjuncts[0]!
|
|
772
|
+
: { operator: "and", operands: elementConjuncts };
|
|
773
|
+
return {
|
|
774
|
+
operator: "and",
|
|
775
|
+
operands: [rebuild(innerBody), ...outerConjuncts],
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Case split on a bare outer boolean reference.
|
|
781
|
+
const outerRef = findOuterBooleanReference(body, [
|
|
782
|
+
...enclosingVariables,
|
|
783
|
+
variable.name,
|
|
784
|
+
]);
|
|
785
|
+
if (outerRef !== undefined) {
|
|
786
|
+
const q: PlanExpressionOperand = { name: outerRef };
|
|
787
|
+
const notQ: PlanExpressionOperand = { operator: "not", operands: [q] };
|
|
788
|
+
return {
|
|
789
|
+
operator: "or",
|
|
790
|
+
operands: [
|
|
791
|
+
{
|
|
792
|
+
operator: "and",
|
|
793
|
+
operands: [
|
|
794
|
+
q,
|
|
795
|
+
rebuild(substituteNamedOperand(body, outerRef, true)),
|
|
796
|
+
],
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
operator: "and",
|
|
800
|
+
operands: [
|
|
801
|
+
notQ,
|
|
802
|
+
rebuild(substituteNamedOperand(body, outerRef, false)),
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
// Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
|
|
806
|
+
{ operator: "and", operands: [q, notQ] },
|
|
807
|
+
],
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return rebuild(body);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
operator: expr.operator,
|
|
817
|
+
operands: expr.operands.map((operand) =>
|
|
818
|
+
hoistOuterScopeReferences(operand, enclosingVariables)
|
|
819
|
+
),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const FOLDABLE_COMPARISON_OPERATORS = new Set([
|
|
824
|
+
"eq",
|
|
825
|
+
"ne",
|
|
826
|
+
"lt",
|
|
827
|
+
"le",
|
|
828
|
+
"gt",
|
|
829
|
+
"ge",
|
|
830
|
+
]);
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
|
|
834
|
+
* produce need folding (constant ternary conditions, comparisons between two constants,
|
|
835
|
+
* logical operators with constant operands, collection macros with constant bodies); real
|
|
836
|
+
* planner output arrives pre-folded.
|
|
837
|
+
*/
|
|
838
|
+
function constantFoldExpression(
|
|
839
|
+
expr: PlanExpressionOperand
|
|
840
|
+
): PlanExpressionOperand {
|
|
841
|
+
if (!isOperatorOperand(expr)) {
|
|
842
|
+
return expr;
|
|
843
|
+
}
|
|
844
|
+
const operands = expr.operands.map(constantFoldExpression);
|
|
845
|
+
const folded: OperatorOperand = { operator: expr.operator, operands };
|
|
846
|
+
|
|
847
|
+
switch (expr.operator) {
|
|
848
|
+
case "if": {
|
|
849
|
+
const [condition, thenBranch, elseBranch] = operands;
|
|
850
|
+
if (condition !== undefined && isValueOperand(condition)) {
|
|
851
|
+
if (typeof condition.value !== "boolean") {
|
|
852
|
+
throw new Error("if (ternary) condition must be a boolean expression");
|
|
853
|
+
}
|
|
854
|
+
return assertDefined(
|
|
855
|
+
condition.value ? thenBranch : elseBranch,
|
|
856
|
+
"if (ternary) requires branch operands"
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
return folded;
|
|
860
|
+
}
|
|
861
|
+
case "and": {
|
|
862
|
+
if (operands.some((o) => isValueOperand(o) && o.value === false)) {
|
|
863
|
+
return { value: false };
|
|
864
|
+
}
|
|
865
|
+
const remaining = operands.filter((o) => !isValueOperand(o));
|
|
866
|
+
if (remaining.length === 0) return { value: true };
|
|
867
|
+
if (remaining.length === 1) return remaining[0]!;
|
|
868
|
+
return { operator: "and", operands: remaining };
|
|
869
|
+
}
|
|
870
|
+
case "or": {
|
|
871
|
+
if (operands.some((o) => isValueOperand(o) && o.value === true)) {
|
|
872
|
+
return { value: true };
|
|
873
|
+
}
|
|
874
|
+
const remaining = operands.filter((o) => !isValueOperand(o));
|
|
875
|
+
if (remaining.length === 0) return { value: false };
|
|
876
|
+
if (remaining.length === 1) return remaining[0]!;
|
|
877
|
+
return { operator: "or", operands: remaining };
|
|
878
|
+
}
|
|
879
|
+
case "not": {
|
|
880
|
+
const operand = operands[0];
|
|
881
|
+
if (operand !== undefined && isValueOperand(operand)) {
|
|
882
|
+
return { value: operand.value !== true };
|
|
883
|
+
}
|
|
884
|
+
return folded;
|
|
885
|
+
}
|
|
886
|
+
case "add":
|
|
887
|
+
case "sub":
|
|
888
|
+
case "mult":
|
|
889
|
+
case "div": {
|
|
890
|
+
const [left, right] = operands;
|
|
891
|
+
if (
|
|
892
|
+
left !== undefined &&
|
|
893
|
+
right !== undefined &&
|
|
894
|
+
isValueOperand(left) &&
|
|
895
|
+
isValueOperand(right)
|
|
896
|
+
) {
|
|
897
|
+
return {
|
|
898
|
+
value: foldArithmetic(expr.operator, left.value, right.value),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return folded;
|
|
902
|
+
}
|
|
903
|
+
case "contains":
|
|
904
|
+
case "startsWith":
|
|
905
|
+
case "endsWith": {
|
|
906
|
+
const [receiver, needle] = operands;
|
|
907
|
+
if (
|
|
908
|
+
receiver !== undefined &&
|
|
909
|
+
needle !== undefined &&
|
|
910
|
+
isValueOperand(receiver) &&
|
|
911
|
+
isValueOperand(needle) &&
|
|
912
|
+
typeof receiver.value === "string" &&
|
|
913
|
+
typeof needle.value === "string"
|
|
914
|
+
) {
|
|
915
|
+
const r = receiver.value;
|
|
916
|
+
const n = needle.value;
|
|
917
|
+
return {
|
|
918
|
+
value:
|
|
919
|
+
expr.operator === "contains"
|
|
920
|
+
? r.includes(n)
|
|
921
|
+
: expr.operator === "startsWith"
|
|
922
|
+
? r.startsWith(n)
|
|
923
|
+
: r.endsWith(n),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
return folded;
|
|
927
|
+
}
|
|
928
|
+
case "exists":
|
|
929
|
+
case "all": {
|
|
930
|
+
const [collection, lambda] = operands;
|
|
931
|
+
if (
|
|
932
|
+
collection !== undefined &&
|
|
933
|
+
lambda !== undefined &&
|
|
934
|
+
isOperatorOperand(lambda) &&
|
|
935
|
+
lambda.operator === "lambda" &&
|
|
936
|
+
lambda.operands[0] !== undefined &&
|
|
937
|
+
isValueOperand(lambda.operands[0])
|
|
938
|
+
) {
|
|
939
|
+
const bodyValue = lambda.operands[0].value === true;
|
|
940
|
+
if (expr.operator === "exists" && !bodyValue) return { value: false };
|
|
941
|
+
if (expr.operator === "all" && bodyValue) return { value: true };
|
|
942
|
+
const sizeExpr: PlanExpressionOperand = {
|
|
943
|
+
operator: "size",
|
|
944
|
+
operands: [collection],
|
|
945
|
+
};
|
|
946
|
+
// exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
|
|
947
|
+
return expr.operator === "exists"
|
|
948
|
+
? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
|
|
949
|
+
: { operator: "eq", operands: [sizeExpr, { value: 0 }] };
|
|
950
|
+
}
|
|
951
|
+
return folded;
|
|
952
|
+
}
|
|
953
|
+
default: {
|
|
954
|
+
if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
|
|
955
|
+
const [left, right] = operands;
|
|
956
|
+
if (
|
|
957
|
+
left !== undefined &&
|
|
958
|
+
right !== undefined &&
|
|
959
|
+
isValueOperand(left) &&
|
|
960
|
+
isValueOperand(right)
|
|
961
|
+
) {
|
|
962
|
+
return {
|
|
963
|
+
value: evaluateConstantComparison(
|
|
964
|
+
expr.operator,
|
|
965
|
+
left.value,
|
|
966
|
+
right.value
|
|
967
|
+
),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return folded;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
236
976
|
/**
|
|
237
977
|
* Resolves a field reference considering relations and nested fields.
|
|
238
978
|
*/
|
|
@@ -277,6 +1017,7 @@ function resolveFieldReference(
|
|
|
277
1017
|
{
|
|
278
1018
|
name,
|
|
279
1019
|
type,
|
|
1020
|
+
model: activeConfig.relation.model,
|
|
280
1021
|
field: activeConfig.relation.field,
|
|
281
1022
|
nestedMapper: fields,
|
|
282
1023
|
},
|
|
@@ -302,6 +1043,7 @@ function resolveFieldReference(
|
|
|
302
1043
|
relations.push({
|
|
303
1044
|
name: nextConfig.relation.name,
|
|
304
1045
|
type: nextConfig.relation.type,
|
|
1046
|
+
model: nextConfig.relation.model,
|
|
305
1047
|
field: nextConfig.relation.field,
|
|
306
1048
|
nestedMapper: nextConfig.relation.fields,
|
|
307
1049
|
});
|
|
@@ -318,11 +1060,18 @@ function resolveFieldReference(
|
|
|
318
1060
|
}
|
|
319
1061
|
}
|
|
320
1062
|
|
|
321
|
-
return {
|
|
1063
|
+
return {
|
|
1064
|
+
path: field ? [field] : remainingParts,
|
|
1065
|
+
relations,
|
|
1066
|
+
valueType: activeConfig.valueType,
|
|
1067
|
+
};
|
|
322
1068
|
}
|
|
323
1069
|
|
|
324
1070
|
// Simple field mapping
|
|
325
|
-
return {
|
|
1071
|
+
return {
|
|
1072
|
+
path: [activeConfig?.field || reference],
|
|
1073
|
+
valueType: activeConfig?.valueType,
|
|
1074
|
+
};
|
|
326
1075
|
}
|
|
327
1076
|
|
|
328
1077
|
/**
|
|
@@ -383,6 +1132,9 @@ function resolveOperand(
|
|
|
383
1132
|
} else if (isValueOperand(operand)) {
|
|
384
1133
|
return { value: operand.value };
|
|
385
1134
|
} else if (isOperatorOperand(operand)) {
|
|
1135
|
+
if (operand.operator === "timestamp") {
|
|
1136
|
+
return resolveTimestampOperand(operand, mapper);
|
|
1137
|
+
}
|
|
386
1138
|
const folded = tryFoldValueExpression(operand, mapper);
|
|
387
1139
|
if (folded !== null) return { value: folded };
|
|
388
1140
|
const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
|
|
@@ -391,11 +1143,39 @@ function resolveOperand(
|
|
|
391
1143
|
throw new Error("Operand must have name, value, or be an expression");
|
|
392
1144
|
}
|
|
393
1145
|
|
|
1146
|
+
function resolveTimestampOperand(
|
|
1147
|
+
expression: OperatorOperand,
|
|
1148
|
+
mapper: Mapper
|
|
1149
|
+
): ResolvedOperand {
|
|
1150
|
+
if (expression.operands.length !== 1) {
|
|
1151
|
+
throw new Error("timestamp() requires exactly one operand");
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const operand = assertDefined(
|
|
1155
|
+
expression.operands[0],
|
|
1156
|
+
"timestamp() requires an operand"
|
|
1157
|
+
);
|
|
1158
|
+
if (isNamedOperand(operand)) {
|
|
1159
|
+
const fieldRef = resolveFieldReference(operand.name, mapper);
|
|
1160
|
+
if (fieldRef.valueType !== "dateTime") {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return fieldRef;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (!isValueOperand(operand) || typeof operand.value !== "string") {
|
|
1169
|
+
throw new Error("timestamp() requires a field reference or RFC 3339 string");
|
|
1170
|
+
}
|
|
1171
|
+
return { value: normalizeRfc3339Milliseconds(operand.value) };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
394
1174
|
function tryFoldValueExpression(
|
|
395
1175
|
expr: OperatorOperand,
|
|
396
1176
|
mapper: Mapper
|
|
397
1177
|
): Value | null {
|
|
398
|
-
if (expr.operator
|
|
1178
|
+
if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
|
|
399
1179
|
const leftOp = expr.operands[0];
|
|
400
1180
|
const rightOp = expr.operands[1];
|
|
401
1181
|
if (!leftOp || !rightOp) return null;
|
|
@@ -406,12 +1186,27 @@ function tryFoldValueExpression(
|
|
|
406
1186
|
if (!isResolvedValue(right)) return null;
|
|
407
1187
|
|
|
408
1188
|
try {
|
|
409
|
-
return
|
|
1189
|
+
return foldArithmetic(expr.operator, left.value, right.value);
|
|
410
1190
|
} catch {
|
|
411
1191
|
return null;
|
|
412
1192
|
}
|
|
413
1193
|
}
|
|
414
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Records that the lambda body being built touches a nullable element column, so the
|
|
1197
|
+
* enclosing collection operator can add its three-valued-logic guard.
|
|
1198
|
+
*/
|
|
1199
|
+
function recordNullableElementField(
|
|
1200
|
+
config: MapperConfig,
|
|
1201
|
+
defaultField: string
|
|
1202
|
+
): void {
|
|
1203
|
+
if (!config.nullable) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const scope = lambdaScopes[lambdaScopes.length - 1];
|
|
1207
|
+
scope?.nullableFields.add(config.field || defaultField);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
415
1210
|
/**
|
|
416
1211
|
* Creates a scoped mapper for collection operations
|
|
417
1212
|
*/
|
|
@@ -463,7 +1258,9 @@ function createScopedMapper(
|
|
|
463
1258
|
}
|
|
464
1259
|
|
|
465
1260
|
// Return the field config if it exists, otherwise create a default one
|
|
466
|
-
|
|
1261
|
+
const fieldConfig = currentConfig[field] || { field };
|
|
1262
|
+
recordNullableElementField(fieldConfig, field);
|
|
1263
|
+
return fieldConfig;
|
|
467
1264
|
}
|
|
468
1265
|
return { field: strippedKey };
|
|
469
1266
|
}
|
|
@@ -516,18 +1313,11 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
516
1313
|
if (!operand) {
|
|
517
1314
|
throw new Error("not operator requires an operand");
|
|
518
1315
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (!relations || relations.length === 0) {
|
|
525
|
-
return buildFieldEqualsFilter(fieldRef, false);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return {
|
|
529
|
-
NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
530
|
-
};
|
|
1316
|
+
return buildNegatedFilter(operand, mapper);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
case "if": {
|
|
1320
|
+
return handleBooleanTernaryOperator(operands, mapper);
|
|
531
1321
|
}
|
|
532
1322
|
|
|
533
1323
|
case "eq":
|
|
@@ -590,6 +1380,467 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
590
1380
|
}
|
|
591
1381
|
}
|
|
592
1382
|
|
|
1383
|
+
function containsCollectionOperator(expr: PlanExpressionOperand): boolean {
|
|
1384
|
+
if (!isOperatorOperand(expr)) {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
if (COLLECTION_OPERATORS.has(expr.operator)) {
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
return expr.operands.some(containsCollectionOperator);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
|
|
1395
|
+
* (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
|
|
1396
|
+
* subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
|
|
1397
|
+
* wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
|
|
1398
|
+
* attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
|
|
1399
|
+
* therefore pushed down to the collection operators, which encode CEL's exact error
|
|
1400
|
+
* semantics:
|
|
1401
|
+
*
|
|
1402
|
+
* - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
|
|
1403
|
+
* and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
|
|
1404
|
+
* UNKNOWN-element (the nullable-field guard).
|
|
1405
|
+
* - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
|
|
1406
|
+
* elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
|
|
1407
|
+
*/
|
|
1408
|
+
function buildNegatedFilter(
|
|
1409
|
+
operand: PlanExpressionOperand,
|
|
1410
|
+
mapper: Mapper
|
|
1411
|
+
): PrismaFilter {
|
|
1412
|
+
if (isNamedOperand(operand)) {
|
|
1413
|
+
const { relations, ...fieldRef } = resolveFieldReference(
|
|
1414
|
+
operand.name,
|
|
1415
|
+
mapper
|
|
1416
|
+
);
|
|
1417
|
+
if (!relations || relations.length === 0) {
|
|
1418
|
+
return buildFieldEqualsFilter(fieldRef, false);
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (isOperatorOperand(operand) && containsCollectionOperator(operand)) {
|
|
1426
|
+
switch (operand.operator) {
|
|
1427
|
+
case "and":
|
|
1428
|
+
return {
|
|
1429
|
+
OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
|
|
1430
|
+
};
|
|
1431
|
+
case "or":
|
|
1432
|
+
return {
|
|
1433
|
+
AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
|
|
1434
|
+
};
|
|
1435
|
+
case "not":
|
|
1436
|
+
return buildPrismaFilterFromCerbosExpression(
|
|
1437
|
+
assertDefined(
|
|
1438
|
+
operand.operands[0],
|
|
1439
|
+
"not operator requires an operand"
|
|
1440
|
+
),
|
|
1441
|
+
mapper
|
|
1442
|
+
);
|
|
1443
|
+
case "exists":
|
|
1444
|
+
case "all":
|
|
1445
|
+
case "except":
|
|
1446
|
+
case "exists_one":
|
|
1447
|
+
case "filter":
|
|
1448
|
+
return buildNegatedCollectionFilter(
|
|
1449
|
+
operand.operator,
|
|
1450
|
+
operand.operands,
|
|
1451
|
+
mapper
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return {
|
|
1457
|
+
NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function getTernaryOperands(operands: PlanExpressionOperand[]): {
|
|
1462
|
+
condition: PlanExpressionOperand;
|
|
1463
|
+
thenBranch: PlanExpressionOperand;
|
|
1464
|
+
elseBranch: PlanExpressionOperand;
|
|
1465
|
+
} {
|
|
1466
|
+
if (operands.length !== 3) {
|
|
1467
|
+
throw new Error(
|
|
1468
|
+
`if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
return {
|
|
1473
|
+
condition: assertDefined(
|
|
1474
|
+
operands[0],
|
|
1475
|
+
"if (ternary) requires a condition operand"
|
|
1476
|
+
),
|
|
1477
|
+
thenBranch: assertDefined(
|
|
1478
|
+
operands[1],
|
|
1479
|
+
"if (ternary) requires a then operand"
|
|
1480
|
+
),
|
|
1481
|
+
elseBranch: assertDefined(
|
|
1482
|
+
operands[2],
|
|
1483
|
+
"if (ternary) requires an else operand"
|
|
1484
|
+
),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function getConstantBooleanCondition(
|
|
1489
|
+
condition: PlanExpressionOperand
|
|
1490
|
+
): boolean | undefined {
|
|
1491
|
+
if (!isValueOperand(condition)) {
|
|
1492
|
+
return undefined;
|
|
1493
|
+
}
|
|
1494
|
+
if (typeof condition.value !== "boolean") {
|
|
1495
|
+
throw new Error("if (ternary) condition must be a boolean expression");
|
|
1496
|
+
}
|
|
1497
|
+
return condition.value;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function buildBooleanBranchFilter(
|
|
1501
|
+
branch: PlanExpressionOperand,
|
|
1502
|
+
mapper: Mapper
|
|
1503
|
+
): TernaryBranchPredicate {
|
|
1504
|
+
if (!isValueOperand(branch)) {
|
|
1505
|
+
return {
|
|
1506
|
+
kind: "filter",
|
|
1507
|
+
filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
if (typeof branch.value !== "boolean") {
|
|
1511
|
+
throw new Error(
|
|
1512
|
+
"if (ternary) branch in boolean position must be a boolean"
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
return { kind: "constant", value: branch.value };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function buildFalseConditionFilter(
|
|
1519
|
+
condition: PlanExpressionOperand,
|
|
1520
|
+
mapper: Mapper
|
|
1521
|
+
): PrismaFilter {
|
|
1522
|
+
if (isValueOperand(condition)) {
|
|
1523
|
+
throw new Error("Constant ternary conditions must be folded before use");
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (isNamedOperand(condition)) {
|
|
1527
|
+
const fieldRef = resolveFieldReference(condition.name, mapper);
|
|
1528
|
+
if (!fieldRef.relations || fieldRef.relations.length === 0) {
|
|
1529
|
+
return buildFieldEqualsFilter(fieldRef, false);
|
|
1530
|
+
}
|
|
1531
|
+
return {
|
|
1532
|
+
NOT: buildFieldEqualsFilter(fieldRef, true),
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (isOperatorOperand(condition) && condition.operator === "not") {
|
|
1537
|
+
return buildPrismaFilterFromCerbosExpression(
|
|
1538
|
+
assertDefined(condition.operands[0], "not operator requires an operand"),
|
|
1539
|
+
mapper
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return {
|
|
1544
|
+
NOT: buildPrismaFilterFromCerbosExpression(condition, mapper),
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function buildGuardedTernaryFilter({
|
|
1549
|
+
condition,
|
|
1550
|
+
thenFilter,
|
|
1551
|
+
elseFilter,
|
|
1552
|
+
mapper,
|
|
1553
|
+
}: {
|
|
1554
|
+
condition: PlanExpressionOperand;
|
|
1555
|
+
thenFilter: TernaryBranchPredicate;
|
|
1556
|
+
elseFilter: TernaryBranchPredicate;
|
|
1557
|
+
mapper: Mapper;
|
|
1558
|
+
}): PrismaFilter {
|
|
1559
|
+
const conditionTrue = buildPrismaFilterFromCerbosExpression(
|
|
1560
|
+
condition,
|
|
1561
|
+
mapper
|
|
1562
|
+
);
|
|
1563
|
+
const conditionFalse = buildFalseConditionFilter(condition, mapper);
|
|
1564
|
+
const guardedBranches: PrismaFilter[] = [];
|
|
1565
|
+
|
|
1566
|
+
if (thenFilter.kind === "filter") {
|
|
1567
|
+
guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
|
|
1568
|
+
} else if (thenFilter.value) {
|
|
1569
|
+
guardedBranches.push(conditionTrue);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
if (elseFilter.kind === "filter") {
|
|
1573
|
+
guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
|
|
1574
|
+
} else if (elseFilter.value) {
|
|
1575
|
+
guardedBranches.push(conditionFalse);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// For a known condition this contradiction is false. If the condition is
|
|
1579
|
+
// NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
|
|
1580
|
+
// ternary UNKNOWN under an outer NOT instead of authorizing the row.
|
|
1581
|
+
guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
|
|
1582
|
+
|
|
1583
|
+
return {
|
|
1584
|
+
OR: guardedBranches,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function finalizeTernaryBranchPredicate(
|
|
1589
|
+
predicate: TernaryBranchPredicate
|
|
1590
|
+
): PrismaFilter {
|
|
1591
|
+
if (predicate.kind === "filter") {
|
|
1592
|
+
return predicate.filter;
|
|
1593
|
+
}
|
|
1594
|
+
if (predicate.value) {
|
|
1595
|
+
return buildAlwaysTrueFilter();
|
|
1596
|
+
}
|
|
1597
|
+
// Prisma has no model-agnostic always-false `where` shape: empty logical
|
|
1598
|
+
// arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
|
|
1599
|
+
throw new Error(
|
|
1600
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function handleBooleanTernaryOperator(
|
|
1605
|
+
operands: PlanExpressionOperand[],
|
|
1606
|
+
mapper: Mapper
|
|
1607
|
+
): PrismaFilter {
|
|
1608
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
|
|
1609
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
1610
|
+
if (constantCondition !== undefined) {
|
|
1611
|
+
return finalizeTernaryBranchPredicate(
|
|
1612
|
+
buildBooleanBranchFilter(
|
|
1613
|
+
constantCondition ? thenBranch : elseBranch,
|
|
1614
|
+
mapper
|
|
1615
|
+
)
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return buildGuardedTernaryFilter({
|
|
1620
|
+
condition,
|
|
1621
|
+
thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
|
|
1622
|
+
elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
|
|
1623
|
+
mapper,
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function buildTernaryComparisonBranch({
|
|
1628
|
+
operator,
|
|
1629
|
+
operands,
|
|
1630
|
+
ternaryIndex,
|
|
1631
|
+
branch,
|
|
1632
|
+
mapper,
|
|
1633
|
+
}: {
|
|
1634
|
+
operator: string;
|
|
1635
|
+
operands: PlanExpressionOperand[];
|
|
1636
|
+
ternaryIndex: number;
|
|
1637
|
+
branch: PlanExpressionOperand;
|
|
1638
|
+
mapper: Mapper;
|
|
1639
|
+
}): TernaryBranchPredicate {
|
|
1640
|
+
const substitutedOperands = operands.map((operand, index) =>
|
|
1641
|
+
index === ternaryIndex ? branch : operand
|
|
1642
|
+
);
|
|
1643
|
+
assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
|
|
1644
|
+
assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
|
|
1645
|
+
|
|
1646
|
+
const normalized = normalizeBinaryOperands(operator, substitutedOperands);
|
|
1647
|
+
|
|
1648
|
+
const normalizedFirst = normalized.operands[0];
|
|
1649
|
+
const normalizedSecond = normalized.operands[1];
|
|
1650
|
+
if (
|
|
1651
|
+
normalized.operands.length === 2 &&
|
|
1652
|
+
normalizedFirst !== undefined &&
|
|
1653
|
+
normalizedSecond !== undefined &&
|
|
1654
|
+
isValueOperand(normalizedFirst) &&
|
|
1655
|
+
isValueOperand(normalizedSecond)
|
|
1656
|
+
) {
|
|
1657
|
+
return {
|
|
1658
|
+
kind: "constant",
|
|
1659
|
+
value: evaluateConstantComparison(
|
|
1660
|
+
normalized.operator,
|
|
1661
|
+
normalizedFirst.value,
|
|
1662
|
+
normalizedSecond.value
|
|
1663
|
+
),
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
return {
|
|
1668
|
+
kind: "filter",
|
|
1669
|
+
filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function tryHandleTernaryComparison(
|
|
1674
|
+
operator: string,
|
|
1675
|
+
operands: PlanExpressionOperand[],
|
|
1676
|
+
mapper: Mapper
|
|
1677
|
+
): PrismaFilter | null {
|
|
1678
|
+
if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const ternaryIndex = operands.findIndex(
|
|
1683
|
+
(operand) => isOperatorOperand(operand) && operand.operator === "if"
|
|
1684
|
+
);
|
|
1685
|
+
if (ternaryIndex === -1) {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
if (operands.length !== 2) {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const ternary = assertDefined(
|
|
1695
|
+
operands[ternaryIndex],
|
|
1696
|
+
"Ternary comparison operand is missing"
|
|
1697
|
+
);
|
|
1698
|
+
if (!isOperatorOperand(ternary)) {
|
|
1699
|
+
throw new Error("Ternary comparison operand must be an expression");
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(
|
|
1703
|
+
ternary.operands
|
|
1704
|
+
);
|
|
1705
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
1706
|
+
if (constantCondition !== undefined) {
|
|
1707
|
+
return finalizeTernaryBranchPredicate(
|
|
1708
|
+
buildTernaryComparisonBranch({
|
|
1709
|
+
operator,
|
|
1710
|
+
operands,
|
|
1711
|
+
ternaryIndex,
|
|
1712
|
+
branch: constantCondition ? thenBranch : elseBranch,
|
|
1713
|
+
mapper,
|
|
1714
|
+
})
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return buildGuardedTernaryFilter({
|
|
1719
|
+
condition,
|
|
1720
|
+
thenFilter: buildTernaryComparisonBranch({
|
|
1721
|
+
operator,
|
|
1722
|
+
operands,
|
|
1723
|
+
ternaryIndex,
|
|
1724
|
+
branch: thenBranch,
|
|
1725
|
+
mapper,
|
|
1726
|
+
}),
|
|
1727
|
+
elseFilter: buildTernaryComparisonBranch({
|
|
1728
|
+
operator,
|
|
1729
|
+
operands,
|
|
1730
|
+
ternaryIndex,
|
|
1731
|
+
branch: elseBranch,
|
|
1732
|
+
mapper,
|
|
1733
|
+
}),
|
|
1734
|
+
mapper,
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function evaluateConstantComparison(
|
|
1739
|
+
operator: string,
|
|
1740
|
+
left: Value,
|
|
1741
|
+
right: Value
|
|
1742
|
+
): boolean {
|
|
1743
|
+
switch (operator) {
|
|
1744
|
+
case "eq":
|
|
1745
|
+
return areValuesEqual(left, right);
|
|
1746
|
+
case "ne":
|
|
1747
|
+
return !areValuesEqual(left, right);
|
|
1748
|
+
case "lt":
|
|
1749
|
+
case "le":
|
|
1750
|
+
case "gt":
|
|
1751
|
+
case "ge": {
|
|
1752
|
+
if (
|
|
1753
|
+
typeof left === "number" &&
|
|
1754
|
+
typeof right === "number" &&
|
|
1755
|
+
(Number.isNaN(left) || Number.isNaN(right))
|
|
1756
|
+
) {
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
if (
|
|
1760
|
+
!(
|
|
1761
|
+
(typeof left === "number" && typeof right === "number") ||
|
|
1762
|
+
(typeof left === "string" && typeof right === "string")
|
|
1763
|
+
)
|
|
1764
|
+
) {
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`${operator} constant comparison requires two numbers or two strings`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
switch (operator) {
|
|
1770
|
+
case "lt":
|
|
1771
|
+
return compareConstantValues(left, right) < 0;
|
|
1772
|
+
case "le":
|
|
1773
|
+
return compareConstantValues(left, right) <= 0;
|
|
1774
|
+
case "gt":
|
|
1775
|
+
return compareConstantValues(left, right) > 0;
|
|
1776
|
+
case "ge":
|
|
1777
|
+
return compareConstantValues(left, right) >= 0;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
throw new Error(`Unsupported constant comparison operator: ${operator}`);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function compareConstantValues(left: number | string, right: number | string) {
|
|
1786
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
1787
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
1788
|
+
}
|
|
1789
|
+
if (typeof left !== "string" || typeof right !== "string") {
|
|
1790
|
+
throw new Error("Cannot order constant values of different types");
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
const leftCodePoints = Array.from(left, (character) =>
|
|
1794
|
+
character.codePointAt(0)
|
|
1795
|
+
);
|
|
1796
|
+
const rightCodePoints = Array.from(right, (character) =>
|
|
1797
|
+
character.codePointAt(0)
|
|
1798
|
+
);
|
|
1799
|
+
const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
|
|
1800
|
+
for (let index = 0; index < sharedLength; index++) {
|
|
1801
|
+
const leftCodePoint = leftCodePoints[index]!;
|
|
1802
|
+
const rightCodePoint = rightCodePoints[index]!;
|
|
1803
|
+
if (leftCodePoint !== rightCodePoint) {
|
|
1804
|
+
return leftCodePoint < rightCodePoint ? -1 : 1;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return leftCodePoints.length === rightCodePoints.length
|
|
1808
|
+
? 0
|
|
1809
|
+
: leftCodePoints.length < rightCodePoints.length
|
|
1810
|
+
? -1
|
|
1811
|
+
: 1;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function areValuesEqual(left: Value, right: Value): boolean {
|
|
1815
|
+
if (Array.isArray(left)) {
|
|
1816
|
+
return (
|
|
1817
|
+
Array.isArray(right) &&
|
|
1818
|
+
left.length === right.length &&
|
|
1819
|
+
left.every((value, index) => areValuesEqual(value, right[index]!))
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (Array.isArray(right)) {
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
if (typeof left === "object" && left !== null) {
|
|
1828
|
+
if (typeof right !== "object" || right === null) {
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
const leftKeys = Object.keys(left);
|
|
1832
|
+
const rightKeys = Object.keys(right);
|
|
1833
|
+
return (
|
|
1834
|
+
leftKeys.length === rightKeys.length &&
|
|
1835
|
+
leftKeys.every(
|
|
1836
|
+
(key) => key in right && areValuesEqual(left[key]!, right[key]!)
|
|
1837
|
+
)
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return left === right;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
593
1844
|
function handleSizeComparison(
|
|
594
1845
|
operator: string,
|
|
595
1846
|
sizeOperand: OperatorOperand,
|
|
@@ -652,6 +1903,16 @@ function handleRelationalOperator(
|
|
|
652
1903
|
operands: PlanExpressionOperand[],
|
|
653
1904
|
mapper: Mapper
|
|
654
1905
|
): PrismaFilter {
|
|
1906
|
+
const ternaryFilter = tryHandleTernaryComparison(
|
|
1907
|
+
operator,
|
|
1908
|
+
operands,
|
|
1909
|
+
mapper
|
|
1910
|
+
);
|
|
1911
|
+
if (ternaryFilter) {
|
|
1912
|
+
return ternaryFilter;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
({ operator, operands } = normalizeBinaryOperands(operator, operands));
|
|
655
1916
|
const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
|
|
656
1917
|
|
|
657
1918
|
if (!prismaOperator) {
|
|
@@ -670,27 +1931,215 @@ function handleRelationalOperator(
|
|
|
670
1931
|
return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
|
|
671
1932
|
}
|
|
672
1933
|
|
|
673
|
-
const
|
|
674
|
-
(o): o is OperatorOperand =>
|
|
675
|
-
isOperatorOperand(o) && o.operator
|
|
1934
|
+
const arithOperand = [leftOperand, rightOperand].find(
|
|
1935
|
+
(o): o is OperatorOperand =>
|
|
1936
|
+
isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator)
|
|
1937
|
+
);
|
|
1938
|
+
if (arithOperand) {
|
|
1939
|
+
const otherOperand =
|
|
1940
|
+
arithOperand === leftOperand ? rightOperand : leftOperand;
|
|
1941
|
+
return handleArithmeticComparison(
|
|
1942
|
+
operator,
|
|
1943
|
+
arithOperand,
|
|
1944
|
+
otherOperand,
|
|
1945
|
+
arithOperand === leftOperand,
|
|
1946
|
+
mapper
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const mapOperand = [leftOperand, rightOperand].find(
|
|
1951
|
+
(o): o is OperatorOperand =>
|
|
1952
|
+
isOperatorOperand(o) && o.operator === "map"
|
|
1953
|
+
);
|
|
1954
|
+
if (mapOperand) {
|
|
1955
|
+
throw new Error(
|
|
1956
|
+
`Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
|
|
1957
|
+
`Wrap the map() expression in hasIntersection(map(...), [...]) instead.`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const left = resolveOperand(leftOperand, mapper);
|
|
1962
|
+
const right = resolveOperand(rightOperand, mapper);
|
|
1963
|
+
|
|
1964
|
+
if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
|
|
1965
|
+
return buildFieldToFieldFilter(
|
|
1966
|
+
operator,
|
|
1967
|
+
leftOperand,
|
|
1968
|
+
left,
|
|
1969
|
+
rightOperand,
|
|
1970
|
+
right
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (isResolvedValue(left) && isResolvedFieldReference(right)) {
|
|
1975
|
+
return buildComparisonFilter(
|
|
1976
|
+
right,
|
|
1977
|
+
MIRRORED_OPERATOR[operator] ?? operator,
|
|
1978
|
+
left.value
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
if (isResolvedValue(left) && isResolvedValue(right)) {
|
|
1983
|
+
const matches = evaluateConstantComparison(
|
|
1984
|
+
operator,
|
|
1985
|
+
left.value,
|
|
1986
|
+
right.value
|
|
1987
|
+
);
|
|
1988
|
+
if (matches) {
|
|
1989
|
+
return buildAlwaysTrueFilter();
|
|
1990
|
+
}
|
|
1991
|
+
throw new Error(
|
|
1992
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const rightValue = requireResolvedValue(
|
|
1997
|
+
right,
|
|
1998
|
+
"Right operand must be a value"
|
|
1999
|
+
);
|
|
2000
|
+
|
|
2001
|
+
if (isResolvedFieldReference(left)) {
|
|
2002
|
+
return buildComparisonFilter(left, operator, rightValue.value);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
return { [prismaOperator]: rightValue.value };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Builds a field-vs-constant comparison. Fractional constants against ordering (and
|
|
2010
|
+
* equality) operators are emitted as a two-clause bracket because Prisma coerces filter
|
|
2011
|
+
* values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
|
|
2012
|
+
* inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
|
|
2013
|
+
* neighbor so the combination is correct BOTH for Float columns (the extra clause is
|
|
2014
|
+
* redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
|
|
2015
|
+
*/
|
|
2016
|
+
function buildComparisonFilter(
|
|
2017
|
+
fieldRef: ResolvedFieldReference,
|
|
2018
|
+
operator: string,
|
|
2019
|
+
value: Value
|
|
2020
|
+
): PrismaFilter {
|
|
2021
|
+
const prismaOperator = assertDefined(
|
|
2022
|
+
CERBOS_TO_PRISMA_OPERATOR[operator],
|
|
2023
|
+
`Unsupported operator: ${operator}`
|
|
2024
|
+
);
|
|
2025
|
+
|
|
2026
|
+
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
2027
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
2028
|
+
const lower = Math.floor(value);
|
|
2029
|
+
const upper = Math.ceil(value);
|
|
2030
|
+
const equalsBracket = {
|
|
2031
|
+
AND: [
|
|
2032
|
+
{ [fieldName]: { equals: value } },
|
|
2033
|
+
{ [fieldName]: { gt: lower } },
|
|
2034
|
+
{ [fieldName]: { lt: upper } },
|
|
2035
|
+
],
|
|
2036
|
+
};
|
|
2037
|
+
let bracket: PrismaFilter | undefined;
|
|
2038
|
+
switch (operator) {
|
|
2039
|
+
case "gt":
|
|
2040
|
+
bracket = {
|
|
2041
|
+
OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
|
|
2042
|
+
};
|
|
2043
|
+
break;
|
|
2044
|
+
case "ge":
|
|
2045
|
+
bracket = {
|
|
2046
|
+
AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
|
|
2047
|
+
};
|
|
2048
|
+
break;
|
|
2049
|
+
case "lt":
|
|
2050
|
+
bracket = {
|
|
2051
|
+
OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
|
|
2052
|
+
};
|
|
2053
|
+
break;
|
|
2054
|
+
case "le":
|
|
2055
|
+
bracket = {
|
|
2056
|
+
AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
|
|
2057
|
+
};
|
|
2058
|
+
break;
|
|
2059
|
+
case "eq":
|
|
2060
|
+
bracket = equalsBracket;
|
|
2061
|
+
break;
|
|
2062
|
+
case "ne":
|
|
2063
|
+
bracket = { NOT: equalsBracket };
|
|
2064
|
+
break;
|
|
2065
|
+
}
|
|
2066
|
+
if (bracket) {
|
|
2067
|
+
return wrapRelations(fieldRef.relations, bracket);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
return buildFieldFilter(fieldRef, prismaOperator, value);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
|
|
2076
|
+
* references between fields of the SAME model, so the container is the root model for
|
|
2077
|
+
* root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
|
|
2078
|
+
* (an element column against an outer column) are cross-model and must fail loudly.
|
|
2079
|
+
*/
|
|
2080
|
+
function buildFieldToFieldFilter(
|
|
2081
|
+
operator: string,
|
|
2082
|
+
leftOperand: PlanExpressionOperand,
|
|
2083
|
+
left: ResolvedFieldReference,
|
|
2084
|
+
rightOperand: PlanExpressionOperand,
|
|
2085
|
+
right: ResolvedFieldReference
|
|
2086
|
+
): PrismaFilter {
|
|
2087
|
+
const prismaOperator = assertDefined(
|
|
2088
|
+
CERBOS_TO_PRISMA_OPERATOR[operator],
|
|
2089
|
+
`Unsupported operator: ${operator}`
|
|
676
2090
|
);
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return handleAddComparison(operator, addOperand, otherOperand, mapper);
|
|
2091
|
+
|
|
2092
|
+
if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
|
|
2093
|
+
throw new Error("Field-to-field comparison requires two named operands");
|
|
681
2094
|
}
|
|
682
2095
|
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
resolveOperand(rightOperand, mapper),
|
|
686
|
-
"Right operand must be a value"
|
|
687
|
-
);
|
|
2096
|
+
const scope = lambdaScopes[lambdaScopes.length - 1];
|
|
2097
|
+
let container: string | undefined;
|
|
688
2098
|
|
|
689
|
-
if (
|
|
690
|
-
|
|
2099
|
+
if (scope) {
|
|
2100
|
+
const prefix = scope.variableName + ".";
|
|
2101
|
+
const leftInScope = leftOperand.name.startsWith(prefix);
|
|
2102
|
+
const rightInScope = rightOperand.name.startsWith(prefix);
|
|
2103
|
+
if (leftInScope !== rightInScope) {
|
|
2104
|
+
throw new Error(
|
|
2105
|
+
`Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
|
|
2106
|
+
"Prisma field references only work between fields of the same model"
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
if (leftInScope) {
|
|
2110
|
+
container = scope.relationModel;
|
|
2111
|
+
if (!container) {
|
|
2112
|
+
throw new Error(
|
|
2113
|
+
"Field-to-field comparison inside a collection requires `relation.model` in the mapper"
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (container === undefined) {
|
|
2120
|
+
if (
|
|
2121
|
+
(left.relations && left.relations.length > 0) ||
|
|
2122
|
+
(right.relations && right.relations.length > 0)
|
|
2123
|
+
) {
|
|
2124
|
+
throw new Error(
|
|
2125
|
+
"Cannot compare columns across relations: Prisma field references only work between fields of the same model"
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
container = rootModelName;
|
|
2129
|
+
if (!container) {
|
|
2130
|
+
throw new Error(
|
|
2131
|
+
"Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
|
|
2132
|
+
);
|
|
2133
|
+
}
|
|
691
2134
|
}
|
|
692
2135
|
|
|
693
|
-
|
|
2136
|
+
const leftField = getLeafField(left.path);
|
|
2137
|
+
const rightField = getLeafField(right.path);
|
|
2138
|
+
return {
|
|
2139
|
+
[leftField]: {
|
|
2140
|
+
[prismaOperator]: { _ref: rightField, _container: container },
|
|
2141
|
+
},
|
|
2142
|
+
};
|
|
694
2143
|
}
|
|
695
2144
|
|
|
696
2145
|
/**
|
|
@@ -700,46 +2149,137 @@ function handleInOperator(
|
|
|
700
2149
|
operands: PlanExpressionOperand[],
|
|
701
2150
|
mapper: Mapper
|
|
702
2151
|
): PrismaFilter {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
2152
|
+
if (operands.length !== 2) {
|
|
2153
|
+
throw new Error("in requires exactly two operands");
|
|
2154
|
+
}
|
|
2155
|
+
const member = assertDefined(operands[0], "in requires a member operand");
|
|
2156
|
+
const collection = assertDefined(
|
|
2157
|
+
operands[1],
|
|
2158
|
+
"in requires a collection operand"
|
|
708
2159
|
);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
2160
|
+
|
|
2161
|
+
if (isNamedOperand(member) && isValueOperand(collection)) {
|
|
2162
|
+
const fieldRef = resolveFieldReference(member.name, mapper);
|
|
2163
|
+
const values = Array.isArray(collection.value)
|
|
2164
|
+
? collection.value
|
|
2165
|
+
: [collection.value];
|
|
2166
|
+
return buildMembershipFilter(fieldRef, values);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (isValueOperand(member) && isNamedOperand(collection)) {
|
|
2170
|
+
return buildMembershipFilter(
|
|
2171
|
+
resolveFieldReference(collection.name, mapper),
|
|
2172
|
+
[member.value]
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if (isNamedOperand(member) && isNamedOperand(collection)) {
|
|
2177
|
+
throw new Error(
|
|
2178
|
+
"Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column"
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
throw new Error(
|
|
2183
|
+
"in requires a field member and literal list, or a literal member and field collection"
|
|
712
2184
|
);
|
|
713
|
-
const values = Array.isArray(value) ? value : [value];
|
|
714
|
-
return buildFieldDirectOrInFilter(fieldRef, values);
|
|
715
2185
|
}
|
|
716
2186
|
|
|
2187
|
+
// Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
|
|
2188
|
+
const MAX_ENUMERATED_NEEDLES = 1000;
|
|
2189
|
+
|
|
717
2190
|
/**
|
|
718
|
-
* Helper function to handle string operators (contains, startsWith, endsWith)
|
|
2191
|
+
* Helper function to handle string operators (contains, startsWith, endsWith).
|
|
2192
|
+
*
|
|
2193
|
+
* These operators are receiver-sensitive and the planner preserves policy source order:
|
|
2194
|
+
* operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
|
|
2195
|
+
* inverts the match direction, so unlike symmetric comparisons they are never normalized.
|
|
719
2196
|
*/
|
|
720
2197
|
function handleStringOperator(
|
|
721
2198
|
operator: string,
|
|
722
2199
|
operands: PlanExpressionOperand[],
|
|
723
2200
|
mapper: Mapper
|
|
724
2201
|
): PrismaFilter {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
2202
|
+
if (operands.length !== 2) {
|
|
2203
|
+
throw new Error(`${operator} requires exactly two operands`);
|
|
2204
|
+
}
|
|
2205
|
+
const receiver = resolveOperand(
|
|
2206
|
+
assertDefined(operands[0], `${operator} requires a receiver operand`),
|
|
2207
|
+
mapper
|
|
730
2208
|
);
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
2209
|
+
const needle = resolveOperand(
|
|
2210
|
+
assertDefined(operands[1], `${operator} requires a needle operand`),
|
|
2211
|
+
mapper
|
|
734
2212
|
);
|
|
735
|
-
|
|
736
|
-
|
|
2213
|
+
|
|
2214
|
+
// Column receiver, constant needle: Prisma's LIKE-based filter.
|
|
2215
|
+
if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
|
|
2216
|
+
const { value } = needle;
|
|
2217
|
+
if (typeof value !== "string") {
|
|
2218
|
+
throw new Error(`${operator} operator requires string value`);
|
|
2219
|
+
}
|
|
2220
|
+
// Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
|
|
2221
|
+
// so a needle containing % or _ would match as a pattern instead of literally (e.g.
|
|
2222
|
+
// startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
|
|
2223
|
+
if (/[%_]/.test(value)) {
|
|
2224
|
+
throw new Error(
|
|
2225
|
+
`Cannot translate ${operator} with a needle containing LIKE metacharacters (% or _): ` +
|
|
2226
|
+
"Prisma does not escape wildcards in string filters"
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
const fieldName = getLeafField(receiver.path);
|
|
2230
|
+
return wrapRelations(receiver.relations, {
|
|
2231
|
+
[fieldName]: { [operator]: value },
|
|
2232
|
+
});
|
|
737
2233
|
}
|
|
738
2234
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
2235
|
+
// Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
|
|
2236
|
+
// candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
|
|
2237
|
+
// substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
|
|
2238
|
+
// NULL needle column stays excluded (CEL missing-attribute deny).
|
|
2239
|
+
if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
|
|
2240
|
+
if (typeof receiver.value !== "string") {
|
|
2241
|
+
throw new Error(`${operator} operator requires a string receiver`);
|
|
2242
|
+
}
|
|
2243
|
+
const haystack = receiver.value;
|
|
2244
|
+
const candidates = new Set<string>([""]);
|
|
2245
|
+
if (operator === "startsWith") {
|
|
2246
|
+
for (let i = 1; i <= haystack.length; i++) {
|
|
2247
|
+
candidates.add(haystack.slice(0, i));
|
|
2248
|
+
}
|
|
2249
|
+
} else if (operator === "endsWith") {
|
|
2250
|
+
for (let i = 0; i < haystack.length; i++) {
|
|
2251
|
+
candidates.add(haystack.slice(i));
|
|
2252
|
+
}
|
|
2253
|
+
} else {
|
|
2254
|
+
for (let start = 0; start < haystack.length; start++) {
|
|
2255
|
+
for (let end = start + 1; end <= haystack.length; end++) {
|
|
2256
|
+
candidates.add(haystack.slice(start, end));
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
if (candidates.size > MAX_ENUMERATED_NEEDLES) {
|
|
2261
|
+
throw new Error(
|
|
2262
|
+
`Cannot translate ${operator} with a constant receiver of this length: ` +
|
|
2263
|
+
`enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
const fieldName = getLeafField(needle.path);
|
|
2267
|
+
return wrapRelations(needle.relations, {
|
|
2268
|
+
[fieldName]: { in: [...candidates] },
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
|
|
2273
|
+
// A column-valued needle would need its LIKE metacharacters escaped per row, which
|
|
2274
|
+
// Prisma's filters cannot do (and Prisma leaks wildcards from field references).
|
|
2275
|
+
throw new Error(
|
|
2276
|
+
`Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
throw new Error(
|
|
2281
|
+
`${operator} between two constants must be folded by the Cerbos planner`
|
|
2282
|
+
);
|
|
743
2283
|
}
|
|
744
2284
|
|
|
745
2285
|
/**
|
|
@@ -777,6 +2317,11 @@ function handleHasIntersectionOperator(
|
|
|
777
2317
|
throw new Error("hasIntersection requires exactly two operands");
|
|
778
2318
|
}
|
|
779
2319
|
|
|
2320
|
+
// Intersection is symmetric, and the planner preserves policy source order — e.g.
|
|
2321
|
+
// `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
|
|
2322
|
+
// field/map-first (see #256).
|
|
2323
|
+
({ operands } = normalizeBinaryOperands("hasIntersection", operands));
|
|
2324
|
+
|
|
780
2325
|
const leftOperand = assertDefined(
|
|
781
2326
|
operands[0],
|
|
782
2327
|
"hasIntersection requires a left operand"
|
|
@@ -838,13 +2383,42 @@ function handleHasIntersectionOperator(
|
|
|
838
2383
|
throw new Error("Invalid map lambda expression structure");
|
|
839
2384
|
}
|
|
840
2385
|
|
|
841
|
-
// Use scoped mapper for resolving the projection
|
|
842
|
-
const
|
|
2386
|
+
// Use scoped mapper for resolving the projection, collecting nullable element columns.
|
|
2387
|
+
const scope: LambdaScope = {
|
|
2388
|
+
variableName: variable.name,
|
|
2389
|
+
relationModel: relations[relations.length - 1]?.model,
|
|
2390
|
+
nullableFields: new Set(),
|
|
2391
|
+
unknownFilters: [],
|
|
2392
|
+
};
|
|
2393
|
+
lambdaScopes.push(scope);
|
|
2394
|
+
let resolved: ResolvedFieldReference;
|
|
2395
|
+
try {
|
|
2396
|
+
resolved = resolveFieldReference(projection.name, scopedMapper);
|
|
2397
|
+
} finally {
|
|
2398
|
+
lambdaScopes.pop();
|
|
2399
|
+
}
|
|
843
2400
|
const fieldName = getLeafField(resolved.path);
|
|
844
2401
|
|
|
845
|
-
|
|
2402
|
+
const base = buildNestedRelationFilter(relations, {
|
|
846
2403
|
[fieldName]: { in: rightOperand.value },
|
|
847
2404
|
});
|
|
2405
|
+
if (scope.nullableFields.size === 0) {
|
|
2406
|
+
return base;
|
|
2407
|
+
}
|
|
2408
|
+
// CEL map() errors if ANY element is missing the projected attribute — even alongside a
|
|
2409
|
+
// matching element — and Cerbos treats that error as deny. Exclude rows holding an
|
|
2410
|
+
// element whose projected (nullable) column is NULL.
|
|
2411
|
+
return {
|
|
2412
|
+
AND: [
|
|
2413
|
+
base,
|
|
2414
|
+
{
|
|
2415
|
+
NOT: buildNestedRelationFilter(
|
|
2416
|
+
relations,
|
|
2417
|
+
buildNullWitnessFilter(scope.nullableFields)
|
|
2418
|
+
),
|
|
2419
|
+
},
|
|
2420
|
+
],
|
|
2421
|
+
};
|
|
848
2422
|
}
|
|
849
2423
|
|
|
850
2424
|
// Handle regular field reference
|
|
@@ -865,11 +2439,7 @@ function handleHasIntersectionOperator(
|
|
|
865
2439
|
}
|
|
866
2440
|
|
|
867
2441
|
if (relations && relations.length > 0) {
|
|
868
|
-
|
|
869
|
-
const fieldFilter = {
|
|
870
|
-
[fieldName]: { in: rightOperand.value },
|
|
871
|
-
};
|
|
872
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
2442
|
+
return buildMembershipFilter({ path, relations }, rightOperand.value);
|
|
873
2443
|
}
|
|
874
2444
|
|
|
875
2445
|
const fieldName = getLeafField(path);
|
|
@@ -898,14 +2468,22 @@ function handleLambdaOperator(operands: PlanExpressionOperand[]): PrismaFilter {
|
|
|
898
2468
|
}));
|
|
899
2469
|
}
|
|
900
2470
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
2471
|
+
type CollectionLambdaParts = {
|
|
2472
|
+
head: RelationConfig;
|
|
2473
|
+
restRelations: RelationConfig[];
|
|
2474
|
+
/** Element predicate, already wrapped through any relations beyond the first. */
|
|
2475
|
+
filterValue: PrismaFilter;
|
|
2476
|
+
/** Nullable element columns referenced by the lambda body (for 3VL guards). */
|
|
2477
|
+
nullableFields: Set<string>;
|
|
2478
|
+
/** Element-level predicates under which a nested collection expression is UNKNOWN. */
|
|
2479
|
+
unknownFilters: PrismaFilter[];
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2482
|
+
function buildCollectionLambdaParts(
|
|
905
2483
|
operator: string,
|
|
906
2484
|
operands: PlanExpressionOperand[],
|
|
907
2485
|
mapper: Mapper
|
|
908
|
-
):
|
|
2486
|
+
): CollectionLambdaParts {
|
|
909
2487
|
if (operands.length !== 2) {
|
|
910
2488
|
throw new Error(`${operator} requires exactly two operands`);
|
|
911
2489
|
}
|
|
@@ -952,23 +2530,46 @@ function handleCollectionOperator(
|
|
|
952
2530
|
throw new Error(`${operator} operator requires a relation mapping`);
|
|
953
2531
|
}
|
|
954
2532
|
|
|
2533
|
+
const head = assertDefined(
|
|
2534
|
+
relations[0],
|
|
2535
|
+
`${operator} operator requires a relation mapping`
|
|
2536
|
+
);
|
|
2537
|
+
const restRelations = relations.slice(1);
|
|
2538
|
+
const deepest = assertDefined(
|
|
2539
|
+
relations[relations.length - 1],
|
|
2540
|
+
`${operator} operator requires a relation mapping`
|
|
2541
|
+
);
|
|
2542
|
+
|
|
955
2543
|
const lambdaConditionOperand = assertDefined(
|
|
956
2544
|
lambda.operands[0],
|
|
957
2545
|
"Lambda expression must provide a condition"
|
|
958
2546
|
);
|
|
959
|
-
const lambdaCondition = buildPrismaFilterFromCerbosExpression(
|
|
960
|
-
lambdaConditionOperand, // Use the condition part of the lambda
|
|
961
|
-
scopedMapper
|
|
962
|
-
);
|
|
963
2547
|
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2548
|
+
const scope: LambdaScope = {
|
|
2549
|
+
variableName: variable.name,
|
|
2550
|
+
relationModel: deepest.model,
|
|
2551
|
+
nullableFields: new Set(),
|
|
2552
|
+
unknownFilters: [],
|
|
2553
|
+
};
|
|
2554
|
+
lambdaScopes.push(scope);
|
|
2555
|
+
let lambdaCondition: PrismaFilter;
|
|
2556
|
+
try {
|
|
2557
|
+
lambdaCondition = buildPrismaFilterFromCerbosExpression(
|
|
2558
|
+
lambdaConditionOperand, // Use the condition part of the lambda
|
|
2559
|
+
scopedMapper
|
|
2560
|
+
);
|
|
2561
|
+
} finally {
|
|
2562
|
+
lambdaScopes.pop();
|
|
2563
|
+
}
|
|
2564
|
+
|
|
968
2565
|
let filterValue = lambdaCondition;
|
|
969
2566
|
|
|
970
|
-
|
|
971
|
-
|
|
2567
|
+
if (restRelations.length > 0) {
|
|
2568
|
+
// Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
|
|
2569
|
+
// of the chain, so the element predicate must join through every intermediate hop.
|
|
2570
|
+
filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
|
|
2571
|
+
} else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
|
|
2572
|
+
// If the lambda condition already has a logical structure, use it as-is
|
|
972
2573
|
filterValue = lambdaCondition;
|
|
973
2574
|
} else {
|
|
974
2575
|
const lambdaKeys = Object.keys(lambdaCondition);
|
|
@@ -980,40 +2581,209 @@ function handleCollectionOperator(
|
|
|
980
2581
|
if (lambdaFieldValue === undefined) {
|
|
981
2582
|
throw new Error("Lambda condition field value cannot be undefined");
|
|
982
2583
|
}
|
|
983
|
-
const filterField =
|
|
2584
|
+
const filterField = head.field || defaultKey;
|
|
984
2585
|
filterValue = {
|
|
985
2586
|
[filterField]: lambdaFieldValue,
|
|
986
2587
|
};
|
|
987
2588
|
}
|
|
988
2589
|
|
|
2590
|
+
return {
|
|
2591
|
+
head,
|
|
2592
|
+
restRelations,
|
|
2593
|
+
filterValue,
|
|
2594
|
+
nullableFields: scope.nullableFields,
|
|
2595
|
+
unknownFilters: scope.unknownFilters,
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
/** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
|
|
2600
|
+
function buildNullWitnessFilter(nullableFields: Set<string>): PrismaFilter {
|
|
2601
|
+
const checks = [...nullableFields].map((field) => ({ [field]: null }));
|
|
2602
|
+
if (checks.length === 1) {
|
|
2603
|
+
return checks[0]!;
|
|
2604
|
+
}
|
|
2605
|
+
return { OR: checks };
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function buildUnknownElementFilter(
|
|
2609
|
+
parts: CollectionLambdaParts
|
|
2610
|
+
): PrismaFilter | undefined {
|
|
2611
|
+
const filters = [...parts.unknownFilters];
|
|
2612
|
+
if (parts.nullableFields.size > 0) {
|
|
2613
|
+
filters.push(buildNullWitnessFilter(parts.nullableFields));
|
|
2614
|
+
}
|
|
2615
|
+
if (filters.length === 0) {
|
|
2616
|
+
return undefined;
|
|
2617
|
+
}
|
|
2618
|
+
if (filters.length === 1) {
|
|
2619
|
+
return filters[0];
|
|
2620
|
+
}
|
|
2621
|
+
return { OR: filters };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
function recordNestedCollectionUnknownFilter(filter: PrismaFilter): void {
|
|
2625
|
+
const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
|
|
2626
|
+
enclosingScope?.unknownFilters.push(filter);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/** Wraps an element-level filter through the full relation chain (head + rest). */
|
|
2630
|
+
function wrapCollectionElementFilter(
|
|
2631
|
+
parts: CollectionLambdaParts,
|
|
2632
|
+
elementFilter: PrismaFilter
|
|
2633
|
+
): PrismaFilter {
|
|
2634
|
+
const inner =
|
|
2635
|
+
parts.restRelations.length > 0
|
|
2636
|
+
? buildNestedRelationFilter(parts.restRelations, elementFilter)
|
|
2637
|
+
: elementFilter;
|
|
2638
|
+
return { [parts.head.name]: { some: inner } };
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function throwUnsupportedCollectionOperator(operator: string): never {
|
|
2642
|
+
if (operator === "exists_one") {
|
|
2643
|
+
throw new Error(
|
|
2644
|
+
"exists_one requires counting matching elements, which Prisma where-filters cannot express"
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
throw new Error(
|
|
2648
|
+
"The filter() collection operator returns a list, not a boolean. " +
|
|
2649
|
+
"It cannot be used as a standalone condition. " +
|
|
2650
|
+
"Use exists() or combine filter() with size() instead."
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* Helper function to handle collection operators (exists, all, except, filter)
|
|
2656
|
+
*/
|
|
2657
|
+
function handleCollectionOperator(
|
|
2658
|
+
operator: string,
|
|
2659
|
+
operands: PlanExpressionOperand[],
|
|
2660
|
+
mapper: Mapper
|
|
2661
|
+
): PrismaFilter {
|
|
2662
|
+
const knownValueFilter = tryHandleKnownValueCollectionOperator(
|
|
2663
|
+
operator,
|
|
2664
|
+
operands,
|
|
2665
|
+
mapper,
|
|
2666
|
+
false
|
|
2667
|
+
);
|
|
2668
|
+
if (knownValueFilter !== undefined) {
|
|
2669
|
+
return knownValueFilter;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (operator === "exists_one" || operator === "filter") {
|
|
2673
|
+
throwUnsupportedCollectionOperator(operator);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
const parts = buildCollectionLambdaParts(operator, operands, mapper);
|
|
2677
|
+
const { head, filterValue, nullableFields } = parts;
|
|
2678
|
+
|
|
989
2679
|
switch (operator) {
|
|
990
|
-
case "exists":
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
2680
|
+
case "exists": {
|
|
2681
|
+
// A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
|
|
2682
|
+
// false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
|
|
2683
|
+
const filter = { [head.name]: { some: filterValue } };
|
|
2684
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
2685
|
+
if (unknownElement !== undefined) {
|
|
2686
|
+
recordNestedCollectionUnknownFilter({
|
|
2687
|
+
AND: [
|
|
2688
|
+
{ NOT: filter },
|
|
2689
|
+
wrapCollectionElementFilter(parts, unknownElement),
|
|
2690
|
+
],
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
return filter;
|
|
2694
|
+
}
|
|
998
2695
|
case "except":
|
|
999
|
-
return { [
|
|
1000
|
-
case "
|
|
2696
|
+
return { [head.name]: { some: { NOT: filterValue } } };
|
|
2697
|
+
case "all": {
|
|
2698
|
+
if (parts.restRelations.length > 0) {
|
|
2699
|
+
throw new Error(
|
|
2700
|
+
"all() over a multi-hop relation chain is not supported"
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
const base = { [head.name]: { every: filterValue } };
|
|
2704
|
+
if (nullableFields.size === 0) {
|
|
2705
|
+
return base;
|
|
2706
|
+
}
|
|
2707
|
+
// CEL all() errors when any element evaluation errors without a false witness; SQL
|
|
2708
|
+
// `every` would treat those elements as vacuously passing. Exclude rows holding any
|
|
2709
|
+
// element whose referenced nullable column is NULL. (`every` already rejects rows
|
|
2710
|
+
// with a definitive false witness, matching error absorption.)
|
|
2711
|
+
return {
|
|
2712
|
+
AND: [
|
|
2713
|
+
base,
|
|
2714
|
+
{ [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
|
|
2715
|
+
],
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
default:
|
|
2719
|
+
throw new Error(`Unexpected operator: ${operator}`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/**
|
|
2724
|
+
* Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
|
|
2725
|
+
* buildNegatedFilter).
|
|
2726
|
+
*/
|
|
2727
|
+
function buildNegatedCollectionFilter(
|
|
2728
|
+
operator: string,
|
|
2729
|
+
operands: PlanExpressionOperand[],
|
|
2730
|
+
mapper: Mapper
|
|
2731
|
+
): PrismaFilter {
|
|
2732
|
+
const knownValueFilter = tryHandleKnownValueCollectionOperator(
|
|
2733
|
+
operator,
|
|
2734
|
+
operands,
|
|
2735
|
+
mapper,
|
|
2736
|
+
true
|
|
2737
|
+
);
|
|
2738
|
+
if (knownValueFilter !== undefined) {
|
|
2739
|
+
return knownValueFilter;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
if (operator === "exists_one" || operator === "filter") {
|
|
2743
|
+
throwUnsupportedCollectionOperator(operator);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
const parts = buildCollectionLambdaParts(operator, operands, mapper);
|
|
2747
|
+
const { head, filterValue, nullableFields } = parts;
|
|
2748
|
+
|
|
2749
|
+
switch (operator) {
|
|
2750
|
+
case "exists": {
|
|
2751
|
+
const base = { NOT: { [head.name]: { some: filterValue } } };
|
|
2752
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
2753
|
+
if (unknownElement === undefined) {
|
|
2754
|
+
return base;
|
|
2755
|
+
}
|
|
2756
|
+
// !exists is TRUE only when every element is definitively false: no P-match AND no
|
|
2757
|
+
// element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
|
|
1001
2758
|
return {
|
|
1002
|
-
[relation.name]: {
|
|
1003
|
-
some: filterValue,
|
|
1004
|
-
},
|
|
1005
2759
|
AND: [
|
|
2760
|
+
base,
|
|
1006
2761
|
{
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
},
|
|
2762
|
+
NOT: wrapCollectionElementFilter(
|
|
2763
|
+
parts,
|
|
2764
|
+
unknownElement
|
|
2765
|
+
),
|
|
1012
2766
|
},
|
|
1013
2767
|
],
|
|
1014
2768
|
};
|
|
2769
|
+
}
|
|
1015
2770
|
case "all":
|
|
1016
|
-
|
|
2771
|
+
// !all is TRUE only with a definitive false witness, which also absorbs error
|
|
2772
|
+
// elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
|
|
2773
|
+
return { [head.name]: { some: { NOT: filterValue } } };
|
|
2774
|
+
case "except": {
|
|
2775
|
+
// !except(c,P) = every element definitively matches P.
|
|
2776
|
+
const base = { [head.name]: { none: { NOT: filterValue } } };
|
|
2777
|
+
if (nullableFields.size === 0) {
|
|
2778
|
+
return base;
|
|
2779
|
+
}
|
|
2780
|
+
return {
|
|
2781
|
+
AND: [
|
|
2782
|
+
base,
|
|
2783
|
+
{ [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
|
|
2784
|
+
],
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
1017
2787
|
default:
|
|
1018
2788
|
throw new Error(`Unexpected operator: ${operator}`);
|
|
1019
2789
|
}
|
|
@@ -1091,83 +2861,198 @@ function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
|
|
|
1091
2861
|
return buildFieldFilter(fieldRef, "in", []);
|
|
1092
2862
|
}
|
|
1093
2863
|
|
|
1094
|
-
|
|
2864
|
+
const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
|
|
2868
|
+
* column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
|
|
2869
|
+
* one constant side always rewrites to a plain comparison. Multiplying/dividing by a
|
|
2870
|
+
* negative constant mirrors directional operators. The rewrite happens in IEEE double space,
|
|
2871
|
+
* matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
|
|
2872
|
+
* (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
|
|
2873
|
+
* exactly as CEL evaluates it).
|
|
2874
|
+
*/
|
|
2875
|
+
function handleArithmeticComparison(
|
|
1095
2876
|
operator: string,
|
|
1096
|
-
|
|
2877
|
+
arithExpr: OperatorOperand,
|
|
1097
2878
|
otherOperand: PlanExpressionOperand,
|
|
2879
|
+
arithIsLeft: boolean,
|
|
1098
2880
|
mapper: Mapper
|
|
1099
2881
|
): PrismaFilter {
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
2882
|
+
const arithOp = arithExpr.operator;
|
|
2883
|
+
const arithLeftOp = assertDefined(
|
|
2884
|
+
arithExpr.operands[0],
|
|
2885
|
+
`${arithOp} operator requires a left operand`
|
|
1103
2886
|
);
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
2887
|
+
const arithRightOp = assertDefined(
|
|
2888
|
+
arithExpr.operands[1],
|
|
2889
|
+
`${arithOp} operator requires a right operand`
|
|
1107
2890
|
);
|
|
1108
2891
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
2892
|
+
if (
|
|
2893
|
+
isOperatorOperand(otherOperand) &&
|
|
2894
|
+
ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
|
|
2895
|
+
tryFoldValueExpression(otherOperand, mapper) === null
|
|
2896
|
+
) {
|
|
2897
|
+
throw new Error(
|
|
2898
|
+
"Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter"
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
1112
2901
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (!prismaOp) throw new Error(`Unsupported operator: ${operator}`);
|
|
2902
|
+
const arithLeft = resolveOperand(arithLeftOp, mapper);
|
|
2903
|
+
const arithRight = resolveOperand(arithRightOp, mapper);
|
|
2904
|
+
const other = resolveOperand(otherOperand, mapper);
|
|
1117
2905
|
|
|
1118
|
-
|
|
1119
|
-
|
|
2906
|
+
// Fully constant arithmetic: fold and compare the other side against the result. The
|
|
2907
|
+
// arithmetic side keeps its source position, so when the folded constant was written
|
|
2908
|
+
// FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
|
|
2909
|
+
if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
|
|
2910
|
+
const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
|
|
2911
|
+
if (!isResolvedFieldReference(other)) {
|
|
2912
|
+
throw new Error(
|
|
2913
|
+
`${arithOp} with two values requires a field reference on the other side`
|
|
2914
|
+
);
|
|
1120
2915
|
}
|
|
1121
|
-
|
|
2916
|
+
const effectiveOperator = arithIsLeft
|
|
2917
|
+
? (MIRRORED_OPERATOR[operator] ?? operator)
|
|
2918
|
+
: operator;
|
|
2919
|
+
return buildComparisonFilter(other, effectiveOperator, folded);
|
|
1122
2920
|
}
|
|
1123
2921
|
|
|
1124
2922
|
if (!isResolvedValue(other)) {
|
|
1125
2923
|
throw new Error(
|
|
1126
|
-
|
|
2924
|
+
`${arithOp} operator with field references requires a value on the other side of the comparison`
|
|
1127
2925
|
);
|
|
1128
2926
|
}
|
|
1129
2927
|
|
|
1130
2928
|
let fieldRef: ResolvedFieldReference;
|
|
1131
|
-
let
|
|
2929
|
+
let constant: Value;
|
|
1132
2930
|
let fieldIsLeft: boolean;
|
|
1133
2931
|
|
|
1134
|
-
if (isResolvedFieldReference(
|
|
1135
|
-
fieldRef =
|
|
1136
|
-
|
|
2932
|
+
if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
|
|
2933
|
+
fieldRef = arithLeft;
|
|
2934
|
+
constant = arithRight.value;
|
|
1137
2935
|
fieldIsLeft = true;
|
|
1138
|
-
} else if (
|
|
1139
|
-
|
|
1140
|
-
|
|
2936
|
+
} else if (
|
|
2937
|
+
isResolvedValue(arithLeft) &&
|
|
2938
|
+
isResolvedFieldReference(arithRight)
|
|
2939
|
+
) {
|
|
2940
|
+
fieldRef = arithRight;
|
|
2941
|
+
constant = arithLeft.value;
|
|
1141
2942
|
fieldIsLeft = false;
|
|
1142
2943
|
} else {
|
|
1143
2944
|
throw new Error(
|
|
1144
|
-
|
|
2945
|
+
`${arithOp} operator requires exactly one field reference and one value, or two values`
|
|
1145
2946
|
);
|
|
1146
2947
|
}
|
|
1147
2948
|
|
|
1148
|
-
|
|
2949
|
+
// The comparison operator was normalized arithmetic-side-first by the caller; if the
|
|
2950
|
+
// arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
|
|
2951
|
+
let effectiveOperator = arithIsLeft
|
|
2952
|
+
? operator
|
|
2953
|
+
: (MIRRORED_OPERATOR[operator] ?? operator);
|
|
2954
|
+
|
|
2955
|
+
// String concatenation solving (eq/ne only).
|
|
2956
|
+
if (arithOp === "add" && typeof constant === "string") {
|
|
2957
|
+
if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`Operator ${effectiveOperator} is not supported with string concatenation`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
|
|
2963
|
+
if (solvedValue === null) {
|
|
2964
|
+
if (effectiveOperator === "eq") return buildImpossibleFilter(fieldRef);
|
|
2965
|
+
return {};
|
|
2966
|
+
}
|
|
2967
|
+
return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
if (typeof constant !== "number" || typeof other.value !== "number") {
|
|
2971
|
+
throw new Error(`${arithOp} comparison requires numeric operands`);
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
if (
|
|
2975
|
+
arithOp === "add" &&
|
|
2976
|
+
(effectiveOperator === "eq" || effectiveOperator === "ne") &&
|
|
2977
|
+
(!Number.isInteger(constant) || !Number.isInteger(other.value))
|
|
2978
|
+
) {
|
|
1149
2979
|
throw new Error(
|
|
1150
|
-
|
|
2980
|
+
"Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
|
|
1151
2981
|
);
|
|
1152
2982
|
}
|
|
1153
2983
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
2984
|
+
let solved: number;
|
|
2985
|
+
switch (arithOp) {
|
|
2986
|
+
case "add":
|
|
2987
|
+
solved = other.value - constant;
|
|
2988
|
+
break;
|
|
2989
|
+
case "sub":
|
|
2990
|
+
if (fieldIsLeft) {
|
|
2991
|
+
// f - c CMP v ⇔ f CMP v + c
|
|
2992
|
+
solved = other.value + constant;
|
|
2993
|
+
} else {
|
|
2994
|
+
// c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
|
|
2995
|
+
solved = constant - other.value;
|
|
2996
|
+
effectiveOperator =
|
|
2997
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
2998
|
+
}
|
|
2999
|
+
break;
|
|
3000
|
+
case "mult":
|
|
3001
|
+
if (constant === 0) {
|
|
3002
|
+
throw new Error(
|
|
3003
|
+
"Multiplication by a constant zero must be folded by the Cerbos planner"
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
solved = other.value / constant;
|
|
3007
|
+
if (constant < 0) {
|
|
3008
|
+
effectiveOperator =
|
|
3009
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
3010
|
+
}
|
|
3011
|
+
break;
|
|
3012
|
+
case "div":
|
|
3013
|
+
if (!fieldIsLeft) {
|
|
3014
|
+
throw new Error(
|
|
3015
|
+
"Division by a column is not supported: the comparison cannot be solved to a plain column filter"
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
if (constant === 0) {
|
|
3019
|
+
throw new Error("Division by a constant zero is not supported");
|
|
3020
|
+
}
|
|
3021
|
+
solved = other.value * constant;
|
|
3022
|
+
if (constant < 0) {
|
|
3023
|
+
effectiveOperator =
|
|
3024
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
3025
|
+
}
|
|
3026
|
+
break;
|
|
3027
|
+
default:
|
|
3028
|
+
throw new Error(`Unsupported operator: ${arithOp}`);
|
|
1158
3029
|
}
|
|
1159
3030
|
|
|
1160
|
-
return
|
|
3031
|
+
return buildComparisonFilter(fieldRef, effectiveOperator, solved);
|
|
1161
3032
|
}
|
|
1162
3033
|
|
|
1163
|
-
function
|
|
1164
|
-
if (
|
|
3034
|
+
function foldArithmetic(operator: string, left: Value, right: Value): Value {
|
|
3035
|
+
if (
|
|
3036
|
+
operator === "add" &&
|
|
3037
|
+
(typeof left === "string" || typeof right === "string")
|
|
3038
|
+
) {
|
|
1165
3039
|
return String(left) + String(right);
|
|
1166
3040
|
}
|
|
1167
|
-
if (typeof left
|
|
1168
|
-
|
|
3041
|
+
if (typeof left !== "number" || typeof right !== "number") {
|
|
3042
|
+
throw new Error(`${operator} operator requires string or number operands`);
|
|
3043
|
+
}
|
|
3044
|
+
switch (operator) {
|
|
3045
|
+
case "add":
|
|
3046
|
+
return left + right;
|
|
3047
|
+
case "sub":
|
|
3048
|
+
return left - right;
|
|
3049
|
+
case "mult":
|
|
3050
|
+
return left * right;
|
|
3051
|
+
case "div":
|
|
3052
|
+
return left / right;
|
|
3053
|
+
default:
|
|
3054
|
+
throw new Error(`Unsupported operator: ${operator}`);
|
|
1169
3055
|
}
|
|
1170
|
-
throw new Error("add operator requires string or number operands");
|
|
1171
3056
|
}
|
|
1172
3057
|
|
|
1173
3058
|
function solveAdd(
|
|
@@ -1377,6 +3262,24 @@ function handleOverlapsOperator(
|
|
|
1377
3262
|
return validConditions[0]!;
|
|
1378
3263
|
}
|
|
1379
3264
|
|
|
3265
|
+
/**
|
|
3266
|
+
* Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
|
|
3267
|
+
*
|
|
3268
|
+
* `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
|
|
3269
|
+
* match as wildcards instead of literally and widen the filter past what the PDP allows.
|
|
3270
|
+
* `[` is unsafe for a separate reason: SQL Server opens a character class on `[` even when
|
|
3271
|
+
* an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly rather
|
|
3272
|
+
* than emit a filter that admits denied rows.
|
|
3273
|
+
*/
|
|
3274
|
+
function assertLikeSafePrefix(prefix: string): void {
|
|
3275
|
+
if (/[%_\[]/.test(prefix)) {
|
|
3276
|
+
throw new Error(
|
|
3277
|
+
"Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
|
|
3278
|
+
"Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one"
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
|
|
1380
3283
|
function handleFieldOverlaps(
|
|
1381
3284
|
left: ResolvedHierarchy,
|
|
1382
3285
|
right: ResolvedHierarchy
|
|
@@ -1401,7 +3304,9 @@ function handleFieldOverlaps(
|
|
|
1401
3304
|
conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
|
|
1402
3305
|
}
|
|
1403
3306
|
conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
|
|
1404
|
-
|
|
3307
|
+
const prefix = otherRaw + delimiter;
|
|
3308
|
+
assertLikeSafePrefix(prefix);
|
|
3309
|
+
conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
|
|
1405
3310
|
|
|
1406
3311
|
if (conditions.length === 1) return conditions[0]!;
|
|
1407
3312
|
return { OR: conditions };
|
|
@@ -1457,7 +3362,9 @@ function handleAncestorDescendantOperator(
|
|
|
1457
3362
|
const descendant = direction === "ancestor" ? right : left;
|
|
1458
3363
|
|
|
1459
3364
|
if (ancestor.type === "constant" && descendant.type === "field") {
|
|
1460
|
-
const prefix =
|
|
3365
|
+
const prefix =
|
|
3366
|
+
ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
|
|
3367
|
+
assertLikeSafePrefix(prefix);
|
|
1461
3368
|
return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
|
|
1462
3369
|
}
|
|
1463
3370
|
|