@cerbos/orm-prisma 3.3.0 → 4.1.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 +243 -10
- package/lib/index.d.ts +102 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1754 -155
- package/lib/index.js.map +1 -1
- package/package.json +24 -15
- package/src/adversarial.test.ts +1281 -0
- package/src/corpus.ts +414 -0
- package/src/generated/prisma-adversarial/browser.ts +54 -0
- package/src/generated/prisma-adversarial/client.ts +76 -0
- package/src/generated/{prisma → prisma-adversarial}/commonInputTypes.ts +131 -27
- package/src/generated/prisma-adversarial/enums.ts +15 -0
- package/src/generated/prisma-adversarial/internal/class.ts +264 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1332 -0
- package/src/generated/{prisma → prisma-adversarial}/internal/prismaNamespaceBrowser.ts +45 -47
- package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1789 -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 +18 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
- package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +356 -0
- package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1371 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +177 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
- package/src/generated/prisma-adversarial-pg/models.ts +18 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -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 +11769 -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/index.ts +2515 -219
- package/src/test-setup.adversarial-url.ts +18 -0
- package/src/test-setup.adversarial.pg.v6.ts +13 -0
- package/src/test-setup.adversarial.pg.v7.ts +9 -0
- package/src/test-setup.adversarial.v6.ts +5 -0
- package/src/test-setup.adversarial.v7.ts +12 -0
- package/src/translator.test.ts +1586 -0
- package/src/generated/prisma/internal/class.ts +0 -274
- package/src/generated/prisma/internal/prismaNamespace.ts +0 -1333
- package/src/generated/prisma/models/Category.ts +0 -1412
- package/src/generated/prisma/models/Label.ts +0 -1276
- package/src/generated/prisma/models/NestedResource.ts +0 -1562
- package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
- package/src/generated/prisma/models/Resource.ts +0 -2136
- package/src/generated/prisma/models/SubCategory.ts +0 -1412
- package/src/generated/prisma/models/Tag.ts +0 -1276
- package/src/generated/prisma/models/User.ts +0 -1503
- package/src/generated/prisma/models.ts +0 -19
- package/src/generated/prisma-v6/index.d.ts +0 -13639
- package/src/index.test.ts +0 -6322
- package/src/test-setup.v6.ts +0 -5
- package/src/test-setup.v7.ts +0 -7
- /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -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,16 +16,178 @@ 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;
|
|
127
|
+
/**
|
|
128
|
+
* Declares that this column can be SQL NULL **and** how the caller represents that NULL in the
|
|
129
|
+
* attributes it sends to `check()`. Declaring it asserts both facts; leaving it undeclared means
|
|
130
|
+
* "treat this column as NOT NULL", which is the historical rendering.
|
|
131
|
+
*
|
|
132
|
+
* This is per attribute rather than per call because one policy suite can legitimately mix the
|
|
133
|
+
* two conventions — the same column can be mapped twice, sent as an explicit null under one
|
|
134
|
+
* attribute name and omitted under another. The call-level option cannot express that, which is
|
|
135
|
+
* what made cerbos/query-plan-adapters#308 unfixable with #302's option alone.
|
|
136
|
+
*
|
|
137
|
+
* - `"explicit"` — a NULL column is sent as an explicit `null` attribute, so CEL holds a null
|
|
138
|
+
* VALUE. `null != "x"` is then TRUE and `null == "x"` is FALSE, both definite, while Prisma's
|
|
139
|
+
* `{ not: "x" }` drops the row under both polarities. The equality family (`equals`, `not`,
|
|
140
|
+
* `in`) is therefore rendered so it can never depend on SQL's UNKNOWN.
|
|
141
|
+
* - `"omitted"` — a NULL column sends no attribute, so CEL raises a missing-attribute error and
|
|
142
|
+
* `check()` denies. Dropping the row is already right, so the rendering is unchanged; what the
|
|
143
|
+
* declaration adds is the same null-operand rejection the call-level `"omitted"` performs,
|
|
144
|
+
* scoped to this attribute.
|
|
145
|
+
*
|
|
146
|
+
* Only the equality family is affected. `lt`/`lte`/`gt`/`gte` and the string operators raise a
|
|
147
|
+
* no-overload error on a null receiver in CEL, which denies under both polarities exactly as a
|
|
148
|
+
* dropped row does, so they are left alone.
|
|
149
|
+
*
|
|
150
|
+
* Distinct from `nullable` above, which declares that a *relation element* column can be NULL so
|
|
151
|
+
* collection macros gain their three-valued guards. The two are independent: `nullable` is about
|
|
152
|
+
* what a subquery does with an UNKNOWN element, this is about what the caller sent to the PDP.
|
|
153
|
+
*
|
|
154
|
+
* See https://github.com/cerbos/query-plan-adapters/issues/308.
|
|
155
|
+
*/
|
|
156
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
24
157
|
relation?: {
|
|
25
158
|
name: string;
|
|
26
159
|
type: "one" | "many";
|
|
160
|
+
/**
|
|
161
|
+
* The Prisma model name of the related record (e.g. "Tag"). Required only for
|
|
162
|
+
* field-to-field comparisons between two columns of the related model, which compile to
|
|
163
|
+
* Prisma field references and need the model name as their container.
|
|
164
|
+
*/
|
|
165
|
+
model?: string;
|
|
27
166
|
field?: string;
|
|
28
167
|
fields?: Record<string, MapperConfig>;
|
|
168
|
+
/**
|
|
169
|
+
* The predicate the application itself applies when it reads this relation — a soft-delete
|
|
170
|
+
* flag, a tenant column, a subtype discriminator, whatever narrows the rows that became the
|
|
171
|
+
* resource attributes.
|
|
172
|
+
*
|
|
173
|
+
* Prisma has no schema-level filtered relation. A `where` injected by a client extension or
|
|
174
|
+
* middleware rewrites the *top-level* query, never the nested `some`/`every`/`none` this
|
|
175
|
+
* adapter generates, so a narrowing the application applies to its own reads does not reach
|
|
176
|
+
* the subquery — and the subquery then matches records the application never serialised, a
|
|
177
|
+
* filter that returns rows the PDP denies. Declaring the predicate here restores the
|
|
178
|
+
* equality; the adapter cannot detect the omission, which is why the field is optional and
|
|
179
|
+
* silence is not a warning. See "Mapping hazards" in the README.
|
|
180
|
+
*
|
|
181
|
+
* It is a Prisma where-input over the RELATED model, ANDed into the nested filter, and it
|
|
182
|
+
* constrains every operator reached through this relation under both polarities. `every` is
|
|
183
|
+
* rewritten to the equivalent `none` so the restriction narrows the records examined rather
|
|
184
|
+
* than the records required to match.
|
|
185
|
+
*
|
|
186
|
+
* ```ts
|
|
187
|
+
* relation: { name: "tags", type: "many", subqueryFilter: { deletedAt: null } }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
subqueryFilter?: PrismaFilter;
|
|
29
191
|
};
|
|
30
192
|
};
|
|
31
193
|
|
|
@@ -33,9 +195,36 @@ export type Mapper =
|
|
|
33
195
|
| Record<string, MapperConfig>
|
|
34
196
|
| ((key: string) => MapperConfig);
|
|
35
197
|
|
|
198
|
+
/**
|
|
199
|
+
* How the caller represents a NULL column when building the attributes it sends to `check()`.
|
|
200
|
+
*
|
|
201
|
+
* The planner emits the same `eq(attr, null)` node either way, so the plan cannot reveal which
|
|
202
|
+
* convention is in use and the adapter has to be told.
|
|
203
|
+
*
|
|
204
|
+
* - `"explicit"` (default) — a NULL column is sent as an explicit `null` attribute. CEL compares
|
|
205
|
+
* `null == null`, so `IS NULL` selects exactly the rows `check()` allows.
|
|
206
|
+
* - `"omitted"` — a NULL column sends no attribute at all. CEL then raises a missing-attribute
|
|
207
|
+
* error, which Cerbos treats as a deny, so a filter that *selects* NULL rows returns rows the
|
|
208
|
+
* PDP denies. Null comparison operands are rejected instead of translated.
|
|
209
|
+
*
|
|
210
|
+
* See https://github.com/cerbos/query-plan-adapters/issues/302.
|
|
211
|
+
*/
|
|
212
|
+
export type NullAttributeRepresentation = "explicit" | "omitted";
|
|
213
|
+
|
|
36
214
|
export interface QueryPlanToPrismaArgs {
|
|
37
215
|
queryPlan: PlanResourcesResponse;
|
|
38
216
|
mapper?: Mapper;
|
|
217
|
+
/**
|
|
218
|
+
* The Prisma model name the generated filter targets (e.g. "Resource"). Required only for
|
|
219
|
+
* field-to-field comparisons between two root columns, which compile to Prisma field
|
|
220
|
+
* references and need the model name as their container.
|
|
221
|
+
*/
|
|
222
|
+
model?: string;
|
|
223
|
+
/**
|
|
224
|
+
* Which NULL-column representation the caller uses when building `check()` attributes.
|
|
225
|
+
* Defaults to `"explicit"`, preserving the historical `IS NULL` translation.
|
|
226
|
+
*/
|
|
227
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
39
228
|
}
|
|
40
229
|
|
|
41
230
|
export type QueryPlanToPrismaResult =
|
|
@@ -91,6 +280,203 @@ function assertDefined<T>(value: T | undefined, message: string): T {
|
|
|
91
280
|
return value;
|
|
92
281
|
}
|
|
93
282
|
|
|
283
|
+
// Operators whose second operand is a lambda that binds an iteration variable.
|
|
284
|
+
const LAMBDA_BINDING_OPERATORS = new Set([
|
|
285
|
+
"exists",
|
|
286
|
+
"exists_one",
|
|
287
|
+
"all",
|
|
288
|
+
"filter",
|
|
289
|
+
"map",
|
|
290
|
+
"except",
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Substitute a lambda iteration variable with a concrete collection element
|
|
295
|
+
* inside a lambda body. A bare reference to the variable becomes the element
|
|
296
|
+
* itself; a `variable.path.to.field` reference drills into the element. A
|
|
297
|
+
* nested collection macro whose lambda rebinds the same variable name shadows
|
|
298
|
+
* the outer variable, so substitution only descends into its collection
|
|
299
|
+
* operand.
|
|
300
|
+
*/
|
|
301
|
+
function substituteLambdaVariable(
|
|
302
|
+
operand: PlanExpressionOperand,
|
|
303
|
+
variableName: string,
|
|
304
|
+
element: Value
|
|
305
|
+
): PlanExpressionOperand {
|
|
306
|
+
if (isNamedOperand(operand)) {
|
|
307
|
+
if (operand.name === variableName) {
|
|
308
|
+
return { value: element };
|
|
309
|
+
}
|
|
310
|
+
if (operand.name.startsWith(`${variableName}.`)) {
|
|
311
|
+
let current: Value = element;
|
|
312
|
+
for (const segment of operand.name
|
|
313
|
+
.slice(variableName.length + 1)
|
|
314
|
+
.split(".")) {
|
|
315
|
+
if (
|
|
316
|
+
current === null ||
|
|
317
|
+
typeof current !== "object" ||
|
|
318
|
+
Array.isArray(current) ||
|
|
319
|
+
!(segment in current) ||
|
|
320
|
+
current[segment] === undefined
|
|
321
|
+
) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Cannot resolve "${operand.name}": collection element has no field "${segment}"`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
current = current[segment];
|
|
327
|
+
}
|
|
328
|
+
return { value: current };
|
|
329
|
+
}
|
|
330
|
+
return operand;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (isOperatorOperand(operand)) {
|
|
334
|
+
if (
|
|
335
|
+
LAMBDA_BINDING_OPERATORS.has(operand.operator) &&
|
|
336
|
+
operand.operands.length === 2
|
|
337
|
+
) {
|
|
338
|
+
const [nestedCollection, nestedLambda] = operand.operands;
|
|
339
|
+
if (
|
|
340
|
+
nestedCollection !== undefined &&
|
|
341
|
+
nestedLambda !== undefined &&
|
|
342
|
+
isOperatorOperand(nestedLambda) &&
|
|
343
|
+
nestedLambda.operator === "lambda"
|
|
344
|
+
) {
|
|
345
|
+
const nestedVariable = nestedLambda.operands[1];
|
|
346
|
+
if (
|
|
347
|
+
nestedVariable !== undefined &&
|
|
348
|
+
isNamedOperand(nestedVariable) &&
|
|
349
|
+
nestedVariable.name === variableName
|
|
350
|
+
) {
|
|
351
|
+
// The nested lambda shadows our variable: substitute only in the
|
|
352
|
+
// collection operand.
|
|
353
|
+
return {
|
|
354
|
+
operator: operand.operator,
|
|
355
|
+
operands: [
|
|
356
|
+
substituteLambdaVariable(nestedCollection, variableName, element),
|
|
357
|
+
nestedLambda,
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
operator: operand.operator,
|
|
365
|
+
operands: operand.operands.map((o) =>
|
|
366
|
+
substituteLambdaVariable(o, variableName, element)
|
|
367
|
+
),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return operand;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Fold a collection macro whose collection operand is a literal value list.
|
|
376
|
+
*
|
|
377
|
+
* The planner emits this shape when a known-value collection (typically a
|
|
378
|
+
* folded principal attribute) has more than 10 elements — at 10 or fewer it
|
|
379
|
+
* unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
|
|
380
|
+
* cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
|
|
381
|
+
* the same fold here so the translated filter does not depend on which side of
|
|
382
|
+
* that threshold the collection lands: substitute each element into the lambda
|
|
383
|
+
* body and combine the per-element filters with OR (`exists`) or AND (`all`).
|
|
384
|
+
*
|
|
385
|
+
* An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
|
|
386
|
+
* match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
|
|
387
|
+
* over an empty collection.
|
|
388
|
+
*/
|
|
389
|
+
function handleKnownValueCollectionOperator(
|
|
390
|
+
operator: string,
|
|
391
|
+
collection: ValueOperand,
|
|
392
|
+
lambda: PlanExpressionOperand,
|
|
393
|
+
mapper: Mapper,
|
|
394
|
+
negated: boolean
|
|
395
|
+
): PrismaFilter {
|
|
396
|
+
if (operator !== "exists" && operator !== "all") {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`${operator} over a literal collection value is not supported. ` +
|
|
399
|
+
"Only exists() and all() can be folded into a flat filter."
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const elements = collection.value;
|
|
404
|
+
if (!Array.isArray(elements)) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`${operator} over a literal collection requires a list value`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`Second operand of ${operator} must be a lambda expression`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (lambda.operands.length !== 2) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`${operator} over a literal collection supports single-variable lambdas only`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const body = assertDefined(
|
|
422
|
+
lambda.operands[0],
|
|
423
|
+
"Lambda expression must provide a condition"
|
|
424
|
+
);
|
|
425
|
+
const variable = assertDefined(
|
|
426
|
+
lambda.operands[1],
|
|
427
|
+
"Lambda variable must have a name"
|
|
428
|
+
);
|
|
429
|
+
if (!isNamedOperand(variable)) {
|
|
430
|
+
throw new Error("Lambda variable must have a name");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const filters = elements.map((element) => {
|
|
434
|
+
const substituted = substituteLambdaVariable(
|
|
435
|
+
body,
|
|
436
|
+
variable.name,
|
|
437
|
+
element
|
|
438
|
+
);
|
|
439
|
+
return negated
|
|
440
|
+
? buildNegatedFilter(substituted, mapper)
|
|
441
|
+
: buildPrismaFilterFromCerbosExpression(substituted, mapper);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const combinesWithOr = operator === "exists" ? !negated : negated;
|
|
445
|
+
return combinesWithOr ? { OR: filters } : { AND: filters };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function tryHandleKnownValueCollectionOperator(
|
|
449
|
+
operator: string,
|
|
450
|
+
operands: PlanExpressionOperand[],
|
|
451
|
+
mapper: Mapper,
|
|
452
|
+
negated: boolean
|
|
453
|
+
): PrismaFilter | undefined {
|
|
454
|
+
if (operands.length !== 2) {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const collection = operands[0];
|
|
459
|
+
const lambda = operands[1];
|
|
460
|
+
if (
|
|
461
|
+
collection === undefined ||
|
|
462
|
+
lambda === undefined ||
|
|
463
|
+
!isValueOperand(collection)
|
|
464
|
+
) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// A literal value list arrives when the planner could not unroll a macro
|
|
469
|
+
// over a known collection (more than 10 elements). Fold it before attempting
|
|
470
|
+
// relation resolution because there is no relation mapping for a literal.
|
|
471
|
+
return handleKnownValueCollectionOperator(
|
|
472
|
+
operator,
|
|
473
|
+
collection,
|
|
474
|
+
lambda,
|
|
475
|
+
mapper,
|
|
476
|
+
negated
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
94
480
|
function getLeafField(path: string[]): string {
|
|
95
481
|
const fieldName = path[path.length - 1];
|
|
96
482
|
if (!fieldName) {
|
|
@@ -111,15 +497,24 @@ function getFilterEntry(filter: Record<string, unknown>): [string, unknown] {
|
|
|
111
497
|
type RelationConfig = {
|
|
112
498
|
name: string;
|
|
113
499
|
type: "one" | "many";
|
|
500
|
+
model?: string;
|
|
114
501
|
field?: string;
|
|
115
502
|
nestedMapper?: Record<string, MapperConfig>;
|
|
503
|
+
subqueryFilter?: PrismaFilter;
|
|
116
504
|
};
|
|
117
505
|
|
|
118
506
|
type ResolvedFieldReference = {
|
|
119
507
|
path: string[];
|
|
120
508
|
relations?: RelationConfig[];
|
|
509
|
+
valueType?: "dateTime";
|
|
510
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
121
511
|
};
|
|
122
512
|
|
|
513
|
+
/** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
|
|
514
|
+
function isExplicitNullReference(fieldRef: ResolvedFieldReference): boolean {
|
|
515
|
+
return fieldRef.nullAttributeRepresentation === "explicit";
|
|
516
|
+
}
|
|
517
|
+
|
|
123
518
|
type ResolvedValue = {
|
|
124
519
|
value: any;
|
|
125
520
|
};
|
|
@@ -136,38 +531,6 @@ function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue {
|
|
|
136
531
|
return "value" in operand;
|
|
137
532
|
}
|
|
138
533
|
|
|
139
|
-
function getNamedOperand(
|
|
140
|
-
operands: PlanExpressionOperand[],
|
|
141
|
-
message: string
|
|
142
|
-
): NamedOperand {
|
|
143
|
-
const operand = operands.find(isNamedOperand);
|
|
144
|
-
if (!operand) {
|
|
145
|
-
throw new Error(message);
|
|
146
|
-
}
|
|
147
|
-
return operand;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function getValueOperand(
|
|
151
|
-
operands: PlanExpressionOperand[],
|
|
152
|
-
message: string
|
|
153
|
-
): ValueOperand {
|
|
154
|
-
const operand = operands.find(isValueOperand);
|
|
155
|
-
if (!operand) {
|
|
156
|
-
throw new Error(message);
|
|
157
|
-
}
|
|
158
|
-
return operand;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function requireResolvedFieldReference(
|
|
162
|
-
operand: ResolvedOperand,
|
|
163
|
-
message: string
|
|
164
|
-
): ResolvedFieldReference {
|
|
165
|
-
if (!isResolvedFieldReference(operand)) {
|
|
166
|
-
throw new Error(message);
|
|
167
|
-
}
|
|
168
|
-
return operand;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
534
|
function requireResolvedValue(
|
|
172
535
|
operand: ResolvedOperand,
|
|
173
536
|
message: string
|
|
@@ -196,16 +559,130 @@ function buildFieldEqualsFilter(
|
|
|
196
559
|
return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
|
|
197
560
|
}
|
|
198
561
|
|
|
562
|
+
function buildDirectOrInLeaf(
|
|
563
|
+
fieldRef: ResolvedFieldReference,
|
|
564
|
+
values: Value[]
|
|
565
|
+
): PrismaFilter {
|
|
566
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
567
|
+
return values.length === 1
|
|
568
|
+
? { [fieldName]: values[0] }
|
|
569
|
+
: { [fieldName]: { in: values } };
|
|
570
|
+
}
|
|
571
|
+
|
|
199
572
|
function buildFieldDirectOrInFilter(
|
|
200
573
|
fieldRef: ResolvedFieldReference,
|
|
201
574
|
values: Value[]
|
|
575
|
+
): PrismaFilter {
|
|
576
|
+
return wrapRelations(fieldRef.relations, buildDirectOrInLeaf(fieldRef, values));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* `field IS NOT NULL AND <leaf>`, so a NULL column makes the whole thing definitely FALSE rather
|
|
581
|
+
* than merely unmatched — which is what lets an enclosing `NOT` include the row.
|
|
582
|
+
*
|
|
583
|
+
* Prisma has no three-valued logic to expose: a record either matches a `where` or it does not.
|
|
584
|
+
* That is exactly why the guard has to be explicit — `{ NOT: { field: { equals: "x" } } }` leans on
|
|
585
|
+
* the generated SQL's UNKNOWN, which excludes the record under both polarities, and an
|
|
586
|
+
* explicit-null attribute needs the negation to include it.
|
|
587
|
+
*
|
|
588
|
+
* The guard is ANDed with the leaf BEFORE the relation wrapping, so both halves are evaluated
|
|
589
|
+
* against the same element rather than against two independently-matched ones.
|
|
590
|
+
*/
|
|
591
|
+
function guardedFieldFilter(
|
|
592
|
+
fieldRef: ResolvedFieldReference,
|
|
593
|
+
leaf: PrismaFilter
|
|
202
594
|
): PrismaFilter {
|
|
203
595
|
const fieldName = getLeafField(fieldRef.path);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
596
|
+
return wrapRelations(fieldRef.relations, {
|
|
597
|
+
AND: [{ [fieldName]: { not: null } }, leaf],
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function buildMembershipFilter(
|
|
602
|
+
fieldRef: ResolvedFieldReference,
|
|
603
|
+
values: Value[]
|
|
604
|
+
): PrismaFilter {
|
|
605
|
+
const nonNullValues = values.filter((value) => value !== null);
|
|
606
|
+
const carriesNull = nonNullValues.length !== values.length;
|
|
607
|
+
if (carriesNull) {
|
|
608
|
+
assertNullOperandTranslatable(
|
|
609
|
+
"a null element in an `in` list",
|
|
610
|
+
fieldRef.nullAttributeRepresentation
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const filters: PrismaFilter[] = [];
|
|
614
|
+
|
|
615
|
+
if (nonNullValues.length > 0 || values.length === 0) {
|
|
616
|
+
// Without a null element nothing has made the membership definite yet: a NULL column is
|
|
617
|
+
// dropped by `{ in: [...] }` under BOTH polarities, while CEL compares a null VALUE against
|
|
618
|
+
// each element and gets a definite false, so its negation is TRUE. With a null element the
|
|
619
|
+
// `[null]` disjunct below already settles it.
|
|
620
|
+
filters.push(
|
|
621
|
+
carriesNull || !isExplicitNullReference(fieldRef)
|
|
622
|
+
? buildFieldDirectOrInFilter(fieldRef, nonNullValues)
|
|
623
|
+
: guardedFieldFilter(
|
|
624
|
+
fieldRef,
|
|
625
|
+
buildDirectOrInLeaf(fieldRef, nonNullValues)
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (carriesNull) {
|
|
630
|
+
filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (filters.length === 1) {
|
|
634
|
+
return assertDefined(filters[0], "Membership filter is missing");
|
|
635
|
+
}
|
|
636
|
+
return { OR: filters };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function buildAlwaysTrueFilter(): PrismaFilter {
|
|
640
|
+
return {};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
type TernaryBranchPredicate =
|
|
644
|
+
| { kind: "constant"; value: boolean }
|
|
645
|
+
| { kind: "filter"; filter: PrismaFilter };
|
|
646
|
+
|
|
647
|
+
// Translation-scoped context. Set at queryPlanToPrisma entry; the lambda stack tracks which
|
|
648
|
+
// collection variable (and related Prisma model) encloses the expression currently being built,
|
|
649
|
+
// so leaf handlers can distinguish element columns from outer columns and collect nullable
|
|
650
|
+
// element fields for three-valued-logic guards. Translation is synchronous, so module scope is
|
|
651
|
+
// safe.
|
|
652
|
+
type LambdaScope = {
|
|
653
|
+
variableName: string;
|
|
654
|
+
relationModel: string | undefined;
|
|
655
|
+
nullableFields: Set<string>;
|
|
656
|
+
unknownFilters: PrismaFilter[];
|
|
657
|
+
};
|
|
658
|
+
let lambdaScopes: LambdaScope[] = [];
|
|
659
|
+
let rootModelName: string | undefined;
|
|
660
|
+
let nullRepresentation: NullAttributeRepresentation = "explicit";
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
|
|
664
|
+
* operand.
|
|
665
|
+
*
|
|
666
|
+
* Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
|
|
667
|
+
* missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
|
|
668
|
+
* the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
|
|
669
|
+
* null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
|
|
670
|
+
* than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
|
|
671
|
+
* `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
|
|
672
|
+
* under any nesting; narrowing it requires negation-parity tracking.
|
|
673
|
+
*/
|
|
674
|
+
function assertNullOperandTranslatable(
|
|
675
|
+
context: string,
|
|
676
|
+
declared?: NullAttributeRepresentation
|
|
677
|
+
): void {
|
|
678
|
+
if ((declared ?? nullRepresentation) === "omitted") {
|
|
679
|
+
throw new Error(
|
|
680
|
+
`Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
|
|
681
|
+
"sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
|
|
682
|
+
"(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
|
|
683
|
+
'explicit nulls and use "explicit", or keep this shape out of the policy.'
|
|
684
|
+
);
|
|
685
|
+
}
|
|
209
686
|
}
|
|
210
687
|
|
|
211
688
|
/**
|
|
@@ -214,25 +691,423 @@ function buildFieldDirectOrInFilter(
|
|
|
214
691
|
export function queryPlanToPrisma({
|
|
215
692
|
queryPlan,
|
|
216
693
|
mapper = {},
|
|
694
|
+
model,
|
|
695
|
+
nullAttributeRepresentation = "explicit",
|
|
217
696
|
}: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
|
|
697
|
+
nullRepresentation = nullAttributeRepresentation;
|
|
218
698
|
switch (queryPlan.kind) {
|
|
219
699
|
case PlanKind.ALWAYS_ALLOWED:
|
|
220
700
|
return { kind: PlanKind.ALWAYS_ALLOWED };
|
|
221
701
|
case PlanKind.ALWAYS_DENIED:
|
|
222
702
|
return { kind: PlanKind.ALWAYS_DENIED };
|
|
223
|
-
case PlanKind.CONDITIONAL:
|
|
703
|
+
case PlanKind.CONDITIONAL: {
|
|
704
|
+
lambdaScopes = [];
|
|
705
|
+
rootModelName = model;
|
|
706
|
+
const condition = constantFoldExpression(
|
|
707
|
+
hoistOuterScopeReferences(queryPlan.condition, [])
|
|
708
|
+
);
|
|
709
|
+
if (isValueOperand(condition)) {
|
|
710
|
+
// Real PDP plans fold constant conditions to ALWAYS_ALLOWED/ALWAYS_DENIED before
|
|
711
|
+
// they reach the adapter; this can only surface with hand-crafted plans.
|
|
712
|
+
if (condition.value === true) {
|
|
713
|
+
return { kind: PlanKind.CONDITIONAL, filters: {} };
|
|
714
|
+
}
|
|
715
|
+
// Prisma has no model-agnostic always-false `where` shape: empty logical arrays
|
|
716
|
+
// are ignored.
|
|
717
|
+
throw new Error(
|
|
718
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
// A `map()` at the ROOT of the condition returns a list, not a boolean. Translating it
|
|
722
|
+
// produces a projection filter that is not a valid Prisma `where` clause — the shape
|
|
723
|
+
// must be refused HERE rather than left for findMany to reject at query time, because
|
|
724
|
+
// a projection that happened to coerce would be a silently-wrong filter. (`filter()`
|
|
725
|
+
// as a condition is already rejected inside handleCollectionOperator with its own
|
|
726
|
+
// message; nested inside a comparison or size() both remain translatable.)
|
|
727
|
+
if (isOperatorOperand(condition) && condition.operator === "map") {
|
|
728
|
+
throw new Error(
|
|
729
|
+
"map() returns a list, not a boolean, so it cannot be a condition on its own; " +
|
|
730
|
+
"only comparisons or size() over its result have a boolean meaning"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
224
733
|
return {
|
|
225
734
|
kind: PlanKind.CONDITIONAL,
|
|
226
|
-
filters: buildPrismaFilterFromCerbosExpression(
|
|
227
|
-
queryPlan.condition,
|
|
228
|
-
mapper
|
|
229
|
-
),
|
|
735
|
+
filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
|
|
230
736
|
};
|
|
737
|
+
}
|
|
231
738
|
default:
|
|
232
739
|
throw Error(`Invalid query plan.`);
|
|
233
740
|
}
|
|
234
741
|
}
|
|
235
742
|
|
|
743
|
+
const COLLECTION_OPERATORS = new Set([
|
|
744
|
+
"exists",
|
|
745
|
+
"exists_one",
|
|
746
|
+
"all",
|
|
747
|
+
"except",
|
|
748
|
+
"filter",
|
|
749
|
+
]);
|
|
750
|
+
|
|
751
|
+
function mentionsVariable(
|
|
752
|
+
expr: PlanExpressionOperand,
|
|
753
|
+
variableName: string
|
|
754
|
+
): boolean {
|
|
755
|
+
if (isNamedOperand(expr)) {
|
|
756
|
+
return (
|
|
757
|
+
expr.name === variableName || expr.name.startsWith(variableName + ".")
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (isOperatorOperand(expr)) {
|
|
761
|
+
return expr.operands.some((operand) =>
|
|
762
|
+
mentionsVariable(operand, variableName)
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function isOuterScopeName(name: string, enclosingVariables: string[]): boolean {
|
|
769
|
+
return !enclosingVariables.some(
|
|
770
|
+
(variable) => name === variable || name.startsWith(variable + ".")
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Replaces every occurrence of the bare named operand `name` with a boolean constant. */
|
|
775
|
+
function substituteNamedOperand(
|
|
776
|
+
expr: PlanExpressionOperand,
|
|
777
|
+
name: string,
|
|
778
|
+
value: boolean
|
|
779
|
+
): PlanExpressionOperand {
|
|
780
|
+
if (isNamedOperand(expr)) {
|
|
781
|
+
return expr.name === name ? { value } : expr;
|
|
782
|
+
}
|
|
783
|
+
if (isOperatorOperand(expr)) {
|
|
784
|
+
return {
|
|
785
|
+
operator: expr.operator,
|
|
786
|
+
operands: expr.operands.map((operand) =>
|
|
787
|
+
substituteNamedOperand(operand, name, value)
|
|
788
|
+
),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return expr;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Finds a bare boolean reference to an OUTER column (root or enclosing lambda scope) in a
|
|
796
|
+
* boolean position of a lambda body: a direct operand of and/or/not, a ternary condition, or
|
|
797
|
+
* the body itself.
|
|
798
|
+
*/
|
|
799
|
+
function findOuterBooleanReference(
|
|
800
|
+
expr: PlanExpressionOperand,
|
|
801
|
+
enclosingVariables: string[]
|
|
802
|
+
): string | undefined {
|
|
803
|
+
if (isNamedOperand(expr)) {
|
|
804
|
+
return isOuterScopeName(expr.name, enclosingVariables)
|
|
805
|
+
? expr.name
|
|
806
|
+
: undefined;
|
|
807
|
+
}
|
|
808
|
+
if (!isOperatorOperand(expr)) {
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
const booleanPositions: PlanExpressionOperand[] = [];
|
|
812
|
+
switch (expr.operator) {
|
|
813
|
+
case "and":
|
|
814
|
+
case "or":
|
|
815
|
+
case "not":
|
|
816
|
+
booleanPositions.push(...expr.operands);
|
|
817
|
+
break;
|
|
818
|
+
case "if":
|
|
819
|
+
booleanPositions.push(...expr.operands);
|
|
820
|
+
break;
|
|
821
|
+
default:
|
|
822
|
+
// Comparisons wrapping a ternary keep the outer reference in the ternary condition.
|
|
823
|
+
booleanPositions.push(
|
|
824
|
+
...expr.operands.filter(
|
|
825
|
+
(operand) =>
|
|
826
|
+
isOperatorOperand(operand) &&
|
|
827
|
+
(operand.operator === "if" ||
|
|
828
|
+
operand.operator === "and" ||
|
|
829
|
+
operand.operator === "or" ||
|
|
830
|
+
operand.operator === "not")
|
|
831
|
+
)
|
|
832
|
+
);
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
for (const operand of booleanPositions) {
|
|
836
|
+
const found = findOuterBooleanReference(operand, enclosingVariables);
|
|
837
|
+
if (found !== undefined) {
|
|
838
|
+
return found;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Rewrites lambda bodies that reference columns of an ENCLOSING scope (the root row, or an
|
|
846
|
+
* outer lambda's element) so that every filter lands on the model it belongs to. Without this,
|
|
847
|
+
* an outer column referenced inside `tags.exists(t, ...)` would be emitted as a field of the
|
|
848
|
+
* tag model. Two sound transforms, applied bottom-up:
|
|
849
|
+
*
|
|
850
|
+
* - Conjunct hoisting (exists only): `exists(c, P(c) && Q)` with row-constant Q becomes
|
|
851
|
+
* `exists(c, P(c)) && Q`. Valid in three-valued logic (AND distributes over the per-element
|
|
852
|
+
* OR), including the empty-collection case (both sides are FALSE).
|
|
853
|
+
* - Case split (any collection op): a bare outer boolean reference Q in a boolean position
|
|
854
|
+
* becomes `(Q && op[Q:=true]) || (!Q && op[Q:=false]) || (Q && !Q)`. The contradiction arm
|
|
855
|
+
* keeps the whole expression UNKNOWN (not FALSE) when Q is NULL-derived, mirroring the
|
|
856
|
+
* guarded-ternary encoding.
|
|
857
|
+
*/
|
|
858
|
+
function hoistOuterScopeReferences(
|
|
859
|
+
expr: PlanExpressionOperand,
|
|
860
|
+
enclosingVariables: string[]
|
|
861
|
+
): PlanExpressionOperand {
|
|
862
|
+
if (!isOperatorOperand(expr)) {
|
|
863
|
+
return expr;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (COLLECTION_OPERATORS.has(expr.operator) && expr.operands.length === 2) {
|
|
867
|
+
const [collection, lambda] = expr.operands;
|
|
868
|
+
if (
|
|
869
|
+
collection !== undefined &&
|
|
870
|
+
lambda !== undefined &&
|
|
871
|
+
isOperatorOperand(lambda) &&
|
|
872
|
+
lambda.operator === "lambda" &&
|
|
873
|
+
lambda.operands.length === 2 &&
|
|
874
|
+
lambda.operands[1] !== undefined &&
|
|
875
|
+
isNamedOperand(lambda.operands[1])
|
|
876
|
+
) {
|
|
877
|
+
const variable = lambda.operands[1];
|
|
878
|
+
const body = hoistOuterScopeReferences(
|
|
879
|
+
assertDefined(lambda.operands[0], "Lambda requires a condition"),
|
|
880
|
+
[...enclosingVariables, variable.name]
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const rebuild = (newBody: PlanExpressionOperand): OperatorOperand => ({
|
|
884
|
+
operator: expr.operator,
|
|
885
|
+
operands: [
|
|
886
|
+
collection,
|
|
887
|
+
{ operator: "lambda", operands: [newBody, variable] },
|
|
888
|
+
],
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Conjunct hoisting out of exists().
|
|
892
|
+
if (
|
|
893
|
+
expr.operator === "exists" &&
|
|
894
|
+
isOperatorOperand(body) &&
|
|
895
|
+
body.operator === "and"
|
|
896
|
+
) {
|
|
897
|
+
const elementConjuncts = body.operands.filter((operand) =>
|
|
898
|
+
mentionsVariable(operand, variable.name)
|
|
899
|
+
);
|
|
900
|
+
const outerConjuncts = body.operands.filter(
|
|
901
|
+
(operand) => !mentionsVariable(operand, variable.name)
|
|
902
|
+
);
|
|
903
|
+
if (outerConjuncts.length > 0 && elementConjuncts.length > 0) {
|
|
904
|
+
const innerBody =
|
|
905
|
+
elementConjuncts.length === 1
|
|
906
|
+
? elementConjuncts[0]!
|
|
907
|
+
: { operator: "and", operands: elementConjuncts };
|
|
908
|
+
return {
|
|
909
|
+
operator: "and",
|
|
910
|
+
operands: [rebuild(innerBody), ...outerConjuncts],
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Case split on a bare outer boolean reference.
|
|
916
|
+
const outerRef = findOuterBooleanReference(body, [
|
|
917
|
+
...enclosingVariables,
|
|
918
|
+
variable.name,
|
|
919
|
+
]);
|
|
920
|
+
if (outerRef !== undefined) {
|
|
921
|
+
const q: PlanExpressionOperand = { name: outerRef };
|
|
922
|
+
const notQ: PlanExpressionOperand = { operator: "not", operands: [q] };
|
|
923
|
+
return {
|
|
924
|
+
operator: "or",
|
|
925
|
+
operands: [
|
|
926
|
+
{
|
|
927
|
+
operator: "and",
|
|
928
|
+
operands: [
|
|
929
|
+
q,
|
|
930
|
+
rebuild(substituteNamedOperand(body, outerRef, true)),
|
|
931
|
+
],
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
operator: "and",
|
|
935
|
+
operands: [
|
|
936
|
+
notQ,
|
|
937
|
+
rebuild(substituteNamedOperand(body, outerRef, false)),
|
|
938
|
+
],
|
|
939
|
+
},
|
|
940
|
+
// Contradiction arm: UNKNOWN when the reference is NULL-derived, never TRUE.
|
|
941
|
+
{ operator: "and", operands: [q, notQ] },
|
|
942
|
+
],
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return rebuild(body);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
operator: expr.operator,
|
|
952
|
+
operands: expr.operands.map((operand) =>
|
|
953
|
+
hoistOuterScopeReferences(operand, enclosingVariables)
|
|
954
|
+
),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const FOLDABLE_COMPARISON_OPERATORS = new Set([
|
|
959
|
+
"eq",
|
|
960
|
+
"ne",
|
|
961
|
+
"lt",
|
|
962
|
+
"le",
|
|
963
|
+
"gt",
|
|
964
|
+
"ge",
|
|
965
|
+
]);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Bottom-up constant folding over the plan AST. Only shapes the case-split transform can
|
|
969
|
+
* produce need folding (constant ternary conditions, comparisons between two constants,
|
|
970
|
+
* logical operators with constant operands, collection macros with constant bodies); real
|
|
971
|
+
* planner output arrives pre-folded.
|
|
972
|
+
*/
|
|
973
|
+
function constantFoldExpression(
|
|
974
|
+
expr: PlanExpressionOperand
|
|
975
|
+
): PlanExpressionOperand {
|
|
976
|
+
if (!isOperatorOperand(expr)) {
|
|
977
|
+
return expr;
|
|
978
|
+
}
|
|
979
|
+
const operands = expr.operands.map(constantFoldExpression);
|
|
980
|
+
const folded: OperatorOperand = { operator: expr.operator, operands };
|
|
981
|
+
|
|
982
|
+
switch (expr.operator) {
|
|
983
|
+
case "if": {
|
|
984
|
+
const [condition, thenBranch, elseBranch] = operands;
|
|
985
|
+
if (condition !== undefined && isValueOperand(condition)) {
|
|
986
|
+
if (typeof condition.value !== "boolean") {
|
|
987
|
+
throw new Error("if (ternary) condition must be a boolean expression");
|
|
988
|
+
}
|
|
989
|
+
return assertDefined(
|
|
990
|
+
condition.value ? thenBranch : elseBranch,
|
|
991
|
+
"if (ternary) requires branch operands"
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
return folded;
|
|
995
|
+
}
|
|
996
|
+
case "and": {
|
|
997
|
+
if (operands.some((o) => isValueOperand(o) && o.value === false)) {
|
|
998
|
+
return { value: false };
|
|
999
|
+
}
|
|
1000
|
+
const remaining = operands.filter((o) => !isValueOperand(o));
|
|
1001
|
+
if (remaining.length === 0) return { value: true };
|
|
1002
|
+
if (remaining.length === 1) return remaining[0]!;
|
|
1003
|
+
return { operator: "and", operands: remaining };
|
|
1004
|
+
}
|
|
1005
|
+
case "or": {
|
|
1006
|
+
if (operands.some((o) => isValueOperand(o) && o.value === true)) {
|
|
1007
|
+
return { value: true };
|
|
1008
|
+
}
|
|
1009
|
+
const remaining = operands.filter((o) => !isValueOperand(o));
|
|
1010
|
+
if (remaining.length === 0) return { value: false };
|
|
1011
|
+
if (remaining.length === 1) return remaining[0]!;
|
|
1012
|
+
return { operator: "or", operands: remaining };
|
|
1013
|
+
}
|
|
1014
|
+
case "not": {
|
|
1015
|
+
const operand = operands[0];
|
|
1016
|
+
if (operand !== undefined && isValueOperand(operand)) {
|
|
1017
|
+
return { value: operand.value !== true };
|
|
1018
|
+
}
|
|
1019
|
+
return folded;
|
|
1020
|
+
}
|
|
1021
|
+
case "add":
|
|
1022
|
+
case "sub":
|
|
1023
|
+
case "mult":
|
|
1024
|
+
case "div": {
|
|
1025
|
+
const [left, right] = operands;
|
|
1026
|
+
if (
|
|
1027
|
+
left !== undefined &&
|
|
1028
|
+
right !== undefined &&
|
|
1029
|
+
isValueOperand(left) &&
|
|
1030
|
+
isValueOperand(right)
|
|
1031
|
+
) {
|
|
1032
|
+
return {
|
|
1033
|
+
value: foldArithmetic(expr.operator, left.value, right.value),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
return folded;
|
|
1037
|
+
}
|
|
1038
|
+
case "contains":
|
|
1039
|
+
case "startsWith":
|
|
1040
|
+
case "endsWith": {
|
|
1041
|
+
const [receiver, needle] = operands;
|
|
1042
|
+
if (
|
|
1043
|
+
receiver !== undefined &&
|
|
1044
|
+
needle !== undefined &&
|
|
1045
|
+
isValueOperand(receiver) &&
|
|
1046
|
+
isValueOperand(needle) &&
|
|
1047
|
+
typeof receiver.value === "string" &&
|
|
1048
|
+
typeof needle.value === "string"
|
|
1049
|
+
) {
|
|
1050
|
+
const r = receiver.value;
|
|
1051
|
+
const n = needle.value;
|
|
1052
|
+
return {
|
|
1053
|
+
value:
|
|
1054
|
+
expr.operator === "contains"
|
|
1055
|
+
? r.includes(n)
|
|
1056
|
+
: expr.operator === "startsWith"
|
|
1057
|
+
? r.startsWith(n)
|
|
1058
|
+
: r.endsWith(n),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
return folded;
|
|
1062
|
+
}
|
|
1063
|
+
case "exists":
|
|
1064
|
+
case "all": {
|
|
1065
|
+
const [collection, lambda] = operands;
|
|
1066
|
+
if (
|
|
1067
|
+
collection !== undefined &&
|
|
1068
|
+
lambda !== undefined &&
|
|
1069
|
+
isOperatorOperand(lambda) &&
|
|
1070
|
+
lambda.operator === "lambda" &&
|
|
1071
|
+
lambda.operands[0] !== undefined &&
|
|
1072
|
+
isValueOperand(lambda.operands[0])
|
|
1073
|
+
) {
|
|
1074
|
+
const bodyValue = lambda.operands[0].value === true;
|
|
1075
|
+
if (expr.operator === "exists" && !bodyValue) return { value: false };
|
|
1076
|
+
if (expr.operator === "all" && bodyValue) return { value: true };
|
|
1077
|
+
const sizeExpr: PlanExpressionOperand = {
|
|
1078
|
+
operator: "size",
|
|
1079
|
+
operands: [collection],
|
|
1080
|
+
};
|
|
1081
|
+
// exists(c, true) is "collection is non-empty"; all(c, false) is "collection is empty".
|
|
1082
|
+
return expr.operator === "exists"
|
|
1083
|
+
? { operator: "gt", operands: [sizeExpr, { value: 0 }] }
|
|
1084
|
+
: { operator: "eq", operands: [sizeExpr, { value: 0 }] };
|
|
1085
|
+
}
|
|
1086
|
+
return folded;
|
|
1087
|
+
}
|
|
1088
|
+
default: {
|
|
1089
|
+
if (FOLDABLE_COMPARISON_OPERATORS.has(expr.operator)) {
|
|
1090
|
+
const [left, right] = operands;
|
|
1091
|
+
if (
|
|
1092
|
+
left !== undefined &&
|
|
1093
|
+
right !== undefined &&
|
|
1094
|
+
isValueOperand(left) &&
|
|
1095
|
+
isValueOperand(right)
|
|
1096
|
+
) {
|
|
1097
|
+
return {
|
|
1098
|
+
value: evaluateConstantComparison(
|
|
1099
|
+
expr.operator,
|
|
1100
|
+
left.value,
|
|
1101
|
+
right.value
|
|
1102
|
+
),
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return folded;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
236
1111
|
/**
|
|
237
1112
|
* Resolves a field reference considering relations and nested fields.
|
|
238
1113
|
*/
|
|
@@ -277,8 +1152,10 @@ function resolveFieldReference(
|
|
|
277
1152
|
{
|
|
278
1153
|
name,
|
|
279
1154
|
type,
|
|
1155
|
+
model: activeConfig.relation.model,
|
|
280
1156
|
field: activeConfig.relation.field,
|
|
281
1157
|
nestedMapper: fields,
|
|
1158
|
+
subqueryFilter: activeConfig.relation.subqueryFilter,
|
|
282
1159
|
},
|
|
283
1160
|
];
|
|
284
1161
|
|
|
@@ -302,8 +1179,10 @@ function resolveFieldReference(
|
|
|
302
1179
|
relations.push({
|
|
303
1180
|
name: nextConfig.relation.name,
|
|
304
1181
|
type: nextConfig.relation.type,
|
|
1182
|
+
model: nextConfig.relation.model,
|
|
305
1183
|
field: nextConfig.relation.field,
|
|
306
1184
|
nestedMapper: nextConfig.relation.fields,
|
|
1185
|
+
subqueryFilter: nextConfig.relation.subqueryFilter,
|
|
307
1186
|
});
|
|
308
1187
|
currentMapper = nextConfig.relation.fields || {};
|
|
309
1188
|
currentParts = currentParts.slice(1);
|
|
@@ -318,11 +1197,20 @@ function resolveFieldReference(
|
|
|
318
1197
|
}
|
|
319
1198
|
}
|
|
320
1199
|
|
|
321
|
-
return {
|
|
1200
|
+
return {
|
|
1201
|
+
path: field ? [field] : remainingParts,
|
|
1202
|
+
relations,
|
|
1203
|
+
valueType: activeConfig.valueType,
|
|
1204
|
+
nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
|
|
1205
|
+
};
|
|
322
1206
|
}
|
|
323
1207
|
|
|
324
1208
|
// Simple field mapping
|
|
325
|
-
return {
|
|
1209
|
+
return {
|
|
1210
|
+
path: [activeConfig?.field || reference],
|
|
1211
|
+
valueType: activeConfig?.valueType,
|
|
1212
|
+
nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
|
|
1213
|
+
};
|
|
326
1214
|
}
|
|
327
1215
|
|
|
328
1216
|
/**
|
|
@@ -336,9 +1224,167 @@ function getPrismaRelationOperator(relation: {
|
|
|
336
1224
|
return relation.type === "one" ? "is" : "some";
|
|
337
1225
|
}
|
|
338
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* One relation-scoped filter (`{ tags: { some: … } }`), narrowed by the store-side predicate the
|
|
1229
|
+
* caller declared on the mapping.
|
|
1230
|
+
*
|
|
1231
|
+
* Every nested relation filter the adapter emits goes through here, so a declared
|
|
1232
|
+
* `subqueryFilter` reaches the collection macros, the membership shapes, the emptiness checks and
|
|
1233
|
+
* the hop-existence guard alike. Adding a new relation shape means routing it through here, or
|
|
1234
|
+
* the declaration silently stops applying to it.
|
|
1235
|
+
*
|
|
1236
|
+
* `every` is the one operator that cannot simply absorb the predicate. `{ every: AND(W, P) }`
|
|
1237
|
+
* would REQUIRE every record to satisfy W, which is the opposite of "ignore the records W hides",
|
|
1238
|
+
* so it is rewritten to `{ none: AND(W, NOT P) }` — no visible record violates P. Both agree with
|
|
1239
|
+
* `every` when nothing is declared, and both stay vacuously true over an empty relation.
|
|
1240
|
+
*/
|
|
1241
|
+
function relationFilter(
|
|
1242
|
+
relation: RelationConfig,
|
|
1243
|
+
operator: string,
|
|
1244
|
+
inner: PrismaFilter
|
|
1245
|
+
): PrismaFilter {
|
|
1246
|
+
const declared = relation.subqueryFilter;
|
|
1247
|
+
if (declared === undefined) {
|
|
1248
|
+
return { [relation.name]: { [operator]: inner } };
|
|
1249
|
+
}
|
|
1250
|
+
const narrow = (filter: PrismaFilter): PrismaFilter =>
|
|
1251
|
+
Object.keys(filter).length === 0 ? declared : { AND: [declared, filter] };
|
|
1252
|
+
if (operator === "every") {
|
|
1253
|
+
return Object.keys(inner).length === 0
|
|
1254
|
+
? { [relation.name]: { every: inner } }
|
|
1255
|
+
: { [relation.name]: { none: { AND: [declared, { NOT: inner }] } } };
|
|
1256
|
+
}
|
|
1257
|
+
return { [relation.name]: { [operator]: narrow(inner) } };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
339
1260
|
/**
|
|
340
1261
|
* Builds a nested relation filter for Prisma queries.
|
|
341
1262
|
*/
|
|
1263
|
+
/**
|
|
1264
|
+
* "Every intermediate hop of a dotted path exists", as a Prisma filter.
|
|
1265
|
+
*
|
|
1266
|
+
* CEL cannot dot through a list, so each intermediate segment of `a.b.c` is a to-ONE
|
|
1267
|
+
* parent: absent, the caller sends no attribute and CEL raises a missing-path error, which
|
|
1268
|
+
* denies. `NOT { categories: { some: { subCategories: { some: P } } } }` is TRUE for a row
|
|
1269
|
+
* with no category at all, so the negation returns rows the PDP denies — the parent must be
|
|
1270
|
+
* required separately (cerbos/query-plan-adapters#309).
|
|
1271
|
+
*
|
|
1272
|
+
* Returns undefined when the reference has no intermediate hop, so a direct relation keeps
|
|
1273
|
+
* its empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
|
|
1274
|
+
*/
|
|
1275
|
+
function buildLeadingHopsExistFilter(
|
|
1276
|
+
head: RelationConfig,
|
|
1277
|
+
restRelations: RelationConfig[]
|
|
1278
|
+
): PrismaFilter | undefined {
|
|
1279
|
+
if (restRelations.length === 0) {
|
|
1280
|
+
return undefined;
|
|
1281
|
+
}
|
|
1282
|
+
// The last entry is the collection being iterated; everything before it is a hop.
|
|
1283
|
+
return buildHopsExistFilter([head, ...restRelations.slice(0, -1)]);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* "Every one of these relations exists", as a nested Prisma filter with an empty innermost
|
|
1288
|
+
* predicate — `{ parent: { is: { inner: { is: {} } } } }`.
|
|
1289
|
+
*/
|
|
1290
|
+
function buildHopsExistFilter(
|
|
1291
|
+
hops: RelationConfig[]
|
|
1292
|
+
): PrismaFilter | undefined {
|
|
1293
|
+
if (hops.length === 0) {
|
|
1294
|
+
return undefined;
|
|
1295
|
+
}
|
|
1296
|
+
let inner: PrismaFilter = {};
|
|
1297
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
1298
|
+
const relation = assertDefined(hops[i], "Relation mapping is missing");
|
|
1299
|
+
inner = relationFilter(relation, getPrismaRelationOperator(relation), inner);
|
|
1300
|
+
}
|
|
1301
|
+
return inner;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* "Every to-one hop this expression dots through exists", as a Prisma filter, or undefined
|
|
1306
|
+
* when it dots through none.
|
|
1307
|
+
*
|
|
1308
|
+
* Prisma has no UNKNOWN: a chained relation filter is exact under the POSITIVE polarity —
|
|
1309
|
+
* `categories: { some: { subCategories: { some: P } } }` cannot hold without the category —
|
|
1310
|
+
* but `NOT` inverts the hop requirement along with the predicate, so every operator built on
|
|
1311
|
+
* a chain readmits the parentless rows once negated. #309 fixed that inside the collection
|
|
1312
|
+
* macros, which left the sibling operators reached through the same chain unguarded:
|
|
1313
|
+
* membership and `hasIntersection` (cerbos/query-plan-adapters#315), and the negated count
|
|
1314
|
+
* spelling `!(size(chain) > 0)` (#316), all of which the macros' guard never saw.
|
|
1315
|
+
*
|
|
1316
|
+
* Requiring the hops OUTSIDE the negation is what fixes all of them at once, and it is
|
|
1317
|
+
* unconditionally faithful to CEL rather than a per-operator patch: a missing hop is a
|
|
1318
|
+
* missing-path error, which denies under BOTH polarities, so the requirement never depends
|
|
1319
|
+
* on which operator sits above the chain.
|
|
1320
|
+
*
|
|
1321
|
+
* Lambda bodies are deliberately not walked — their references resolve against a scoped
|
|
1322
|
+
* mapper, and a negated macro carries its own guard through buildNegatedCollectionFilter.
|
|
1323
|
+
*/
|
|
1324
|
+
function buildExpressionHopsExistFilter(
|
|
1325
|
+
operand: PlanExpressionOperand,
|
|
1326
|
+
mapper: Mapper
|
|
1327
|
+
): PrismaFilter | undefined {
|
|
1328
|
+
const hops: PrismaFilter[] = [];
|
|
1329
|
+
const seen = new Set<string>();
|
|
1330
|
+
|
|
1331
|
+
const visit = (expr: PlanExpressionOperand): void => {
|
|
1332
|
+
if (isNamedOperand(expr)) {
|
|
1333
|
+
if (seen.has(expr.name)) {
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
seen.add(expr.name);
|
|
1337
|
+
const { relations } = resolveFieldReference(expr.name, mapper);
|
|
1338
|
+
if (!relations || relations.length === 0) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
// Every to-ONE relation on the path is a hop that must EXIST — including a TRAILING one,
|
|
1342
|
+
// which is the case a chain of to-many relations never produces. `parent.aBool` resolves
|
|
1343
|
+
// to a single to-one relation, and `NOT { parent: { is: { aBool: true } } }` is TRUE for
|
|
1344
|
+
// a row with no parent at all, so the negation returns rows the PDP denies. A trailing
|
|
1345
|
+
// to-MANY relation is the collection being iterated rather than a hop, and keeps its
|
|
1346
|
+
// empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
|
|
1347
|
+
const last = assertDefined(
|
|
1348
|
+
relations[relations.length - 1],
|
|
1349
|
+
"Relation mapping is missing"
|
|
1350
|
+
);
|
|
1351
|
+
const hopFilter = buildHopsExistFilter(
|
|
1352
|
+
last.type === "one" ? relations : relations.slice(0, -1)
|
|
1353
|
+
);
|
|
1354
|
+
if (hopFilter !== undefined) {
|
|
1355
|
+
hops.push(hopFilter);
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (!isOperatorOperand(expr) || expr.operator === "lambda") {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
expr.operands.forEach(visit);
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
visit(operand);
|
|
1366
|
+
|
|
1367
|
+
if (hops.length === 0) {
|
|
1368
|
+
return undefined;
|
|
1369
|
+
}
|
|
1370
|
+
return hops.length === 1
|
|
1371
|
+
? assertDefined(hops[0], "Leading-hops filter is missing")
|
|
1372
|
+
: { AND: hops };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* `{ NOT: filter }`, with every to-one hop `operand` dots through required OUTSIDE the
|
|
1377
|
+
* negation so an absent parent stays denied (see buildExpressionHopsExistFilter).
|
|
1378
|
+
*/
|
|
1379
|
+
function negateRequiringHops(
|
|
1380
|
+
operand: PlanExpressionOperand,
|
|
1381
|
+
filter: PrismaFilter,
|
|
1382
|
+
mapper: Mapper
|
|
1383
|
+
): PrismaFilter {
|
|
1384
|
+
const hops = buildExpressionHopsExistFilter(operand, mapper);
|
|
1385
|
+
return hops === undefined ? { NOT: filter } : { AND: [hops, { NOT: filter }] };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
342
1388
|
function buildNestedRelationFilter(
|
|
343
1389
|
relations: RelationConfig[],
|
|
344
1390
|
fieldFilter: any
|
|
@@ -365,7 +1411,7 @@ function buildNestedRelationFilter(
|
|
|
365
1411
|
}
|
|
366
1412
|
}
|
|
367
1413
|
|
|
368
|
-
currentFilter =
|
|
1414
|
+
currentFilter = relationFilter(relation, relationOperator, currentFilter);
|
|
369
1415
|
}
|
|
370
1416
|
|
|
371
1417
|
return currentFilter;
|
|
@@ -383,6 +1429,9 @@ function resolveOperand(
|
|
|
383
1429
|
} else if (isValueOperand(operand)) {
|
|
384
1430
|
return { value: operand.value };
|
|
385
1431
|
} else if (isOperatorOperand(operand)) {
|
|
1432
|
+
if (operand.operator === "timestamp") {
|
|
1433
|
+
return resolveTimestampOperand(operand, mapper);
|
|
1434
|
+
}
|
|
386
1435
|
const folded = tryFoldValueExpression(operand, mapper);
|
|
387
1436
|
if (folded !== null) return { value: folded };
|
|
388
1437
|
const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
|
|
@@ -391,11 +1440,39 @@ function resolveOperand(
|
|
|
391
1440
|
throw new Error("Operand must have name, value, or be an expression");
|
|
392
1441
|
}
|
|
393
1442
|
|
|
1443
|
+
function resolveTimestampOperand(
|
|
1444
|
+
expression: OperatorOperand,
|
|
1445
|
+
mapper: Mapper
|
|
1446
|
+
): ResolvedOperand {
|
|
1447
|
+
if (expression.operands.length !== 1) {
|
|
1448
|
+
throw new Error("timestamp() requires exactly one operand");
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const operand = assertDefined(
|
|
1452
|
+
expression.operands[0],
|
|
1453
|
+
"timestamp() requires an operand"
|
|
1454
|
+
);
|
|
1455
|
+
if (isNamedOperand(operand)) {
|
|
1456
|
+
const fieldRef = resolveFieldReference(operand.name, mapper);
|
|
1457
|
+
if (fieldRef.valueType !== "dateTime") {
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`timestamp() field ${operand.name} must be mapped with valueType: \"dateTime\"`
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
return fieldRef;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (!isValueOperand(operand) || typeof operand.value !== "string") {
|
|
1466
|
+
throw new Error("timestamp() requires a field reference or RFC 3339 string");
|
|
1467
|
+
}
|
|
1468
|
+
return { value: normalizeRfc3339Milliseconds(operand.value) };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
394
1471
|
function tryFoldValueExpression(
|
|
395
1472
|
expr: OperatorOperand,
|
|
396
1473
|
mapper: Mapper
|
|
397
1474
|
): Value | null {
|
|
398
|
-
if (expr.operator
|
|
1475
|
+
if (!ARITHMETIC_OPERATORS.has(expr.operator)) return null;
|
|
399
1476
|
const leftOp = expr.operands[0];
|
|
400
1477
|
const rightOp = expr.operands[1];
|
|
401
1478
|
if (!leftOp || !rightOp) return null;
|
|
@@ -406,12 +1483,27 @@ function tryFoldValueExpression(
|
|
|
406
1483
|
if (!isResolvedValue(right)) return null;
|
|
407
1484
|
|
|
408
1485
|
try {
|
|
409
|
-
return
|
|
1486
|
+
return foldArithmetic(expr.operator, left.value, right.value);
|
|
410
1487
|
} catch {
|
|
411
1488
|
return null;
|
|
412
1489
|
}
|
|
413
1490
|
}
|
|
414
1491
|
|
|
1492
|
+
/**
|
|
1493
|
+
* Records that the lambda body being built touches a nullable element column, so the
|
|
1494
|
+
* enclosing collection operator can add its three-valued-logic guard.
|
|
1495
|
+
*/
|
|
1496
|
+
function recordNullableElementField(
|
|
1497
|
+
config: MapperConfig,
|
|
1498
|
+
defaultField: string
|
|
1499
|
+
): void {
|
|
1500
|
+
if (!config.nullable) {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const scope = lambdaScopes[lambdaScopes.length - 1];
|
|
1504
|
+
scope?.nullableFields.add(config.field || defaultField);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
415
1507
|
/**
|
|
416
1508
|
* Creates a scoped mapper for collection operations
|
|
417
1509
|
*/
|
|
@@ -463,7 +1555,9 @@ function createScopedMapper(
|
|
|
463
1555
|
}
|
|
464
1556
|
|
|
465
1557
|
// Return the field config if it exists, otherwise create a default one
|
|
466
|
-
|
|
1558
|
+
const fieldConfig = currentConfig[field] || { field };
|
|
1559
|
+
recordNullableElementField(fieldConfig, field);
|
|
1560
|
+
return fieldConfig;
|
|
467
1561
|
}
|
|
468
1562
|
return { field: strippedKey };
|
|
469
1563
|
}
|
|
@@ -516,18 +1610,11 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
516
1610
|
if (!operand) {
|
|
517
1611
|
throw new Error("not operator requires an operand");
|
|
518
1612
|
}
|
|
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
|
-
};
|
|
1613
|
+
return buildNegatedFilter(operand, mapper);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
case "if": {
|
|
1617
|
+
return handleBooleanTernaryOperator(operands, mapper);
|
|
531
1618
|
}
|
|
532
1619
|
|
|
533
1620
|
case "eq":
|
|
@@ -549,10 +1636,6 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
549
1636
|
return handleStringOperator(operator, operands, mapper);
|
|
550
1637
|
}
|
|
551
1638
|
|
|
552
|
-
case "isSet": {
|
|
553
|
-
return handleIsSetOperator(operands, mapper);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
1639
|
case "hasIntersection": {
|
|
557
1640
|
return handleHasIntersectionOperator(operands, mapper);
|
|
558
1641
|
}
|
|
@@ -590,6 +1673,493 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
590
1673
|
}
|
|
591
1674
|
}
|
|
592
1675
|
|
|
1676
|
+
function containsCollectionOperator(expr: PlanExpressionOperand): boolean {
|
|
1677
|
+
if (!isOperatorOperand(expr)) {
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
if (COLLECTION_OPERATORS.has(expr.operator)) {
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
return expr.operands.some(containsCollectionOperator);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Whether `expr` dots through at least one intermediate to-one hop, so negating it needs the
|
|
1688
|
+
* hop requirement of buildExpressionHopsExistFilter.
|
|
1689
|
+
*/
|
|
1690
|
+
function referencesChainedRelation(
|
|
1691
|
+
expr: PlanExpressionOperand,
|
|
1692
|
+
mapper: Mapper
|
|
1693
|
+
): boolean {
|
|
1694
|
+
if (isNamedOperand(expr)) {
|
|
1695
|
+
const { relations } = resolveFieldReference(expr.name, mapper);
|
|
1696
|
+
return relations !== undefined && relations.length > 1;
|
|
1697
|
+
}
|
|
1698
|
+
if (!isOperatorOperand(expr) || expr.operator === "lambda") {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
return expr.operands.some((operand) =>
|
|
1702
|
+
referencesChainedRelation(operand, mapper)
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
|
|
1708
|
+
* (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
|
|
1709
|
+
* subqueries collapse UNKNOWN to false at the EXISTS boundary, so `NOT(some(P))` would
|
|
1710
|
+
* wrongly include rows whose elements make P UNKNOWN (a NULL element column is a missing
|
|
1711
|
+
* attribute — a CEL error — on the check side, which Cerbos treats as deny). Negation is
|
|
1712
|
+
* therefore pushed down to the collection operators, which encode CEL's exact error
|
|
1713
|
+
* semantics:
|
|
1714
|
+
*
|
|
1715
|
+
* - exists(c,P) is TRUE with a true witness, FALSE if every element is definitively false,
|
|
1716
|
+
* and an ERROR otherwise — so `!exists` admits only rows with no P-match AND no
|
|
1717
|
+
* UNKNOWN-element (the nullable-field guard).
|
|
1718
|
+
* - all(c,P) is FALSE only with a definitive false witness (a false witness absorbs error
|
|
1719
|
+
* elements) — so `!all` is `some(NOT P)`, which SQL already evaluates definitively.
|
|
1720
|
+
*/
|
|
1721
|
+
function buildNegatedFilter(
|
|
1722
|
+
operand: PlanExpressionOperand,
|
|
1723
|
+
mapper: Mapper
|
|
1724
|
+
): PrismaFilter {
|
|
1725
|
+
if (isNamedOperand(operand)) {
|
|
1726
|
+
const { relations, ...fieldRef } = resolveFieldReference(
|
|
1727
|
+
operand.name,
|
|
1728
|
+
mapper
|
|
1729
|
+
);
|
|
1730
|
+
if (!relations || relations.length === 0) {
|
|
1731
|
+
return buildFieldEqualsFilter(fieldRef, false);
|
|
1732
|
+
}
|
|
1733
|
+
return negateRequiringHops(
|
|
1734
|
+
operand,
|
|
1735
|
+
buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1736
|
+
mapper
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// `and`/`or` must be pushed through with De Morgan whenever a branch can be UNKNOWN, so
|
|
1741
|
+
// CEL's error absorption survives: `!(A && B)` with A erroring and B false is TRUE in CEL,
|
|
1742
|
+
// and `OR[!A, !B]` reproduces that where a single outer NOT over the conjunction — with the
|
|
1743
|
+
// hop requirement ANDed outside it — would deny. A chained relation is the second source of
|
|
1744
|
+
// UNKNOWN besides the collection macros, so it opens the same push-down.
|
|
1745
|
+
if (
|
|
1746
|
+
isOperatorOperand(operand) &&
|
|
1747
|
+
(containsCollectionOperator(operand) ||
|
|
1748
|
+
referencesChainedRelation(operand, mapper))
|
|
1749
|
+
) {
|
|
1750
|
+
switch (operand.operator) {
|
|
1751
|
+
case "and":
|
|
1752
|
+
return {
|
|
1753
|
+
OR: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
|
|
1754
|
+
};
|
|
1755
|
+
case "or":
|
|
1756
|
+
return {
|
|
1757
|
+
AND: operand.operands.map((o) => buildNegatedFilter(o, mapper)),
|
|
1758
|
+
};
|
|
1759
|
+
case "not":
|
|
1760
|
+
return buildPrismaFilterFromCerbosExpression(
|
|
1761
|
+
assertDefined(
|
|
1762
|
+
operand.operands[0],
|
|
1763
|
+
"not operator requires an operand"
|
|
1764
|
+
),
|
|
1765
|
+
mapper
|
|
1766
|
+
);
|
|
1767
|
+
case "exists":
|
|
1768
|
+
case "all":
|
|
1769
|
+
case "except":
|
|
1770
|
+
case "exists_one":
|
|
1771
|
+
case "filter":
|
|
1772
|
+
return buildNegatedCollectionFilter(
|
|
1773
|
+
operand.operator,
|
|
1774
|
+
operand.operands,
|
|
1775
|
+
mapper
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
return negateRequiringHops(
|
|
1781
|
+
operand,
|
|
1782
|
+
buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1783
|
+
mapper
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function getTernaryOperands(operands: PlanExpressionOperand[]): {
|
|
1788
|
+
condition: PlanExpressionOperand;
|
|
1789
|
+
thenBranch: PlanExpressionOperand;
|
|
1790
|
+
elseBranch: PlanExpressionOperand;
|
|
1791
|
+
} {
|
|
1792
|
+
if (operands.length !== 3) {
|
|
1793
|
+
throw new Error(
|
|
1794
|
+
`if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return {
|
|
1799
|
+
condition: assertDefined(
|
|
1800
|
+
operands[0],
|
|
1801
|
+
"if (ternary) requires a condition operand"
|
|
1802
|
+
),
|
|
1803
|
+
thenBranch: assertDefined(
|
|
1804
|
+
operands[1],
|
|
1805
|
+
"if (ternary) requires a then operand"
|
|
1806
|
+
),
|
|
1807
|
+
elseBranch: assertDefined(
|
|
1808
|
+
operands[2],
|
|
1809
|
+
"if (ternary) requires an else operand"
|
|
1810
|
+
),
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function getConstantBooleanCondition(
|
|
1815
|
+
condition: PlanExpressionOperand
|
|
1816
|
+
): boolean | undefined {
|
|
1817
|
+
if (!isValueOperand(condition)) {
|
|
1818
|
+
return undefined;
|
|
1819
|
+
}
|
|
1820
|
+
if (typeof condition.value !== "boolean") {
|
|
1821
|
+
throw new Error("if (ternary) condition must be a boolean expression");
|
|
1822
|
+
}
|
|
1823
|
+
return condition.value;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function buildBooleanBranchFilter(
|
|
1827
|
+
branch: PlanExpressionOperand,
|
|
1828
|
+
mapper: Mapper
|
|
1829
|
+
): TernaryBranchPredicate {
|
|
1830
|
+
if (!isValueOperand(branch)) {
|
|
1831
|
+
return {
|
|
1832
|
+
kind: "filter",
|
|
1833
|
+
filter: buildPrismaFilterFromCerbosExpression(branch, mapper),
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
if (typeof branch.value !== "boolean") {
|
|
1837
|
+
throw new Error(
|
|
1838
|
+
"if (ternary) branch in boolean position must be a boolean"
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
return { kind: "constant", value: branch.value };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* "The ternary's condition is definitively FALSE", which is exactly buildNegatedFilter's
|
|
1846
|
+
* contract — three-valued, so a condition that is UNKNOWN (a NULL column, an erroring lambda
|
|
1847
|
+
* body, an absent to-one parent) selects NEITHER branch.
|
|
1848
|
+
*
|
|
1849
|
+
* It delegates rather than spelling a second negation. A private copy is how the ternary
|
|
1850
|
+
* missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is TRUE when
|
|
1851
|
+
* the to-one parent is absent, so the else-branch was selected for rows CEL denies outright —
|
|
1852
|
+
* the missing path is an evaluation error, not a false condition
|
|
1853
|
+
* (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
|
|
1854
|
+
* which a chained `and`/`or` condition needs for CEL's error absorption to survive.
|
|
1855
|
+
*/
|
|
1856
|
+
function buildFalseConditionFilter(
|
|
1857
|
+
condition: PlanExpressionOperand,
|
|
1858
|
+
mapper: Mapper
|
|
1859
|
+
): PrismaFilter {
|
|
1860
|
+
if (isValueOperand(condition)) {
|
|
1861
|
+
throw new Error("Constant ternary conditions must be folded before use");
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return buildNegatedFilter(condition, mapper);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function buildGuardedTernaryFilter({
|
|
1868
|
+
condition,
|
|
1869
|
+
thenFilter,
|
|
1870
|
+
elseFilter,
|
|
1871
|
+
mapper,
|
|
1872
|
+
}: {
|
|
1873
|
+
condition: PlanExpressionOperand;
|
|
1874
|
+
thenFilter: TernaryBranchPredicate;
|
|
1875
|
+
elseFilter: TernaryBranchPredicate;
|
|
1876
|
+
mapper: Mapper;
|
|
1877
|
+
}): PrismaFilter {
|
|
1878
|
+
const conditionTrue = buildPrismaFilterFromCerbosExpression(
|
|
1879
|
+
condition,
|
|
1880
|
+
mapper
|
|
1881
|
+
);
|
|
1882
|
+
const conditionFalse = buildFalseConditionFilter(condition, mapper);
|
|
1883
|
+
const guardedBranches: PrismaFilter[] = [];
|
|
1884
|
+
|
|
1885
|
+
if (thenFilter.kind === "filter") {
|
|
1886
|
+
guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
|
|
1887
|
+
} else if (thenFilter.value) {
|
|
1888
|
+
guardedBranches.push(conditionTrue);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
if (elseFilter.kind === "filter") {
|
|
1892
|
+
guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
|
|
1893
|
+
} else if (elseFilter.value) {
|
|
1894
|
+
guardedBranches.push(conditionFalse);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// For a known condition this contradiction is false. If the condition is
|
|
1898
|
+
// NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
|
|
1899
|
+
// ternary UNKNOWN under an outer NOT instead of authorizing the row.
|
|
1900
|
+
guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
|
|
1901
|
+
|
|
1902
|
+
return {
|
|
1903
|
+
OR: guardedBranches,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function finalizeTernaryBranchPredicate(
|
|
1908
|
+
predicate: TernaryBranchPredicate
|
|
1909
|
+
): PrismaFilter {
|
|
1910
|
+
if (predicate.kind === "filter") {
|
|
1911
|
+
return predicate.filter;
|
|
1912
|
+
}
|
|
1913
|
+
if (predicate.value) {
|
|
1914
|
+
return buildAlwaysTrueFilter();
|
|
1915
|
+
}
|
|
1916
|
+
// Prisma has no model-agnostic always-false `where` shape: empty logical
|
|
1917
|
+
// arrays are ignored. Real PDP plans fold this case to ALWAYS_DENIED.
|
|
1918
|
+
throw new Error(
|
|
1919
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function handleBooleanTernaryOperator(
|
|
1924
|
+
operands: PlanExpressionOperand[],
|
|
1925
|
+
mapper: Mapper
|
|
1926
|
+
): PrismaFilter {
|
|
1927
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
|
|
1928
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
1929
|
+
if (constantCondition !== undefined) {
|
|
1930
|
+
return finalizeTernaryBranchPredicate(
|
|
1931
|
+
buildBooleanBranchFilter(
|
|
1932
|
+
constantCondition ? thenBranch : elseBranch,
|
|
1933
|
+
mapper
|
|
1934
|
+
)
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
return buildGuardedTernaryFilter({
|
|
1939
|
+
condition,
|
|
1940
|
+
thenFilter: buildBooleanBranchFilter(thenBranch, mapper),
|
|
1941
|
+
elseFilter: buildBooleanBranchFilter(elseBranch, mapper),
|
|
1942
|
+
mapper,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function buildTernaryComparisonBranch({
|
|
1947
|
+
operator,
|
|
1948
|
+
operands,
|
|
1949
|
+
ternaryIndex,
|
|
1950
|
+
branch,
|
|
1951
|
+
mapper,
|
|
1952
|
+
}: {
|
|
1953
|
+
operator: string;
|
|
1954
|
+
operands: PlanExpressionOperand[];
|
|
1955
|
+
ternaryIndex: number;
|
|
1956
|
+
branch: PlanExpressionOperand;
|
|
1957
|
+
mapper: Mapper;
|
|
1958
|
+
}): TernaryBranchPredicate {
|
|
1959
|
+
const substitutedOperands = operands.map((operand, index) =>
|
|
1960
|
+
index === ternaryIndex ? branch : operand
|
|
1961
|
+
);
|
|
1962
|
+
assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
|
|
1963
|
+
assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
|
|
1964
|
+
|
|
1965
|
+
const normalized = normalizeBinaryOperands(operator, substitutedOperands);
|
|
1966
|
+
|
|
1967
|
+
const normalizedFirst = normalized.operands[0];
|
|
1968
|
+
const normalizedSecond = normalized.operands[1];
|
|
1969
|
+
if (
|
|
1970
|
+
normalized.operands.length === 2 &&
|
|
1971
|
+
normalizedFirst !== undefined &&
|
|
1972
|
+
normalizedSecond !== undefined &&
|
|
1973
|
+
isValueOperand(normalizedFirst) &&
|
|
1974
|
+
isValueOperand(normalizedSecond)
|
|
1975
|
+
) {
|
|
1976
|
+
return {
|
|
1977
|
+
kind: "constant",
|
|
1978
|
+
value: evaluateConstantComparison(
|
|
1979
|
+
normalized.operator,
|
|
1980
|
+
normalizedFirst.value,
|
|
1981
|
+
normalizedSecond.value
|
|
1982
|
+
),
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
return {
|
|
1987
|
+
kind: "filter",
|
|
1988
|
+
filter: buildPrismaFilterFromCerbosExpression(normalized, mapper),
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function tryHandleTernaryComparison(
|
|
1993
|
+
operator: string,
|
|
1994
|
+
operands: PlanExpressionOperand[],
|
|
1995
|
+
mapper: Mapper
|
|
1996
|
+
): PrismaFilter | null {
|
|
1997
|
+
if (CERBOS_TO_PRISMA_OPERATOR[operator] === undefined) {
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const ternaryIndex = operands.findIndex(
|
|
2002
|
+
(operand) => isOperatorOperand(operand) && operand.operator === "if"
|
|
2003
|
+
);
|
|
2004
|
+
if (ternaryIndex === -1) {
|
|
2005
|
+
return null;
|
|
2006
|
+
}
|
|
2007
|
+
if (operands.length !== 2) {
|
|
2008
|
+
throw new Error(
|
|
2009
|
+
`${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const ternary = assertDefined(
|
|
2014
|
+
operands[ternaryIndex],
|
|
2015
|
+
"Ternary comparison operand is missing"
|
|
2016
|
+
);
|
|
2017
|
+
if (!isOperatorOperand(ternary)) {
|
|
2018
|
+
throw new Error("Ternary comparison operand must be an expression");
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(
|
|
2022
|
+
ternary.operands
|
|
2023
|
+
);
|
|
2024
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
2025
|
+
if (constantCondition !== undefined) {
|
|
2026
|
+
return finalizeTernaryBranchPredicate(
|
|
2027
|
+
buildTernaryComparisonBranch({
|
|
2028
|
+
operator,
|
|
2029
|
+
operands,
|
|
2030
|
+
ternaryIndex,
|
|
2031
|
+
branch: constantCondition ? thenBranch : elseBranch,
|
|
2032
|
+
mapper,
|
|
2033
|
+
})
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
return buildGuardedTernaryFilter({
|
|
2038
|
+
condition,
|
|
2039
|
+
thenFilter: buildTernaryComparisonBranch({
|
|
2040
|
+
operator,
|
|
2041
|
+
operands,
|
|
2042
|
+
ternaryIndex,
|
|
2043
|
+
branch: thenBranch,
|
|
2044
|
+
mapper,
|
|
2045
|
+
}),
|
|
2046
|
+
elseFilter: buildTernaryComparisonBranch({
|
|
2047
|
+
operator,
|
|
2048
|
+
operands,
|
|
2049
|
+
ternaryIndex,
|
|
2050
|
+
branch: elseBranch,
|
|
2051
|
+
mapper,
|
|
2052
|
+
}),
|
|
2053
|
+
mapper,
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function evaluateConstantComparison(
|
|
2058
|
+
operator: string,
|
|
2059
|
+
left: Value,
|
|
2060
|
+
right: Value
|
|
2061
|
+
): boolean {
|
|
2062
|
+
switch (operator) {
|
|
2063
|
+
case "eq":
|
|
2064
|
+
return areValuesEqual(left, right);
|
|
2065
|
+
case "ne":
|
|
2066
|
+
return !areValuesEqual(left, right);
|
|
2067
|
+
case "lt":
|
|
2068
|
+
case "le":
|
|
2069
|
+
case "gt":
|
|
2070
|
+
case "ge": {
|
|
2071
|
+
if (
|
|
2072
|
+
typeof left === "number" &&
|
|
2073
|
+
typeof right === "number" &&
|
|
2074
|
+
(Number.isNaN(left) || Number.isNaN(right))
|
|
2075
|
+
) {
|
|
2076
|
+
return false;
|
|
2077
|
+
}
|
|
2078
|
+
if (
|
|
2079
|
+
!(
|
|
2080
|
+
(typeof left === "number" && typeof right === "number") ||
|
|
2081
|
+
(typeof left === "string" && typeof right === "string")
|
|
2082
|
+
)
|
|
2083
|
+
) {
|
|
2084
|
+
throw new Error(
|
|
2085
|
+
`${operator} constant comparison requires two numbers or two strings`
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
switch (operator) {
|
|
2089
|
+
case "lt":
|
|
2090
|
+
return compareConstantValues(left, right) < 0;
|
|
2091
|
+
case "le":
|
|
2092
|
+
return compareConstantValues(left, right) <= 0;
|
|
2093
|
+
case "gt":
|
|
2094
|
+
return compareConstantValues(left, right) > 0;
|
|
2095
|
+
case "ge":
|
|
2096
|
+
return compareConstantValues(left, right) >= 0;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
throw new Error(`Unsupported constant comparison operator: ${operator}`);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function compareConstantValues(left: number | string, right: number | string) {
|
|
2105
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
2106
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
2107
|
+
}
|
|
2108
|
+
if (typeof left !== "string" || typeof right !== "string") {
|
|
2109
|
+
throw new Error("Cannot order constant values of different types");
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const leftCodePoints = Array.from(left, (character) =>
|
|
2113
|
+
character.codePointAt(0)
|
|
2114
|
+
);
|
|
2115
|
+
const rightCodePoints = Array.from(right, (character) =>
|
|
2116
|
+
character.codePointAt(0)
|
|
2117
|
+
);
|
|
2118
|
+
const sharedLength = Math.min(leftCodePoints.length, rightCodePoints.length);
|
|
2119
|
+
for (let index = 0; index < sharedLength; index++) {
|
|
2120
|
+
const leftCodePoint = leftCodePoints[index]!;
|
|
2121
|
+
const rightCodePoint = rightCodePoints[index]!;
|
|
2122
|
+
if (leftCodePoint !== rightCodePoint) {
|
|
2123
|
+
return leftCodePoint < rightCodePoint ? -1 : 1;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return leftCodePoints.length === rightCodePoints.length
|
|
2127
|
+
? 0
|
|
2128
|
+
: leftCodePoints.length < rightCodePoints.length
|
|
2129
|
+
? -1
|
|
2130
|
+
: 1;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function areValuesEqual(left: Value, right: Value): boolean {
|
|
2134
|
+
if (Array.isArray(left)) {
|
|
2135
|
+
return (
|
|
2136
|
+
Array.isArray(right) &&
|
|
2137
|
+
left.length === right.length &&
|
|
2138
|
+
left.every((value, index) => areValuesEqual(value, right[index]!))
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (Array.isArray(right)) {
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
if (typeof left === "object" && left !== null) {
|
|
2147
|
+
if (typeof right !== "object" || right === null) {
|
|
2148
|
+
return false;
|
|
2149
|
+
}
|
|
2150
|
+
const leftKeys = Object.keys(left);
|
|
2151
|
+
const rightKeys = Object.keys(right);
|
|
2152
|
+
return (
|
|
2153
|
+
leftKeys.length === rightKeys.length &&
|
|
2154
|
+
leftKeys.every(
|
|
2155
|
+
(key) => key in right && areValuesEqual(left[key]!, right[key]!)
|
|
2156
|
+
)
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
return left === right;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
593
2163
|
function handleSizeComparison(
|
|
594
2164
|
operator: string,
|
|
595
2165
|
sizeOperand: OperatorOperand,
|
|
@@ -635,7 +2205,7 @@ function handleSizeComparison(
|
|
|
635
2205
|
}
|
|
636
2206
|
|
|
637
2207
|
const prismaOp = isNonEmpty ? "some" : "none";
|
|
638
|
-
const leafFilter =
|
|
2208
|
+
const leafFilter = relationFilter(deepest, prismaOp, {});
|
|
639
2209
|
|
|
640
2210
|
if (relations.length === 1) {
|
|
641
2211
|
return leafFilter;
|
|
@@ -652,6 +2222,16 @@ function handleRelationalOperator(
|
|
|
652
2222
|
operands: PlanExpressionOperand[],
|
|
653
2223
|
mapper: Mapper
|
|
654
2224
|
): PrismaFilter {
|
|
2225
|
+
const ternaryFilter = tryHandleTernaryComparison(
|
|
2226
|
+
operator,
|
|
2227
|
+
operands,
|
|
2228
|
+
mapper
|
|
2229
|
+
);
|
|
2230
|
+
if (ternaryFilter) {
|
|
2231
|
+
return ternaryFilter;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
({ operator, operands } = normalizeBinaryOperands(operator, operands));
|
|
655
2235
|
const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
|
|
656
2236
|
|
|
657
2237
|
if (!prismaOperator) {
|
|
@@ -661,36 +2241,293 @@ function handleRelationalOperator(
|
|
|
661
2241
|
const leftOperand = operands.find(
|
|
662
2242
|
(o) => isNamedOperand(o) || isOperatorOperand(o)
|
|
663
2243
|
);
|
|
664
|
-
if (!leftOperand) throw new Error("No valid left operand found");
|
|
2244
|
+
if (!leftOperand) throw new Error("No valid left operand found");
|
|
2245
|
+
|
|
2246
|
+
const rightOperand = operands.find((o) => o !== leftOperand);
|
|
2247
|
+
if (!rightOperand) throw new Error("No valid right operand found");
|
|
2248
|
+
|
|
2249
|
+
if (isOperatorOperand(leftOperand) && leftOperand.operator === "size") {
|
|
2250
|
+
return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const arithOperand = [leftOperand, rightOperand].find(
|
|
2254
|
+
(o): o is OperatorOperand =>
|
|
2255
|
+
isOperatorOperand(o) && ARITHMETIC_OPERATORS.has(o.operator)
|
|
2256
|
+
);
|
|
2257
|
+
if (arithOperand) {
|
|
2258
|
+
const otherOperand =
|
|
2259
|
+
arithOperand === leftOperand ? rightOperand : leftOperand;
|
|
2260
|
+
return handleArithmeticComparison(
|
|
2261
|
+
operator,
|
|
2262
|
+
arithOperand,
|
|
2263
|
+
otherOperand,
|
|
2264
|
+
arithOperand === leftOperand,
|
|
2265
|
+
mapper
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
const mapOperand = [leftOperand, rightOperand].find(
|
|
2270
|
+
(o): o is OperatorOperand =>
|
|
2271
|
+
isOperatorOperand(o) && o.operator === "map"
|
|
2272
|
+
);
|
|
2273
|
+
if (mapOperand) {
|
|
2274
|
+
throw new Error(
|
|
2275
|
+
`Direct comparison of map(...) to a value is not supported (operator: ${operator}). ` +
|
|
2276
|
+
`Wrap the map() expression in hasIntersection(map(...), [...]) instead.`
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
const left = resolveOperand(leftOperand, mapper);
|
|
2281
|
+
const right = resolveOperand(rightOperand, mapper);
|
|
2282
|
+
|
|
2283
|
+
if (isResolvedFieldReference(left) && isResolvedFieldReference(right)) {
|
|
2284
|
+
return buildFieldToFieldFilter(
|
|
2285
|
+
operator,
|
|
2286
|
+
leftOperand,
|
|
2287
|
+
left,
|
|
2288
|
+
rightOperand,
|
|
2289
|
+
right
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (isResolvedValue(left) && isResolvedFieldReference(right)) {
|
|
2294
|
+
return buildComparisonFilter(
|
|
2295
|
+
right,
|
|
2296
|
+
MIRRORED_OPERATOR[operator] ?? operator,
|
|
2297
|
+
left.value
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (isResolvedValue(left) && isResolvedValue(right)) {
|
|
2302
|
+
const matches = evaluateConstantComparison(
|
|
2303
|
+
operator,
|
|
2304
|
+
left.value,
|
|
2305
|
+
right.value
|
|
2306
|
+
);
|
|
2307
|
+
if (matches) {
|
|
2308
|
+
return buildAlwaysTrueFilter();
|
|
2309
|
+
}
|
|
2310
|
+
throw new Error(
|
|
2311
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const rightValue = requireResolvedValue(
|
|
2316
|
+
right,
|
|
2317
|
+
"Right operand must be a value"
|
|
2318
|
+
);
|
|
2319
|
+
|
|
2320
|
+
if (isResolvedFieldReference(left)) {
|
|
2321
|
+
return buildComparisonFilter(left, operator, rightValue.value);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
return { [prismaOperator]: rightValue.value };
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Builds a field-vs-constant comparison. Fractional constants against ordering (and
|
|
2329
|
+
* equality) operators are emitted as a two-clause bracket because Prisma coerces filter
|
|
2330
|
+
* values to the column's type: on an Int column, `gte: 1.5` is silently bound as `gte: 1`,
|
|
2331
|
+
* inverting rows like aNumber == 1. Each bracket pairs the exact constant with its integer
|
|
2332
|
+
* neighbor so the combination is correct BOTH for Float columns (the extra clause is
|
|
2333
|
+
* redundant) and Int columns (whichever way Prisma truncates or floors the fraction).
|
|
2334
|
+
*/
|
|
2335
|
+
function buildComparisonFilter(
|
|
2336
|
+
fieldRef: ResolvedFieldReference,
|
|
2337
|
+
operator: string,
|
|
2338
|
+
value: Value
|
|
2339
|
+
): PrismaFilter {
|
|
2340
|
+
const prismaOperator = assertDefined(
|
|
2341
|
+
CERBOS_TO_PRISMA_OPERATOR[operator],
|
|
2342
|
+
`Unsupported operator: ${operator}`
|
|
2343
|
+
);
|
|
2344
|
+
|
|
2345
|
+
if (value === null) {
|
|
2346
|
+
assertNullOperandTranslatable(
|
|
2347
|
+
`\`${operator}\` against a null operand`,
|
|
2348
|
+
fieldRef.nullAttributeRepresentation
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// An attribute the caller sends as an explicit null holds a null VALUE in CEL, so equality
|
|
2353
|
+
// against a non-null operand is a definite FALSE and inequality a definite TRUE. Prisma leans
|
|
2354
|
+
// on SQL's UNKNOWN for both, which drops the row under either polarity, so the presence of the
|
|
2355
|
+
// column has to become part of the predicate. Confined to the equality family: an ordering
|
|
2356
|
+
// comparison against a null receiver is a no-overload error in CEL, which denies exactly as the
|
|
2357
|
+
// dropped row does.
|
|
2358
|
+
if (value !== null && isExplicitNullReference(fieldRef)) {
|
|
2359
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
2360
|
+
if (operator === "eq") {
|
|
2361
|
+
return guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } });
|
|
2362
|
+
}
|
|
2363
|
+
if (operator === "ne") {
|
|
2364
|
+
return {
|
|
2365
|
+
NOT: guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } }),
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
2371
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
2372
|
+
const lower = Math.floor(value);
|
|
2373
|
+
const upper = Math.ceil(value);
|
|
2374
|
+
const equalsBracket = {
|
|
2375
|
+
AND: [
|
|
2376
|
+
{ [fieldName]: { equals: value } },
|
|
2377
|
+
{ [fieldName]: { gt: lower } },
|
|
2378
|
+
{ [fieldName]: { lt: upper } },
|
|
2379
|
+
],
|
|
2380
|
+
};
|
|
2381
|
+
let bracket: PrismaFilter | undefined;
|
|
2382
|
+
switch (operator) {
|
|
2383
|
+
case "gt":
|
|
2384
|
+
bracket = {
|
|
2385
|
+
OR: [{ [fieldName]: { gt: value } }, { [fieldName]: { gte: upper } }],
|
|
2386
|
+
};
|
|
2387
|
+
break;
|
|
2388
|
+
case "ge":
|
|
2389
|
+
bracket = {
|
|
2390
|
+
AND: [{ [fieldName]: { gte: value } }, { [fieldName]: { gt: lower } }],
|
|
2391
|
+
};
|
|
2392
|
+
break;
|
|
2393
|
+
case "lt":
|
|
2394
|
+
bracket = {
|
|
2395
|
+
OR: [{ [fieldName]: { lt: value } }, { [fieldName]: { lte: lower } }],
|
|
2396
|
+
};
|
|
2397
|
+
break;
|
|
2398
|
+
case "le":
|
|
2399
|
+
bracket = {
|
|
2400
|
+
AND: [{ [fieldName]: { lte: value } }, { [fieldName]: { lt: upper } }],
|
|
2401
|
+
};
|
|
2402
|
+
break;
|
|
2403
|
+
case "eq":
|
|
2404
|
+
bracket = equalsBracket;
|
|
2405
|
+
break;
|
|
2406
|
+
case "ne":
|
|
2407
|
+
bracket = { NOT: equalsBracket };
|
|
2408
|
+
break;
|
|
2409
|
+
}
|
|
2410
|
+
if (bracket) {
|
|
2411
|
+
return wrapRelations(fieldRef.relations, bracket);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
return buildFieldFilter(fieldRef, prismaOperator, value);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/**
|
|
2419
|
+
* Builds a column-vs-column comparison as a Prisma field reference. Prisma only supports
|
|
2420
|
+
* references between fields of the SAME model, so the container is the root model for
|
|
2421
|
+
* root-level comparisons and the relation's model inside a lambda; mixed-scope comparisons
|
|
2422
|
+
* (an element column against an outer column) are cross-model and must fail loudly.
|
|
2423
|
+
*/
|
|
2424
|
+
function buildFieldToFieldFilter(
|
|
2425
|
+
operator: string,
|
|
2426
|
+
leftOperand: PlanExpressionOperand,
|
|
2427
|
+
left: ResolvedFieldReference,
|
|
2428
|
+
rightOperand: PlanExpressionOperand,
|
|
2429
|
+
right: ResolvedFieldReference
|
|
2430
|
+
): PrismaFilter {
|
|
2431
|
+
const prismaOperator = assertDefined(
|
|
2432
|
+
CERBOS_TO_PRISMA_OPERATOR[operator],
|
|
2433
|
+
`Unsupported operator: ${operator}`
|
|
2434
|
+
);
|
|
665
2435
|
|
|
666
|
-
|
|
667
|
-
|
|
2436
|
+
if (!isNamedOperand(leftOperand) || !isNamedOperand(rightOperand)) {
|
|
2437
|
+
throw new Error("Field-to-field comparison requires two named operands");
|
|
2438
|
+
}
|
|
668
2439
|
|
|
669
|
-
|
|
670
|
-
|
|
2440
|
+
const scope = lambdaScopes[lambdaScopes.length - 1];
|
|
2441
|
+
let container: string | undefined;
|
|
2442
|
+
|
|
2443
|
+
if (scope) {
|
|
2444
|
+
const prefix = scope.variableName + ".";
|
|
2445
|
+
const leftInScope = leftOperand.name.startsWith(prefix);
|
|
2446
|
+
const rightInScope = rightOperand.name.startsWith(prefix);
|
|
2447
|
+
if (leftInScope !== rightInScope) {
|
|
2448
|
+
throw new Error(
|
|
2449
|
+
`Cannot compare a collection element column with an outer column (${leftOperand.name} vs ${rightOperand.name}): ` +
|
|
2450
|
+
"Prisma field references only work between fields of the same model"
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
if (leftInScope) {
|
|
2454
|
+
container = scope.relationModel;
|
|
2455
|
+
if (!container) {
|
|
2456
|
+
throw new Error(
|
|
2457
|
+
"Field-to-field comparison inside a collection requires `relation.model` in the mapper"
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
671
2461
|
}
|
|
672
2462
|
|
|
673
|
-
|
|
674
|
-
(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
2463
|
+
if (container === undefined) {
|
|
2464
|
+
if (
|
|
2465
|
+
(left.relations && left.relations.length > 0) ||
|
|
2466
|
+
(right.relations && right.relations.length > 0)
|
|
2467
|
+
) {
|
|
2468
|
+
throw new Error(
|
|
2469
|
+
"Cannot compare columns across relations: Prisma field references only work between fields of the same model"
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
container = rootModelName;
|
|
2473
|
+
if (!container) {
|
|
2474
|
+
throw new Error(
|
|
2475
|
+
"Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
681
2478
|
}
|
|
682
2479
|
|
|
683
|
-
const
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
2480
|
+
const leftField = getLeafField(left.path);
|
|
2481
|
+
const rightField = getLeafField(right.path);
|
|
2482
|
+
const fieldReference = { _ref: rightField, _container: container };
|
|
2483
|
+
|
|
2484
|
+
// Two explicit nulls are EQUAL in CEL, and a null against a value is definitely NOT equal.
|
|
2485
|
+
// A bare field reference gives UNKNOWN for both, which drops the row under either polarity.
|
|
2486
|
+
const leftExplicit = isExplicitNullReference(left);
|
|
2487
|
+
const rightExplicit = isExplicitNullReference(right);
|
|
2488
|
+
// Mixing the two conventions across one comparison has no faithful rendering. The declared side
|
|
2489
|
+
// needs a definite answer for its NULL (CEL holds a null VALUE); the undeclared side needs
|
|
2490
|
+
// UNKNOWN for its NULL (a missing attribute, which CEL denies under both polarities). A definite
|
|
2491
|
+
// predicate returns rows the PDP refuses; a plain one drops rows the PDP allows. Refuse it
|
|
2492
|
+
// rather than pick a direction — declare both attributes, or neither.
|
|
2493
|
+
if (leftExplicit !== rightExplicit && (operator === "eq" || operator === "ne")) {
|
|
2494
|
+
throw new Error(
|
|
2495
|
+
`Cannot translate \`${operator}\` between two columns under mixed null conventions: ` +
|
|
2496
|
+
"cannot compare an attribute declared explicit-null with one on the omitted convention: the omitted side is UNKNOWN for a NULL column while the declared side is definite, and no single predicate is both. Declare nullAttributeRepresentation on both mapper entries, or on neither."
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
if (leftExplicit && rightExplicit && (operator === "eq" || operator === "ne")) {
|
|
2500
|
+
const present: PrismaFilter[] = [];
|
|
2501
|
+
if (leftExplicit) {
|
|
2502
|
+
present.push({ [leftField]: { not: null } });
|
|
2503
|
+
}
|
|
2504
|
+
if (rightExplicit) {
|
|
2505
|
+
present.push({ [rightField]: { not: null } });
|
|
2506
|
+
}
|
|
2507
|
+
const equality: PrismaFilter =
|
|
2508
|
+
leftExplicit && rightExplicit
|
|
2509
|
+
? {
|
|
2510
|
+
OR: [
|
|
2511
|
+
{ AND: [{ [leftField]: null }, { [rightField]: null }] },
|
|
2512
|
+
{ AND: [...present, { [leftField]: { equals: fieldReference } }] },
|
|
2513
|
+
],
|
|
2514
|
+
}
|
|
2515
|
+
: { AND: [...present, { [leftField]: { equals: fieldReference } }] };
|
|
2516
|
+
return operator === "eq" ? equality : { NOT: equality };
|
|
2517
|
+
}
|
|
688
2518
|
|
|
689
|
-
|
|
690
|
-
|
|
2519
|
+
// `ne` is the one operator whose Prisma spelling cannot carry a field reference: `not` nests a
|
|
2520
|
+
// NestedStringFilter, which has no `_ref` form, so `{ col: { not: { _ref } } }` is rejected by
|
|
2521
|
+
// the client rather than executed. Hoisting the negation to the top-level `NOT` combinator says
|
|
2522
|
+
// the same thing with a shape that does accept one — the same rewrite the explicit-null branch
|
|
2523
|
+
// above already performs. Three-valued logic is unchanged by the hoist: a NULL on either side
|
|
2524
|
+
// makes the inner equality UNKNOWN, and `NOT UNKNOWN` is still UNKNOWN, so the row stays out
|
|
2525
|
+
// under both polarities, which is what a missing attribute does on the check side.
|
|
2526
|
+
if (operator === "ne") {
|
|
2527
|
+
return { NOT: { [leftField]: { equals: fieldReference } } };
|
|
691
2528
|
}
|
|
692
2529
|
|
|
693
|
-
return { [prismaOperator]:
|
|
2530
|
+
return { [leftField]: { [prismaOperator]: fieldReference } };
|
|
694
2531
|
}
|
|
695
2532
|
|
|
696
2533
|
/**
|
|
@@ -700,70 +2537,145 @@ function handleInOperator(
|
|
|
700
2537
|
operands: PlanExpressionOperand[],
|
|
701
2538
|
mapper: Mapper
|
|
702
2539
|
): PrismaFilter {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
2540
|
+
if (operands.length !== 2) {
|
|
2541
|
+
throw new Error("in requires exactly two operands");
|
|
2542
|
+
}
|
|
2543
|
+
const member = assertDefined(operands[0], "in requires a member operand");
|
|
2544
|
+
const collection = assertDefined(
|
|
2545
|
+
operands[1],
|
|
2546
|
+
"in requires a collection operand"
|
|
708
2547
|
);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
2548
|
+
|
|
2549
|
+
if (isNamedOperand(member) && isValueOperand(collection)) {
|
|
2550
|
+
const fieldRef = resolveFieldReference(member.name, mapper);
|
|
2551
|
+
const values = Array.isArray(collection.value)
|
|
2552
|
+
? collection.value
|
|
2553
|
+
: [collection.value];
|
|
2554
|
+
return buildMembershipFilter(fieldRef, values);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
if (isValueOperand(member) && isNamedOperand(collection)) {
|
|
2558
|
+
return buildMembershipFilter(
|
|
2559
|
+
resolveFieldReference(collection.name, mapper),
|
|
2560
|
+
[member.value]
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
if (isNamedOperand(member) && isNamedOperand(collection)) {
|
|
2565
|
+
throw new Error(
|
|
2566
|
+
"Membership between two database attributes is not supported: Prisma cannot compare a related element column with an outer scalar column"
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
throw new Error(
|
|
2571
|
+
"in requires a field member and literal list, or a literal member and field collection"
|
|
712
2572
|
);
|
|
713
|
-
const values = Array.isArray(value) ? value : [value];
|
|
714
|
-
return buildFieldDirectOrInFilter(fieldRef, values);
|
|
715
2573
|
}
|
|
716
2574
|
|
|
2575
|
+
// Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
|
|
2576
|
+
const MAX_ENUMERATED_NEEDLES = 1000;
|
|
2577
|
+
|
|
717
2578
|
/**
|
|
718
|
-
* Helper function to handle string operators (contains, startsWith, endsWith)
|
|
2579
|
+
* Helper function to handle string operators (contains, startsWith, endsWith).
|
|
2580
|
+
*
|
|
2581
|
+
* These operators are receiver-sensitive and the planner preserves policy source order:
|
|
2582
|
+
* operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
|
|
2583
|
+
* inverts the match direction, so unlike symmetric comparisons they are never normalized.
|
|
719
2584
|
*/
|
|
720
2585
|
function handleStringOperator(
|
|
721
2586
|
operator: string,
|
|
722
2587
|
operands: PlanExpressionOperand[],
|
|
723
2588
|
mapper: Mapper
|
|
724
2589
|
): PrismaFilter {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
2590
|
+
if (operands.length !== 2) {
|
|
2591
|
+
throw new Error(`${operator} requires exactly two operands`);
|
|
2592
|
+
}
|
|
2593
|
+
const receiver = resolveOperand(
|
|
2594
|
+
assertDefined(operands[0], `${operator} requires a receiver operand`),
|
|
2595
|
+
mapper
|
|
730
2596
|
);
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
2597
|
+
const needle = resolveOperand(
|
|
2598
|
+
assertDefined(operands[1], `${operator} requires a needle operand`),
|
|
2599
|
+
mapper
|
|
734
2600
|
);
|
|
735
|
-
|
|
736
|
-
|
|
2601
|
+
|
|
2602
|
+
// Column receiver, constant needle: Prisma's LIKE-based filter.
|
|
2603
|
+
if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
|
|
2604
|
+
const { value } = needle;
|
|
2605
|
+
if (typeof value !== "string") {
|
|
2606
|
+
throw new Error(`${operator} operator requires string value`);
|
|
2607
|
+
}
|
|
2608
|
+
// Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
|
|
2609
|
+
// so a needle containing % or _ would match as a pattern instead of literally (e.g.
|
|
2610
|
+
// startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
|
|
2611
|
+
//
|
|
2612
|
+
// A backslash is the same hazard reached from the other direction, and only an executed
|
|
2613
|
+
// PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
|
|
2614
|
+
// character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
|
|
2615
|
+
// DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
|
|
2616
|
+
// ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
|
|
2617
|
+
// silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
|
|
2618
|
+
if (/[%_\\]/.test(value)) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
|
|
2621
|
+
"Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
|
|
2622
|
+
"escape character on PostgreSQL and MySQL"
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
const fieldName = getLeafField(receiver.path);
|
|
2626
|
+
return wrapRelations(receiver.relations, {
|
|
2627
|
+
[fieldName]: { [operator]: value },
|
|
2628
|
+
});
|
|
737
2629
|
}
|
|
738
2630
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
2631
|
+
// Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
|
|
2632
|
+
// candidate needle the constant admits — prefixes for startsWith, suffixes for endsWith,
|
|
2633
|
+
// substrings for contains — into an exact IN filter. No LIKE, so no escaping hazards; a
|
|
2634
|
+
// NULL needle column stays excluded (CEL missing-attribute deny).
|
|
2635
|
+
if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
|
|
2636
|
+
if (typeof receiver.value !== "string") {
|
|
2637
|
+
throw new Error(`${operator} operator requires a string receiver`);
|
|
2638
|
+
}
|
|
2639
|
+
const haystack = receiver.value;
|
|
2640
|
+
const candidates = new Set<string>([""]);
|
|
2641
|
+
if (operator === "startsWith") {
|
|
2642
|
+
for (let i = 1; i <= haystack.length; i++) {
|
|
2643
|
+
candidates.add(haystack.slice(0, i));
|
|
2644
|
+
}
|
|
2645
|
+
} else if (operator === "endsWith") {
|
|
2646
|
+
for (let i = 0; i < haystack.length; i++) {
|
|
2647
|
+
candidates.add(haystack.slice(i));
|
|
2648
|
+
}
|
|
2649
|
+
} else {
|
|
2650
|
+
for (let start = 0; start < haystack.length; start++) {
|
|
2651
|
+
for (let end = start + 1; end <= haystack.length; end++) {
|
|
2652
|
+
candidates.add(haystack.slice(start, end));
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
if (candidates.size > MAX_ENUMERATED_NEEDLES) {
|
|
2657
|
+
throw new Error(
|
|
2658
|
+
`Cannot translate ${operator} with a constant receiver of this length: ` +
|
|
2659
|
+
`enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
|
|
2660
|
+
);
|
|
2661
|
+
}
|
|
2662
|
+
const fieldName = getLeafField(needle.path);
|
|
2663
|
+
return wrapRelations(needle.relations, {
|
|
2664
|
+
[fieldName]: { in: [...candidates] },
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
744
2667
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const nameOperand = getNamedOperand(operands, "Name operand is undefined");
|
|
753
|
-
const valueOperand = getValueOperand(operands, "Value operand is undefined");
|
|
754
|
-
const fieldRef = requireResolvedFieldReference(
|
|
755
|
-
resolveOperand(nameOperand, mapper),
|
|
756
|
-
"Name operand must resolve to a field reference"
|
|
757
|
-
);
|
|
758
|
-
const resolvedValue = requireResolvedValue(
|
|
759
|
-
resolveOperand(valueOperand, mapper),
|
|
760
|
-
"Value operand must resolve to a value"
|
|
761
|
-
);
|
|
2668
|
+
if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
|
|
2669
|
+
// A column-valued needle would need its LIKE metacharacters escaped per row, which
|
|
2670
|
+
// Prisma's filters cannot do (and Prisma leaks wildcards from field references).
|
|
2671
|
+
throw new Error(
|
|
2672
|
+
`Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
762
2675
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
});
|
|
2676
|
+
throw new Error(
|
|
2677
|
+
`${operator} between two constants must be folded by the Cerbos planner`
|
|
2678
|
+
);
|
|
767
2679
|
}
|
|
768
2680
|
|
|
769
2681
|
/**
|
|
@@ -777,6 +2689,11 @@ function handleHasIntersectionOperator(
|
|
|
777
2689
|
throw new Error("hasIntersection requires exactly two operands");
|
|
778
2690
|
}
|
|
779
2691
|
|
|
2692
|
+
// Intersection is symmetric, and the planner preserves policy source order — e.g.
|
|
2693
|
+
// `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
|
|
2694
|
+
// field/map-first (see #256).
|
|
2695
|
+
({ operands } = normalizeBinaryOperands("hasIntersection", operands));
|
|
2696
|
+
|
|
780
2697
|
const leftOperand = assertDefined(
|
|
781
2698
|
operands[0],
|
|
782
2699
|
"hasIntersection requires a left operand"
|
|
@@ -838,13 +2755,42 @@ function handleHasIntersectionOperator(
|
|
|
838
2755
|
throw new Error("Invalid map lambda expression structure");
|
|
839
2756
|
}
|
|
840
2757
|
|
|
841
|
-
// Use scoped mapper for resolving the projection
|
|
842
|
-
const
|
|
2758
|
+
// Use scoped mapper for resolving the projection, collecting nullable element columns.
|
|
2759
|
+
const scope: LambdaScope = {
|
|
2760
|
+
variableName: variable.name,
|
|
2761
|
+
relationModel: relations[relations.length - 1]?.model,
|
|
2762
|
+
nullableFields: new Set(),
|
|
2763
|
+
unknownFilters: [],
|
|
2764
|
+
};
|
|
2765
|
+
lambdaScopes.push(scope);
|
|
2766
|
+
let resolved: ResolvedFieldReference;
|
|
2767
|
+
try {
|
|
2768
|
+
resolved = resolveFieldReference(projection.name, scopedMapper);
|
|
2769
|
+
} finally {
|
|
2770
|
+
lambdaScopes.pop();
|
|
2771
|
+
}
|
|
843
2772
|
const fieldName = getLeafField(resolved.path);
|
|
844
2773
|
|
|
845
|
-
|
|
2774
|
+
const base = buildNestedRelationFilter(relations, {
|
|
846
2775
|
[fieldName]: { in: rightOperand.value },
|
|
847
2776
|
});
|
|
2777
|
+
if (scope.nullableFields.size === 0) {
|
|
2778
|
+
return base;
|
|
2779
|
+
}
|
|
2780
|
+
// CEL map() errors if ANY element is missing the projected attribute — even alongside a
|
|
2781
|
+
// matching element — and Cerbos treats that error as deny. Exclude rows holding an
|
|
2782
|
+
// element whose projected (nullable) column is NULL.
|
|
2783
|
+
return {
|
|
2784
|
+
AND: [
|
|
2785
|
+
base,
|
|
2786
|
+
{
|
|
2787
|
+
NOT: buildNestedRelationFilter(
|
|
2788
|
+
relations,
|
|
2789
|
+
buildNullWitnessFilter(scope.nullableFields)
|
|
2790
|
+
),
|
|
2791
|
+
},
|
|
2792
|
+
],
|
|
2793
|
+
};
|
|
848
2794
|
}
|
|
849
2795
|
|
|
850
2796
|
// Handle regular field reference
|
|
@@ -865,11 +2811,7 @@ function handleHasIntersectionOperator(
|
|
|
865
2811
|
}
|
|
866
2812
|
|
|
867
2813
|
if (relations && relations.length > 0) {
|
|
868
|
-
|
|
869
|
-
const fieldFilter = {
|
|
870
|
-
[fieldName]: { in: rightOperand.value },
|
|
871
|
-
};
|
|
872
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
2814
|
+
return buildMembershipFilter({ path, relations }, rightOperand.value);
|
|
873
2815
|
}
|
|
874
2816
|
|
|
875
2817
|
const fieldName = getLeafField(path);
|
|
@@ -898,14 +2840,22 @@ function handleLambdaOperator(operands: PlanExpressionOperand[]): PrismaFilter {
|
|
|
898
2840
|
}));
|
|
899
2841
|
}
|
|
900
2842
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
2843
|
+
type CollectionLambdaParts = {
|
|
2844
|
+
head: RelationConfig;
|
|
2845
|
+
restRelations: RelationConfig[];
|
|
2846
|
+
/** Element predicate, already wrapped through any relations beyond the first. */
|
|
2847
|
+
filterValue: PrismaFilter;
|
|
2848
|
+
/** Nullable element columns referenced by the lambda body (for 3VL guards). */
|
|
2849
|
+
nullableFields: Set<string>;
|
|
2850
|
+
/** Element-level predicates under which a nested collection expression is UNKNOWN. */
|
|
2851
|
+
unknownFilters: PrismaFilter[];
|
|
2852
|
+
};
|
|
2853
|
+
|
|
2854
|
+
function buildCollectionLambdaParts(
|
|
905
2855
|
operator: string,
|
|
906
2856
|
operands: PlanExpressionOperand[],
|
|
907
2857
|
mapper: Mapper
|
|
908
|
-
):
|
|
2858
|
+
): CollectionLambdaParts {
|
|
909
2859
|
if (operands.length !== 2) {
|
|
910
2860
|
throw new Error(`${operator} requires exactly two operands`);
|
|
911
2861
|
}
|
|
@@ -952,23 +2902,46 @@ function handleCollectionOperator(
|
|
|
952
2902
|
throw new Error(`${operator} operator requires a relation mapping`);
|
|
953
2903
|
}
|
|
954
2904
|
|
|
2905
|
+
const head = assertDefined(
|
|
2906
|
+
relations[0],
|
|
2907
|
+
`${operator} operator requires a relation mapping`
|
|
2908
|
+
);
|
|
2909
|
+
const restRelations = relations.slice(1);
|
|
2910
|
+
const deepest = assertDefined(
|
|
2911
|
+
relations[relations.length - 1],
|
|
2912
|
+
`${operator} operator requires a relation mapping`
|
|
2913
|
+
);
|
|
2914
|
+
|
|
955
2915
|
const lambdaConditionOperand = assertDefined(
|
|
956
2916
|
lambda.operands[0],
|
|
957
2917
|
"Lambda expression must provide a condition"
|
|
958
2918
|
);
|
|
959
|
-
const lambdaCondition = buildPrismaFilterFromCerbosExpression(
|
|
960
|
-
lambdaConditionOperand, // Use the condition part of the lambda
|
|
961
|
-
scopedMapper
|
|
962
|
-
);
|
|
963
2919
|
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2920
|
+
const scope: LambdaScope = {
|
|
2921
|
+
variableName: variable.name,
|
|
2922
|
+
relationModel: deepest.model,
|
|
2923
|
+
nullableFields: new Set(),
|
|
2924
|
+
unknownFilters: [],
|
|
2925
|
+
};
|
|
2926
|
+
lambdaScopes.push(scope);
|
|
2927
|
+
let lambdaCondition: PrismaFilter;
|
|
2928
|
+
try {
|
|
2929
|
+
lambdaCondition = buildPrismaFilterFromCerbosExpression(
|
|
2930
|
+
lambdaConditionOperand, // Use the condition part of the lambda
|
|
2931
|
+
scopedMapper
|
|
2932
|
+
);
|
|
2933
|
+
} finally {
|
|
2934
|
+
lambdaScopes.pop();
|
|
2935
|
+
}
|
|
2936
|
+
|
|
968
2937
|
let filterValue = lambdaCondition;
|
|
969
2938
|
|
|
970
|
-
|
|
971
|
-
|
|
2939
|
+
if (restRelations.length > 0) {
|
|
2940
|
+
// Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
|
|
2941
|
+
// of the chain, so the element predicate must join through every intermediate hop.
|
|
2942
|
+
filterValue = buildNestedRelationFilter(restRelations, lambdaCondition);
|
|
2943
|
+
} else if (lambdaCondition["AND"] || lambdaCondition["OR"]) {
|
|
2944
|
+
// If the lambda condition already has a logical structure, use it as-is
|
|
972
2945
|
filterValue = lambdaCondition;
|
|
973
2946
|
} else {
|
|
974
2947
|
const lambdaKeys = Object.keys(lambdaCondition);
|
|
@@ -980,40 +2953,223 @@ function handleCollectionOperator(
|
|
|
980
2953
|
if (lambdaFieldValue === undefined) {
|
|
981
2954
|
throw new Error("Lambda condition field value cannot be undefined");
|
|
982
2955
|
}
|
|
983
|
-
const filterField =
|
|
2956
|
+
const filterField = head.field || defaultKey;
|
|
984
2957
|
filterValue = {
|
|
985
2958
|
[filterField]: lambdaFieldValue,
|
|
986
2959
|
};
|
|
987
2960
|
}
|
|
988
2961
|
|
|
2962
|
+
return {
|
|
2963
|
+
head,
|
|
2964
|
+
restRelations,
|
|
2965
|
+
filterValue,
|
|
2966
|
+
nullableFields: scope.nullableFields,
|
|
2967
|
+
unknownFilters: scope.unknownFilters,
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/** OR of `field IS NULL` checks for every nullable element column the lambda touched. */
|
|
2972
|
+
function buildNullWitnessFilter(nullableFields: Set<string>): PrismaFilter {
|
|
2973
|
+
const checks = [...nullableFields].map((field) => ({ [field]: null }));
|
|
2974
|
+
if (checks.length === 1) {
|
|
2975
|
+
return checks[0]!;
|
|
2976
|
+
}
|
|
2977
|
+
return { OR: checks };
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function buildUnknownElementFilter(
|
|
2981
|
+
parts: CollectionLambdaParts
|
|
2982
|
+
): PrismaFilter | undefined {
|
|
2983
|
+
const filters = [...parts.unknownFilters];
|
|
2984
|
+
if (parts.nullableFields.size > 0) {
|
|
2985
|
+
filters.push(buildNullWitnessFilter(parts.nullableFields));
|
|
2986
|
+
}
|
|
2987
|
+
if (filters.length === 0) {
|
|
2988
|
+
return undefined;
|
|
2989
|
+
}
|
|
2990
|
+
if (filters.length === 1) {
|
|
2991
|
+
return filters[0];
|
|
2992
|
+
}
|
|
2993
|
+
return { OR: filters };
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function recordNestedCollectionUnknownFilter(filter: PrismaFilter): void {
|
|
2997
|
+
const enclosingScope = lambdaScopes[lambdaScopes.length - 1];
|
|
2998
|
+
enclosingScope?.unknownFilters.push(filter);
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/** Wraps an element-level filter through the full relation chain (head + rest). */
|
|
3002
|
+
function wrapCollectionElementFilter(
|
|
3003
|
+
parts: CollectionLambdaParts,
|
|
3004
|
+
elementFilter: PrismaFilter
|
|
3005
|
+
): PrismaFilter {
|
|
3006
|
+
const inner =
|
|
3007
|
+
parts.restRelations.length > 0
|
|
3008
|
+
? buildNestedRelationFilter(parts.restRelations, elementFilter)
|
|
3009
|
+
: elementFilter;
|
|
3010
|
+
return relationFilter(parts.head, "some", inner);
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
function throwUnsupportedCollectionOperator(operator: string): never {
|
|
3014
|
+
if (operator === "exists_one") {
|
|
3015
|
+
throw new Error(
|
|
3016
|
+
"exists_one requires counting matching elements, which Prisma where-filters cannot express"
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
throw new Error(
|
|
3020
|
+
"The filter() collection operator returns a list, not a boolean. " +
|
|
3021
|
+
"It cannot be used as a standalone condition. " +
|
|
3022
|
+
"Use exists() or combine filter() with size() instead."
|
|
3023
|
+
);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/**
|
|
3027
|
+
* Helper function to handle collection operators (exists, all, except, filter)
|
|
3028
|
+
*/
|
|
3029
|
+
function handleCollectionOperator(
|
|
3030
|
+
operator: string,
|
|
3031
|
+
operands: PlanExpressionOperand[],
|
|
3032
|
+
mapper: Mapper
|
|
3033
|
+
): PrismaFilter {
|
|
3034
|
+
const knownValueFilter = tryHandleKnownValueCollectionOperator(
|
|
3035
|
+
operator,
|
|
3036
|
+
operands,
|
|
3037
|
+
mapper,
|
|
3038
|
+
false
|
|
3039
|
+
);
|
|
3040
|
+
if (knownValueFilter !== undefined) {
|
|
3041
|
+
return knownValueFilter;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (operator === "exists_one" || operator === "filter") {
|
|
3045
|
+
throwUnsupportedCollectionOperator(operator);
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
const parts = buildCollectionLambdaParts(operator, operands, mapper);
|
|
3049
|
+
const { head, filterValue, nullableFields } = parts;
|
|
3050
|
+
|
|
989
3051
|
switch (operator) {
|
|
990
|
-
case "exists":
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
3052
|
+
case "exists": {
|
|
3053
|
+
// A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
|
|
3054
|
+
// false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
|
|
3055
|
+
const filter = relationFilter(head, "some", filterValue);
|
|
3056
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
3057
|
+
if (unknownElement !== undefined) {
|
|
3058
|
+
recordNestedCollectionUnknownFilter({
|
|
3059
|
+
AND: [
|
|
3060
|
+
{ NOT: filter },
|
|
3061
|
+
wrapCollectionElementFilter(parts, unknownElement),
|
|
3062
|
+
],
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
return filter;
|
|
3066
|
+
}
|
|
998
3067
|
case "except":
|
|
999
|
-
return
|
|
1000
|
-
case "
|
|
3068
|
+
return relationFilter(head, "some", { NOT: filterValue });
|
|
3069
|
+
case "all": {
|
|
3070
|
+
if (parts.restRelations.length > 0) {
|
|
3071
|
+
throw new Error(
|
|
3072
|
+
"all() over a multi-hop relation chain is not supported"
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
const base = relationFilter(head, "every", filterValue);
|
|
3076
|
+
if (nullableFields.size === 0) {
|
|
3077
|
+
return base;
|
|
3078
|
+
}
|
|
3079
|
+
// CEL all() errors when any element evaluation errors without a false witness; SQL
|
|
3080
|
+
// `every` would treat those elements as vacuously passing. Exclude rows holding any
|
|
3081
|
+
// element whose referenced nullable column is NULL. (`every` already rejects rows
|
|
3082
|
+
// with a definitive false witness, matching error absorption.)
|
|
3083
|
+
return {
|
|
3084
|
+
AND: [
|
|
3085
|
+
base,
|
|
3086
|
+
relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
|
|
3087
|
+
],
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
default:
|
|
3091
|
+
throw new Error(`Unexpected operator: ${operator}`);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
/**
|
|
3096
|
+
* Builds the filter for a NEGATED collection operator, encoding CEL's error semantics (see
|
|
3097
|
+
* buildNegatedFilter).
|
|
3098
|
+
*/
|
|
3099
|
+
function buildNegatedCollectionFilter(
|
|
3100
|
+
operator: string,
|
|
3101
|
+
operands: PlanExpressionOperand[],
|
|
3102
|
+
mapper: Mapper
|
|
3103
|
+
): PrismaFilter {
|
|
3104
|
+
const knownValueFilter = tryHandleKnownValueCollectionOperator(
|
|
3105
|
+
operator,
|
|
3106
|
+
operands,
|
|
3107
|
+
mapper,
|
|
3108
|
+
true
|
|
3109
|
+
);
|
|
3110
|
+
if (knownValueFilter !== undefined) {
|
|
3111
|
+
return knownValueFilter;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
if (operator === "exists_one" || operator === "filter") {
|
|
3115
|
+
throwUnsupportedCollectionOperator(operator);
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
const parts = buildCollectionLambdaParts(operator, operands, mapper);
|
|
3119
|
+
const { head, filterValue, nullableFields } = parts;
|
|
3120
|
+
// An absent to-one parent must stay denied under negation rather than satisfying it
|
|
3121
|
+
// vacuously (#309).
|
|
3122
|
+
const leadingHopsExist = buildLeadingHopsExistFilter(
|
|
3123
|
+
head,
|
|
3124
|
+
parts.restRelations
|
|
3125
|
+
);
|
|
3126
|
+
const requireLeadingHops = (filter: PrismaFilter): PrismaFilter =>
|
|
3127
|
+
leadingHopsExist === undefined
|
|
3128
|
+
? filter
|
|
3129
|
+
: { AND: [leadingHopsExist, filter] };
|
|
3130
|
+
|
|
3131
|
+
switch (operator) {
|
|
3132
|
+
case "exists": {
|
|
3133
|
+
const base = requireLeadingHops({
|
|
3134
|
+
NOT: relationFilter(head, "some", filterValue),
|
|
3135
|
+
});
|
|
3136
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
3137
|
+
if (unknownElement === undefined) {
|
|
3138
|
+
return base;
|
|
3139
|
+
}
|
|
3140
|
+
// !exists is TRUE only when every element is definitively false: no P-match AND no
|
|
3141
|
+
// element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
|
|
1001
3142
|
return {
|
|
1002
|
-
[relation.name]: {
|
|
1003
|
-
some: filterValue,
|
|
1004
|
-
},
|
|
1005
3143
|
AND: [
|
|
3144
|
+
base,
|
|
1006
3145
|
{
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
},
|
|
3146
|
+
NOT: wrapCollectionElementFilter(
|
|
3147
|
+
parts,
|
|
3148
|
+
unknownElement
|
|
3149
|
+
),
|
|
1012
3150
|
},
|
|
1013
3151
|
],
|
|
1014
3152
|
};
|
|
3153
|
+
}
|
|
1015
3154
|
case "all":
|
|
1016
|
-
|
|
3155
|
+
// !all is TRUE only with a definitive false witness, which also absorbs error
|
|
3156
|
+
// elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
|
|
3157
|
+
return relationFilter(head, "some", { NOT: filterValue });
|
|
3158
|
+
case "except": {
|
|
3159
|
+
// !except(c,P) = every element definitively matches P.
|
|
3160
|
+
const base = requireLeadingHops(
|
|
3161
|
+
relationFilter(head, "none", { NOT: filterValue })
|
|
3162
|
+
);
|
|
3163
|
+
if (nullableFields.size === 0) {
|
|
3164
|
+
return base;
|
|
3165
|
+
}
|
|
3166
|
+
return {
|
|
3167
|
+
AND: [
|
|
3168
|
+
base,
|
|
3169
|
+
relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
|
|
3170
|
+
],
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
1017
3173
|
default:
|
|
1018
3174
|
throw new Error(`Unexpected operator: ${operator}`);
|
|
1019
3175
|
}
|
|
@@ -1091,83 +3247,198 @@ function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
|
|
|
1091
3247
|
return buildFieldFilter(fieldRef, "in", []);
|
|
1092
3248
|
}
|
|
1093
3249
|
|
|
1094
|
-
|
|
3250
|
+
const ARITHMETIC_OPERATORS = new Set(["add", "sub", "mult", "div"]);
|
|
3251
|
+
|
|
3252
|
+
/**
|
|
3253
|
+
* Translates `column ⊕ constant CMP value` (and its mirrored forms) by solving for the
|
|
3254
|
+
* column: Prisma where-filters cannot express column arithmetic, but linear arithmetic with
|
|
3255
|
+
* one constant side always rewrites to a plain comparison. Multiplying/dividing by a
|
|
3256
|
+
* negative constant mirrors directional operators. The rewrite happens in IEEE double space,
|
|
3257
|
+
* matching CEL: Cerbos attribute numbers are doubles, so this preserves check-time semantics
|
|
3258
|
+
* (e.g. `n * 0.1 == 0.3` solves to an unrepresentable fraction and matches no integer row,
|
|
3259
|
+
* exactly as CEL evaluates it).
|
|
3260
|
+
*/
|
|
3261
|
+
function handleArithmeticComparison(
|
|
1095
3262
|
operator: string,
|
|
1096
|
-
|
|
3263
|
+
arithExpr: OperatorOperand,
|
|
1097
3264
|
otherOperand: PlanExpressionOperand,
|
|
3265
|
+
arithIsLeft: boolean,
|
|
1098
3266
|
mapper: Mapper
|
|
1099
3267
|
): PrismaFilter {
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
3268
|
+
const arithOp = arithExpr.operator;
|
|
3269
|
+
const arithLeftOp = assertDefined(
|
|
3270
|
+
arithExpr.operands[0],
|
|
3271
|
+
`${arithOp} operator requires a left operand`
|
|
1103
3272
|
);
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
3273
|
+
const arithRightOp = assertDefined(
|
|
3274
|
+
arithExpr.operands[1],
|
|
3275
|
+
`${arithOp} operator requires a right operand`
|
|
1107
3276
|
);
|
|
1108
3277
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
3278
|
+
if (
|
|
3279
|
+
isOperatorOperand(otherOperand) &&
|
|
3280
|
+
ARITHMETIC_OPERATORS.has(otherOperand.operator) &&
|
|
3281
|
+
tryFoldValueExpression(otherOperand, mapper) === null
|
|
3282
|
+
) {
|
|
3283
|
+
throw new Error(
|
|
3284
|
+
"Arithmetic on both sides of a comparison is not supported: the expression cannot be solved to a plain column filter"
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
1112
3287
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (!prismaOp) throw new Error(`Unsupported operator: ${operator}`);
|
|
3288
|
+
const arithLeft = resolveOperand(arithLeftOp, mapper);
|
|
3289
|
+
const arithRight = resolveOperand(arithRightOp, mapper);
|
|
3290
|
+
const other = resolveOperand(otherOperand, mapper);
|
|
1117
3291
|
|
|
1118
|
-
|
|
1119
|
-
|
|
3292
|
+
// Fully constant arithmetic: fold and compare the other side against the result. The
|
|
3293
|
+
// arithmetic side keeps its source position, so when the folded constant was written
|
|
3294
|
+
// FIRST (`1 + 2 < f`), directional operators mirror to keep the field on the left.
|
|
3295
|
+
if (isResolvedValue(arithLeft) && isResolvedValue(arithRight)) {
|
|
3296
|
+
const folded = foldArithmetic(arithOp, arithLeft.value, arithRight.value);
|
|
3297
|
+
if (!isResolvedFieldReference(other)) {
|
|
3298
|
+
throw new Error(
|
|
3299
|
+
`${arithOp} with two values requires a field reference on the other side`
|
|
3300
|
+
);
|
|
1120
3301
|
}
|
|
1121
|
-
|
|
3302
|
+
const effectiveOperator = arithIsLeft
|
|
3303
|
+
? (MIRRORED_OPERATOR[operator] ?? operator)
|
|
3304
|
+
: operator;
|
|
3305
|
+
return buildComparisonFilter(other, effectiveOperator, folded);
|
|
1122
3306
|
}
|
|
1123
3307
|
|
|
1124
3308
|
if (!isResolvedValue(other)) {
|
|
1125
3309
|
throw new Error(
|
|
1126
|
-
|
|
3310
|
+
`${arithOp} operator with field references requires a value on the other side of the comparison`
|
|
1127
3311
|
);
|
|
1128
3312
|
}
|
|
1129
3313
|
|
|
1130
3314
|
let fieldRef: ResolvedFieldReference;
|
|
1131
|
-
let
|
|
3315
|
+
let constant: Value;
|
|
1132
3316
|
let fieldIsLeft: boolean;
|
|
1133
3317
|
|
|
1134
|
-
if (isResolvedFieldReference(
|
|
1135
|
-
fieldRef =
|
|
1136
|
-
|
|
3318
|
+
if (isResolvedFieldReference(arithLeft) && isResolvedValue(arithRight)) {
|
|
3319
|
+
fieldRef = arithLeft;
|
|
3320
|
+
constant = arithRight.value;
|
|
1137
3321
|
fieldIsLeft = true;
|
|
1138
|
-
} else if (
|
|
1139
|
-
|
|
1140
|
-
|
|
3322
|
+
} else if (
|
|
3323
|
+
isResolvedValue(arithLeft) &&
|
|
3324
|
+
isResolvedFieldReference(arithRight)
|
|
3325
|
+
) {
|
|
3326
|
+
fieldRef = arithRight;
|
|
3327
|
+
constant = arithLeft.value;
|
|
1141
3328
|
fieldIsLeft = false;
|
|
1142
3329
|
} else {
|
|
1143
3330
|
throw new Error(
|
|
1144
|
-
|
|
3331
|
+
`${arithOp} operator requires exactly one field reference and one value, or two values`
|
|
1145
3332
|
);
|
|
1146
3333
|
}
|
|
1147
3334
|
|
|
1148
|
-
|
|
3335
|
+
// The comparison operator was normalized arithmetic-side-first by the caller; if the
|
|
3336
|
+
// arithmetic was written on the RIGHT of the comparison, direction was already mirrored.
|
|
3337
|
+
let effectiveOperator = arithIsLeft
|
|
3338
|
+
? operator
|
|
3339
|
+
: (MIRRORED_OPERATOR[operator] ?? operator);
|
|
3340
|
+
|
|
3341
|
+
// String concatenation solving (eq/ne only).
|
|
3342
|
+
if (arithOp === "add" && typeof constant === "string") {
|
|
3343
|
+
if (effectiveOperator !== "eq" && effectiveOperator !== "ne") {
|
|
3344
|
+
throw new Error(
|
|
3345
|
+
`Operator ${effectiveOperator} is not supported with string concatenation`
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
const solvedValue = solveAdd(other.value, constant, fieldIsLeft);
|
|
3349
|
+
if (solvedValue === null) {
|
|
3350
|
+
if (effectiveOperator === "eq") return buildImpossibleFilter(fieldRef);
|
|
3351
|
+
return {};
|
|
3352
|
+
}
|
|
3353
|
+
return buildComparisonFilter(fieldRef, effectiveOperator, solvedValue);
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
if (typeof constant !== "number" || typeof other.value !== "number") {
|
|
3357
|
+
throw new Error(`${arithOp} comparison requires numeric operands`);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
if (
|
|
3361
|
+
arithOp === "add" &&
|
|
3362
|
+
(effectiveOperator === "eq" || effectiveOperator === "ne") &&
|
|
3363
|
+
(!Number.isInteger(constant) || !Number.isInteger(other.value))
|
|
3364
|
+
) {
|
|
1149
3365
|
throw new Error(
|
|
1150
|
-
|
|
3366
|
+
"Fractional addition equality cannot be translated safely: solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
|
|
1151
3367
|
);
|
|
1152
3368
|
}
|
|
1153
3369
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
3370
|
+
let solved: number;
|
|
3371
|
+
switch (arithOp) {
|
|
3372
|
+
case "add":
|
|
3373
|
+
solved = other.value - constant;
|
|
3374
|
+
break;
|
|
3375
|
+
case "sub":
|
|
3376
|
+
if (fieldIsLeft) {
|
|
3377
|
+
// f - c CMP v ⇔ f CMP v + c
|
|
3378
|
+
solved = other.value + constant;
|
|
3379
|
+
} else {
|
|
3380
|
+
// c - f CMP v ⇔ -f CMP v - c ⇔ f mirror(CMP) c - v
|
|
3381
|
+
solved = constant - other.value;
|
|
3382
|
+
effectiveOperator =
|
|
3383
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
3384
|
+
}
|
|
3385
|
+
break;
|
|
3386
|
+
case "mult":
|
|
3387
|
+
if (constant === 0) {
|
|
3388
|
+
throw new Error(
|
|
3389
|
+
"Multiplication by a constant zero must be folded by the Cerbos planner"
|
|
3390
|
+
);
|
|
3391
|
+
}
|
|
3392
|
+
solved = other.value / constant;
|
|
3393
|
+
if (constant < 0) {
|
|
3394
|
+
effectiveOperator =
|
|
3395
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
3396
|
+
}
|
|
3397
|
+
break;
|
|
3398
|
+
case "div":
|
|
3399
|
+
if (!fieldIsLeft) {
|
|
3400
|
+
throw new Error(
|
|
3401
|
+
"Division by a column is not supported: the comparison cannot be solved to a plain column filter"
|
|
3402
|
+
);
|
|
3403
|
+
}
|
|
3404
|
+
if (constant === 0) {
|
|
3405
|
+
throw new Error("Division by a constant zero is not supported");
|
|
3406
|
+
}
|
|
3407
|
+
solved = other.value * constant;
|
|
3408
|
+
if (constant < 0) {
|
|
3409
|
+
effectiveOperator =
|
|
3410
|
+
MIRRORED_OPERATOR[effectiveOperator] ?? effectiveOperator;
|
|
3411
|
+
}
|
|
3412
|
+
break;
|
|
3413
|
+
default:
|
|
3414
|
+
throw new Error(`Unsupported operator: ${arithOp}`);
|
|
1158
3415
|
}
|
|
1159
3416
|
|
|
1160
|
-
return
|
|
3417
|
+
return buildComparisonFilter(fieldRef, effectiveOperator, solved);
|
|
1161
3418
|
}
|
|
1162
3419
|
|
|
1163
|
-
function
|
|
1164
|
-
if (
|
|
3420
|
+
function foldArithmetic(operator: string, left: Value, right: Value): Value {
|
|
3421
|
+
if (
|
|
3422
|
+
operator === "add" &&
|
|
3423
|
+
(typeof left === "string" || typeof right === "string")
|
|
3424
|
+
) {
|
|
1165
3425
|
return String(left) + String(right);
|
|
1166
3426
|
}
|
|
1167
|
-
if (typeof left
|
|
1168
|
-
|
|
3427
|
+
if (typeof left !== "number" || typeof right !== "number") {
|
|
3428
|
+
throw new Error(`${operator} operator requires string or number operands`);
|
|
3429
|
+
}
|
|
3430
|
+
switch (operator) {
|
|
3431
|
+
case "add":
|
|
3432
|
+
return left + right;
|
|
3433
|
+
case "sub":
|
|
3434
|
+
return left - right;
|
|
3435
|
+
case "mult":
|
|
3436
|
+
return left * right;
|
|
3437
|
+
case "div":
|
|
3438
|
+
return left / right;
|
|
3439
|
+
default:
|
|
3440
|
+
throw new Error(`Unsupported operator: ${operator}`);
|
|
1169
3441
|
}
|
|
1170
|
-
throw new Error("add operator requires string or number operands");
|
|
1171
3442
|
}
|
|
1172
3443
|
|
|
1173
3444
|
function solveAdd(
|
|
@@ -1377,6 +3648,27 @@ function handleOverlapsOperator(
|
|
|
1377
3648
|
return validConditions[0]!;
|
|
1378
3649
|
}
|
|
1379
3650
|
|
|
3651
|
+
/**
|
|
3652
|
+
* Guards every hierarchy prefix that is about to become a Prisma `startsWith`.
|
|
3653
|
+
*
|
|
3654
|
+
* `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
|
|
3655
|
+
* match as wildcards instead of literally and widen the filter past what the PDP allows.
|
|
3656
|
+
* `\` is unsafe because PostgreSQL and MySQL treat it as the DEFAULT escape character with no
|
|
3657
|
+
* `ESCAPE` clause present, where SQLite has none at all — so the same prefix means different
|
|
3658
|
+
* things per provider. `[` is unsafe for a third reason: SQL Server opens a character class on
|
|
3659
|
+
* `[` even when an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly
|
|
3660
|
+
* rather than emit a filter that admits denied rows.
|
|
3661
|
+
*/
|
|
3662
|
+
function assertLikeSafePrefix(prefix: string): void {
|
|
3663
|
+
if (/[%_\[\\]/.test(prefix)) {
|
|
3664
|
+
throw new Error(
|
|
3665
|
+
"Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _, \\ or [): " +
|
|
3666
|
+
"Prisma emits LIKE without an ESCAPE clause, \\ is the default escape character on " +
|
|
3667
|
+
"PostgreSQL and MySQL, and [ opens a character class on SQL Server even with one"
|
|
3668
|
+
);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
1380
3672
|
function handleFieldOverlaps(
|
|
1381
3673
|
left: ResolvedHierarchy,
|
|
1382
3674
|
right: ResolvedHierarchy
|
|
@@ -1401,7 +3693,9 @@ function handleFieldOverlaps(
|
|
|
1401
3693
|
conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
|
|
1402
3694
|
}
|
|
1403
3695
|
conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
|
|
1404
|
-
|
|
3696
|
+
const prefix = otherRaw + delimiter;
|
|
3697
|
+
assertLikeSafePrefix(prefix);
|
|
3698
|
+
conditions.push(buildFieldFilter(field.fieldRef, "startsWith", prefix));
|
|
1405
3699
|
|
|
1406
3700
|
if (conditions.length === 1) return conditions[0]!;
|
|
1407
3701
|
return { OR: conditions };
|
|
@@ -1457,7 +3751,9 @@ function handleAncestorDescendantOperator(
|
|
|
1457
3751
|
const descendant = direction === "ancestor" ? right : left;
|
|
1458
3752
|
|
|
1459
3753
|
if (ancestor.type === "constant" && descendant.type === "field") {
|
|
1460
|
-
const prefix =
|
|
3754
|
+
const prefix =
|
|
3755
|
+
ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
|
|
3756
|
+
assertLikeSafePrefix(prefix);
|
|
1461
3757
|
return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
|
|
1462
3758
|
}
|
|
1463
3759
|
|