@cerbos/orm-prisma 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +611 -460
- package/lib/collections.d.ts +30 -0
- package/lib/collections.d.ts.map +1 -0
- package/lib/collections.js +421 -0
- package/lib/collections.js.map +1 -0
- package/lib/comparison.d.ts +24 -0
- package/lib/comparison.d.ts.map +1 -0
- package/lib/comparison.js +731 -0
- package/lib/comparison.js.map +1 -0
- package/lib/errors.d.ts +13 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +17 -0
- package/lib/errors.js.map +1 -0
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.d.ts.map +1 -0
- package/lib/evaluate.js +118 -0
- package/lib/evaluate.js.map +1 -0
- package/lib/fields.d.ts +34 -0
- package/lib/fields.d.ts.map +1 -0
- package/lib/fields.js +253 -0
- package/lib/fields.js.map +1 -0
- package/lib/functions.d.ts +5 -0
- package/lib/functions.d.ts.map +1 -0
- package/lib/functions.js +408 -0
- package/lib/functions.js.map +1 -0
- package/lib/hierarchy.d.ts +6 -0
- package/lib/hierarchy.d.ts.map +1 -0
- package/lib/hierarchy.js +266 -0
- package/lib/hierarchy.js.map +1 -0
- package/lib/index.d.ts +91 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2301
- package/lib/index.js.map +1 -1
- package/lib/literals.d.ts +20 -0
- package/lib/literals.d.ts.map +1 -0
- package/lib/literals.js +290 -0
- package/lib/literals.js.map +1 -0
- package/lib/mapping.d.ts +95 -0
- package/lib/mapping.d.ts.map +1 -0
- package/lib/mapping.js +316 -0
- package/lib/mapping.js.map +1 -0
- package/lib/narrowing.d.ts +12 -0
- package/lib/narrowing.d.ts.map +1 -0
- package/lib/narrowing.js +32 -0
- package/lib/narrowing.js.map +1 -0
- package/lib/plan.d.ts +49 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +86 -0
- package/lib/plan.js.map +1 -0
- package/lib/presence.d.ts +25 -0
- package/lib/presence.d.ts.map +1 -0
- package/lib/presence.js +82 -0
- package/lib/presence.js.map +1 -0
- package/lib/regex.d.ts +33 -0
- package/lib/regex.d.ts.map +1 -0
- package/lib/regex.js +480 -0
- package/lib/regex.js.map +1 -0
- package/lib/relations.d.ts +58 -0
- package/lib/relations.d.ts.map +1 -0
- package/lib/relations.js +175 -0
- package/lib/relations.js.map +1 -0
- package/lib/rewrite.d.ts +36 -0
- package/lib/rewrite.d.ts.map +1 -0
- package/lib/rewrite.js +366 -0
- package/lib/rewrite.js.map +1 -0
- package/lib/solve.d.ts +20 -0
- package/lib/solve.d.ts.map +1 -0
- package/lib/solve.js +77 -0
- package/lib/solve.js.map +1 -0
- package/lib/strings.d.ts +12 -0
- package/lib/strings.d.ts.map +1 -0
- package/lib/strings.js +110 -0
- package/lib/strings.js.map +1 -0
- package/lib/ternary.d.ts +12 -0
- package/lib/ternary.d.ts.map +1 -0
- package/lib/ternary.js +192 -0
- package/lib/ternary.js.map +1 -0
- package/lib/timestamp.d.ts +45 -0
- package/lib/timestamp.d.ts.map +1 -0
- package/lib/timestamp.js +225 -0
- package/lib/timestamp.js.map +1 -0
- package/lib/translate.d.ts +20 -0
- package/lib/translate.d.ts.map +1 -0
- package/lib/translate.js +287 -0
- package/lib/translate.js.map +1 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +980 -0
- package/lib/types.js.map +1 -0
- package/lib/value.d.ts +8 -0
- package/lib/value.d.ts.map +1 -0
- package/lib/value.js +7 -0
- package/lib/value.js.map +1 -0
- package/package.json +31 -18
- package/src/adversarial.test.ts +488 -488
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +597 -0
- package/src/errors.ts +12 -0
- package/src/evaluate.ts +170 -0
- package/src/fields.ts +373 -0
- package/src/functions.ts +548 -0
- package/src/generated/prisma-adversarial/browser.ts +20 -0
- package/src/generated/prisma-adversarial/client.ts +20 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +44 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
- package/src/generated/prisma-adversarial/models.ts +4 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
- package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
- package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
- package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
- package/src/generated/prisma-adversarial-pg/client.ts +86 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
- package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
- package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -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/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -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 +20 -0
- package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +134 -3325
- package/src/literals.ts +363 -0
- package/src/mapping.ts +474 -0
- package/src/narrowing.ts +33 -0
- package/src/plan.ts +125 -0
- package/src/presence.ts +114 -0
- package/src/regex.ts +596 -0
- package/src/relations.ts +226 -0
- package/src/rewrite.ts +524 -0
- package/src/solve.ts +96 -0
- package/src/strings.ts +152 -0
- package/src/ternary.ts +359 -0
- package/src/test-setup.adversarial-url.ts +21 -0
- package/src/test-setup.adversarial.mysql.v6.ts +13 -0
- package/src/test-setup.adversarial.mysql.v7.ts +12 -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.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +766 -0
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
- package/src/generated/prisma/browser.ts +0 -59
- package/src/generated/prisma/internal/class.ts +0 -274
- package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
- 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 -2196
- 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 -13726
- package/src/index.test.ts +0 -8885
- package/src/test-setup.v6.ts +0 -5
- package/src/test-setup.v7.ts +0 -7
- /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// The collection macros (`exists`, `all`, `except`, and the refused `exists_one`/`filter`), in
|
|
2
|
+
// both polarities, plus `map`, `hasIntersection` and a bare `lambda`.
|
|
3
|
+
//
|
|
4
|
+
// A macro over a relation lowers to Prisma's `some`/`every`/`none`. Those collapse SQL's UNKNOWN
|
|
5
|
+
// to false at the EXISTS boundary, so every macro whose lambda body touches a nullable element
|
|
6
|
+
// column carries an explicit guard to keep CEL's error semantics (a NULL element column is a
|
|
7
|
+
// missing attribute, which Cerbos treats as deny).
|
|
8
|
+
|
|
9
|
+
import type { PlanExpressionOperand } from "@cerbos/core";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildMembershipFilter,
|
|
13
|
+
buildNullWitnessFilter,
|
|
14
|
+
} from "./fields";
|
|
15
|
+
import type { PrismaFilter } from "./index";
|
|
16
|
+
import {
|
|
17
|
+
currentScope,
|
|
18
|
+
enterLambdaScope,
|
|
19
|
+
getLeafField,
|
|
20
|
+
resolveFieldReference,
|
|
21
|
+
} from "./mapping";
|
|
22
|
+
import type {
|
|
23
|
+
LambdaScope,
|
|
24
|
+
RelationConfig,
|
|
25
|
+
TranslationContext,
|
|
26
|
+
} from "./mapping";
|
|
27
|
+
import {
|
|
28
|
+
assertDefined,
|
|
29
|
+
isNamedOperand,
|
|
30
|
+
isOperatorOperand,
|
|
31
|
+
isValueOperand,
|
|
32
|
+
normalizeBinaryOperands,
|
|
33
|
+
} from "./plan";
|
|
34
|
+
import type { ValueOperand } from "./plan";
|
|
35
|
+
import {
|
|
36
|
+
buildLeadingHopsExistFilter,
|
|
37
|
+
negateFilter,
|
|
38
|
+
relationFilter,
|
|
39
|
+
relationOperator,
|
|
40
|
+
wrapInRelations,
|
|
41
|
+
} from "./relations";
|
|
42
|
+
import { substituteLambdaVariable } from "./rewrite";
|
|
43
|
+
import {
|
|
44
|
+
buildNegatedFilter,
|
|
45
|
+
buildPrismaFilterFromCerbosExpression,
|
|
46
|
+
} from "./translate";
|
|
47
|
+
import { UnsupportedQueryPlanError } from "./errors";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Refuses a two-variable comprehension (`list.exists(i, v, ...)`, `map.exists(k, v, ...)`), whose
|
|
51
|
+
* lambda binds the element's index or key as well as the element. A Prisma relation filter tests
|
|
52
|
+
* related rows, which carry no position, and enumerates no map keys, so the first variable has
|
|
53
|
+
* nothing to bind to.
|
|
54
|
+
*/
|
|
55
|
+
export function assertSingleVariableLambda(
|
|
56
|
+
operator: string,
|
|
57
|
+
lambda: { operator: string; operands: PlanExpressionOperand[] }
|
|
58
|
+
): void {
|
|
59
|
+
if (lambda.operator === "lambda" && lambda.operands.length > 2) {
|
|
60
|
+
throw new UnsupportedQueryPlanError(
|
|
61
|
+
`${operator} with a two-variable lambda: the first variable binds the element's list ` +
|
|
62
|
+
"index or map key, and a Prisma relation filter tests related rows, which carry no " +
|
|
63
|
+
"position, and enumerates no map keys"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function newLambdaScope(
|
|
69
|
+
variableName: string,
|
|
70
|
+
relations: RelationConfig[]
|
|
71
|
+
): LambdaScope {
|
|
72
|
+
return {
|
|
73
|
+
variableName,
|
|
74
|
+
relationModel: relations[relations.length - 1]?.model,
|
|
75
|
+
nullableFields: new Set(),
|
|
76
|
+
unknownFilters: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fold a collection macro whose collection operand is a literal value list.
|
|
82
|
+
*
|
|
83
|
+
* The planner emits this shape when a known-value collection (typically a
|
|
84
|
+
* folded principal attribute) has more than 10 elements — at 10 or fewer it
|
|
85
|
+
* unrolls `exists`/`all` into an or/and chain itself (cerbos/cerbos#2570,
|
|
86
|
+
* cerbos/cerbos#2817; `maxItems = 10` in the planner's struct matcher). Apply
|
|
87
|
+
* the same fold here so the translated filter does not depend on which side of
|
|
88
|
+
* that threshold the collection lands: substitute each element into the lambda
|
|
89
|
+
* body and combine the per-element filters with OR (`exists`) or AND (`all`).
|
|
90
|
+
*
|
|
91
|
+
* An empty list yields `{OR: []}` / `{AND: []}`, which Prisma evaluates to
|
|
92
|
+
* match-nothing / match-everything — exactly CEL's `exists`/`all` semantics
|
|
93
|
+
* over an empty collection.
|
|
94
|
+
*/
|
|
95
|
+
function foldKnownValueCollection(
|
|
96
|
+
operator: string,
|
|
97
|
+
collection: ValueOperand,
|
|
98
|
+
lambda: PlanExpressionOperand,
|
|
99
|
+
context: TranslationContext,
|
|
100
|
+
negated: boolean
|
|
101
|
+
): PrismaFilter {
|
|
102
|
+
if (operator !== "exists" && operator !== "all") {
|
|
103
|
+
throw new UnsupportedQueryPlanError(
|
|
104
|
+
`${operator} over a literal collection value is not supported. ` +
|
|
105
|
+
"Only exists() and all() can be folded into a flat filter."
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const elements = collection.value;
|
|
110
|
+
|
|
111
|
+
if (!Array.isArray(elements)) {
|
|
112
|
+
throw new UnsupportedQueryPlanError(
|
|
113
|
+
`${operator} over a literal collection requires a list value`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
|
|
118
|
+
throw new UnsupportedQueryPlanError(
|
|
119
|
+
`Second operand of ${operator} must be a lambda expression`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (lambda.operands.length !== 2) {
|
|
124
|
+
throw new UnsupportedQueryPlanError(
|
|
125
|
+
`${operator} over a literal collection supports single-variable lambdas only`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const body = assertDefined(
|
|
130
|
+
lambda.operands[0],
|
|
131
|
+
"Lambda expression must provide a condition"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const variable = assertDefined(
|
|
135
|
+
lambda.operands[1],
|
|
136
|
+
"Lambda variable must have a name"
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (!isNamedOperand(variable)) {
|
|
140
|
+
throw new UnsupportedQueryPlanError("Lambda variable must have a name");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const filters = elements.map((element) => {
|
|
144
|
+
const substituted = substituteLambdaVariable(body, variable.name, element);
|
|
145
|
+
|
|
146
|
+
return negated
|
|
147
|
+
? buildNegatedFilter(substituted, context)
|
|
148
|
+
: buildPrismaFilterFromCerbosExpression(substituted, context);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const combinesWithOr = operator === "exists" ? !negated : negated;
|
|
152
|
+
|
|
153
|
+
return combinesWithOr ? { OR: filters } : { AND: filters };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type CollectionLambdaParts = {
|
|
157
|
+
head: RelationConfig;
|
|
158
|
+
restRelations: RelationConfig[];
|
|
159
|
+
/** Element predicate, already wrapped through any relations beyond the first. */
|
|
160
|
+
filterValue: PrismaFilter;
|
|
161
|
+
/** Element predicate on the elements at the end of the chain, unwrapped. */
|
|
162
|
+
elementFilter: PrismaFilter;
|
|
163
|
+
/** Nullable element columns referenced by the lambda body (for 3VL guards). */
|
|
164
|
+
nullableFields: Set<string>;
|
|
165
|
+
/** Element-level predicates under which a nested collection expression is UNKNOWN. */
|
|
166
|
+
unknownFilters: PrismaFilter[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function buildCollectionLambdaParts(
|
|
170
|
+
operator: string,
|
|
171
|
+
operands: PlanExpressionOperand[],
|
|
172
|
+
context: TranslationContext
|
|
173
|
+
): CollectionLambdaParts {
|
|
174
|
+
if (operands.length !== 2) {
|
|
175
|
+
throw new UnsupportedQueryPlanError(`${operator} requires exactly two operands`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const collection = assertDefined(
|
|
179
|
+
operands[0],
|
|
180
|
+
`${operator} requires a collection operand`
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const lambda = assertDefined(
|
|
184
|
+
operands[1],
|
|
185
|
+
`${operator} requires a lambda operand`
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!isNamedOperand(collection)) {
|
|
189
|
+
throw new UnsupportedQueryPlanError(
|
|
190
|
+
`First operand of ${operator} must be a collection reference`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!isOperatorOperand(lambda)) {
|
|
195
|
+
throw new UnsupportedQueryPlanError(
|
|
196
|
+
`Second operand of ${operator} must be a lambda expression`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
assertSingleVariableLambda(operator, lambda);
|
|
201
|
+
|
|
202
|
+
const variable = assertDefined(
|
|
203
|
+
lambda.operands[1],
|
|
204
|
+
"Lambda variable must have a name"
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!isNamedOperand(variable)) {
|
|
208
|
+
throw new UnsupportedQueryPlanError("Lambda variable must have a name");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { relations } = resolveFieldReference(collection.name, context);
|
|
212
|
+
|
|
213
|
+
if (!relations || relations.length === 0) {
|
|
214
|
+
throw new UnsupportedQueryPlanError(`${operator} operator requires a relation mapping`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (relations[relations.length - 1]!.type === "one") {
|
|
218
|
+
// CEL reads a to-one relation as a map, and a macro over a map ranges over its KEYS — the
|
|
219
|
+
// attribute names present on the related row — which no Prisma relation filter enumerates.
|
|
220
|
+
throw new UnsupportedQueryPlanError(
|
|
221
|
+
`${operator} over ${collection.name}: it is a to-one relation, which CEL reads as a map ` +
|
|
222
|
+
"whose keys (the attribute names present on the related row) the macro ranges over, " +
|
|
223
|
+
"and a Prisma relation filter tests rows, not attribute names"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const head = relations[0]!;
|
|
228
|
+
const restRelations = relations.slice(1);
|
|
229
|
+
|
|
230
|
+
const lambdaConditionOperand = assertDefined(
|
|
231
|
+
lambda.operands[0],
|
|
232
|
+
"Lambda expression must provide a condition"
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const scope = newLambdaScope(variable.name, relations);
|
|
236
|
+
|
|
237
|
+
const lambdaCondition = buildPrismaFilterFromCerbosExpression(
|
|
238
|
+
lambdaConditionOperand,
|
|
239
|
+
enterLambdaScope(context, collection.name, scope)
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
head,
|
|
244
|
+
restRelations,
|
|
245
|
+
// Chained collection reference (e.g. R.attr.a.b): the lambda's elements live at the END
|
|
246
|
+
// of the chain, so the element predicate must join through every intermediate hop.
|
|
247
|
+
filterValue: wrapInRelations(restRelations, lambdaCondition),
|
|
248
|
+
elementFilter: lambdaCondition,
|
|
249
|
+
nullableFields: scope.nullableFields,
|
|
250
|
+
unknownFilters: scope.unknownFilters,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildUnknownElementFilter(
|
|
255
|
+
parts: CollectionLambdaParts
|
|
256
|
+
): PrismaFilter | undefined {
|
|
257
|
+
const filters = [...parts.unknownFilters];
|
|
258
|
+
|
|
259
|
+
if (parts.nullableFields.size > 0) {
|
|
260
|
+
filters.push(buildNullWitnessFilter(parts.nullableFields));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (filters.length === 0) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return filters.length === 1 ? filters[0] : { OR: filters };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Wraps an element-level filter through the full relation chain (head + rest). */
|
|
271
|
+
function wrapCollectionElementFilter(
|
|
272
|
+
parts: CollectionLambdaParts,
|
|
273
|
+
elementFilter: PrismaFilter
|
|
274
|
+
): PrismaFilter {
|
|
275
|
+
return relationFilter(
|
|
276
|
+
parts.head,
|
|
277
|
+
"some",
|
|
278
|
+
wrapInRelations(parts.restRelations, elementFilter)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* all() over a chain (`R.attr.a.b`): the elements are the flattened tail, so the predicate must
|
|
284
|
+
* hold at the end of every path (`every` through each to-many hop, `is` through a to-one one),
|
|
285
|
+
* and the hops must exist — an absent parent is a missing-path error, never a vacuous TRUE.
|
|
286
|
+
*/
|
|
287
|
+
function positiveChainAllFilter(parts: CollectionLambdaParts): PrismaFilter {
|
|
288
|
+
let universal = parts.elementFilter;
|
|
289
|
+
|
|
290
|
+
for (const relation of [...parts.restRelations].reverse()) {
|
|
291
|
+
universal = relationFilter(
|
|
292
|
+
relation,
|
|
293
|
+
relation.type === "one" ? "is" : "every",
|
|
294
|
+
universal
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const filters = [
|
|
299
|
+
buildLeadingHopsExistFilter(parts.head, parts.restRelations)!,
|
|
300
|
+
relationFilter(parts.head, "every", universal),
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
if (parts.nullableFields.size > 0) {
|
|
304
|
+
// As for a direct relation: an element whose referenced column is NULL errors in CEL.
|
|
305
|
+
filters.push({
|
|
306
|
+
NOT: wrapCollectionElementFilter(parts, buildNullWitnessFilter(parts.nullableFields)),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { AND: filters };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `relation: { none: <a nullable element column is NULL> }`, ANDed onto `base` when needed. */
|
|
314
|
+
function excludeNullElements(
|
|
315
|
+
base: PrismaFilter,
|
|
316
|
+
parts: CollectionLambdaParts
|
|
317
|
+
): PrismaFilter {
|
|
318
|
+
if (parts.nullableFields.size === 0) {
|
|
319
|
+
return base;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
AND: [
|
|
324
|
+
base,
|
|
325
|
+
relationFilter(parts.head, "none", buildNullWitnessFilter(parts.nullableFields)),
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function positiveCollectionFilter(
|
|
331
|
+
operator: string,
|
|
332
|
+
parts: CollectionLambdaParts,
|
|
333
|
+
context: TranslationContext
|
|
334
|
+
): PrismaFilter {
|
|
335
|
+
const { head, filterValue } = parts;
|
|
336
|
+
|
|
337
|
+
switch (operator) {
|
|
338
|
+
case "exists": {
|
|
339
|
+
// A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
|
|
340
|
+
// false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
|
|
341
|
+
// An enclosing macro still needs to know when this one is UNKNOWN, so it is recorded.
|
|
342
|
+
const filter = relationFilter(head, "some", filterValue);
|
|
343
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
344
|
+
|
|
345
|
+
if (unknownElement !== undefined) {
|
|
346
|
+
currentScope(context)?.unknownFilters.push({
|
|
347
|
+
AND: [
|
|
348
|
+
{ NOT: filter },
|
|
349
|
+
wrapCollectionElementFilter(parts, unknownElement),
|
|
350
|
+
],
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return filter;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case "except":
|
|
358
|
+
return relationFilter(head, "some", negateFilter(filterValue));
|
|
359
|
+
case "all":
|
|
360
|
+
if (parts.restRelations.length > 0) {
|
|
361
|
+
return positiveChainAllFilter(parts);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// CEL all() errors when any element evaluation errors without a false witness; SQL
|
|
365
|
+
// `every` would treat those elements as vacuously passing. Exclude rows holding any
|
|
366
|
+
// element whose referenced nullable column is NULL. (`every` already rejects rows
|
|
367
|
+
// with a definitive false witness, matching error absorption.)
|
|
368
|
+
return excludeNullElements(relationFilter(head, "every", filterValue), parts);
|
|
369
|
+
default:
|
|
370
|
+
throw new UnsupportedQueryPlanError(`Unexpected operator: ${operator}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The filter for a NEGATED collection operator, encoding CEL's error semantics (see
|
|
376
|
+
* buildNegatedFilter).
|
|
377
|
+
*/
|
|
378
|
+
function negatedCollectionFilter(
|
|
379
|
+
operator: string,
|
|
380
|
+
parts: CollectionLambdaParts
|
|
381
|
+
): PrismaFilter {
|
|
382
|
+
const { head, filterValue } = parts;
|
|
383
|
+
|
|
384
|
+
// An absent to-one parent must stay denied under negation rather than satisfying it
|
|
385
|
+
// vacuously (#309).
|
|
386
|
+
const leadingHopsExist = buildLeadingHopsExistFilter(
|
|
387
|
+
head,
|
|
388
|
+
parts.restRelations
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const requireLeadingHops = (filter: PrismaFilter): PrismaFilter =>
|
|
392
|
+
leadingHopsExist === undefined
|
|
393
|
+
? filter
|
|
394
|
+
: { AND: [leadingHopsExist, filter] };
|
|
395
|
+
|
|
396
|
+
switch (operator) {
|
|
397
|
+
case "exists": {
|
|
398
|
+
const base = requireLeadingHops({
|
|
399
|
+
NOT: relationFilter(head, "some", filterValue),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const unknownElement = buildUnknownElementFilter(parts);
|
|
403
|
+
|
|
404
|
+
if (unknownElement === undefined) {
|
|
405
|
+
return base;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// !exists is TRUE only when every element is definitively false: no P-match AND no
|
|
409
|
+
// element whose evaluation is UNKNOWN (NULL column = missing attribute = CEL error).
|
|
410
|
+
return {
|
|
411
|
+
AND: [base, { NOT: wrapCollectionElementFilter(parts, unknownElement) }],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case "all":
|
|
416
|
+
// !all is TRUE only with a definitive false witness, which also absorbs error
|
|
417
|
+
// elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN). Over a chain
|
|
418
|
+
// the witness is an element at the END of it, so the negation sits inside every hop:
|
|
419
|
+
// negating the some-wrapped chain instead would admit a hop with no elements at all.
|
|
420
|
+
return wrapCollectionElementFilter(parts, negateFilter(parts.elementFilter));
|
|
421
|
+
case "except":
|
|
422
|
+
// !except(c,P) = every element definitively matches P.
|
|
423
|
+
return excludeNullElements(
|
|
424
|
+
requireLeadingHops(relationFilter(head, "none", negateFilter(filterValue))),
|
|
425
|
+
parts
|
|
426
|
+
);
|
|
427
|
+
default:
|
|
428
|
+
throw new UnsupportedQueryPlanError(`Unexpected operator: ${operator}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function translateCollectionMacro(
|
|
433
|
+
operator: string,
|
|
434
|
+
operands: PlanExpressionOperand[],
|
|
435
|
+
context: TranslationContext,
|
|
436
|
+
negated: boolean
|
|
437
|
+
): PrismaFilter {
|
|
438
|
+
// A literal value list arrives when the planner could not unroll a macro over a known
|
|
439
|
+
// collection (more than 10 elements). Fold it before attempting relation resolution because
|
|
440
|
+
// there is no relation mapping for a literal.
|
|
441
|
+
const [collection, lambda] = operands;
|
|
442
|
+
|
|
443
|
+
if (
|
|
444
|
+
operands.length === 2 &&
|
|
445
|
+
collection !== undefined &&
|
|
446
|
+
lambda !== undefined &&
|
|
447
|
+
isValueOperand(collection)
|
|
448
|
+
) {
|
|
449
|
+
return foldKnownValueCollection(operator, collection, lambda, context, negated);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (operator === "exists_one") {
|
|
453
|
+
throw new UnsupportedQueryPlanError(
|
|
454
|
+
"exists_one requires counting matching elements, which Prisma where-filters cannot express"
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (operator === "filter") {
|
|
459
|
+
throw new UnsupportedQueryPlanError(
|
|
460
|
+
"The filter() collection operator returns a list, not a boolean. " +
|
|
461
|
+
"It cannot be used as a standalone condition. " +
|
|
462
|
+
"Use exists() or combine filter() with size() instead."
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const parts = buildCollectionLambdaParts(operator, operands, context);
|
|
467
|
+
|
|
468
|
+
return negated
|
|
469
|
+
? negatedCollectionFilter(operator, parts)
|
|
470
|
+
: positiveCollectionFilter(operator, parts, context);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A collection macro (exists, all, except, exists_one, filter) in positive position. */
|
|
474
|
+
export function handleCollectionOperator(
|
|
475
|
+
operator: string,
|
|
476
|
+
operands: PlanExpressionOperand[],
|
|
477
|
+
context: TranslationContext
|
|
478
|
+
): PrismaFilter {
|
|
479
|
+
return translateCollectionMacro(operator, operands, context, false);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The same macro under `!`, which is not the plain `NOT` of the positive filter. */
|
|
483
|
+
export function buildNegatedCollectionFilter(
|
|
484
|
+
operator: string,
|
|
485
|
+
operands: PlanExpressionOperand[],
|
|
486
|
+
context: TranslationContext
|
|
487
|
+
): PrismaFilter {
|
|
488
|
+
return translateCollectionMacro(operator, operands, context, true);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Helper function to handle "map" operator
|
|
493
|
+
*/
|
|
494
|
+
export function handleMapOperator(
|
|
495
|
+
operands: PlanExpressionOperand[],
|
|
496
|
+
context: TranslationContext
|
|
497
|
+
): PrismaFilter {
|
|
498
|
+
if (operands.length !== 2) {
|
|
499
|
+
throw new UnsupportedQueryPlanError("map requires exactly two operands");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const collection = assertDefined(
|
|
503
|
+
operands[0],
|
|
504
|
+
"map requires a collection operand"
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const lambda = assertDefined(operands[1], "map requires a lambda operand");
|
|
508
|
+
|
|
509
|
+
if (!isNamedOperand(collection)) {
|
|
510
|
+
throw new UnsupportedQueryPlanError("First operand of map must be a collection reference");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") {
|
|
514
|
+
throw new UnsupportedQueryPlanError("Second operand of map must be a lambda expression");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
assertSingleVariableLambda("map", lambda);
|
|
518
|
+
|
|
519
|
+
const projection = assertDefined(
|
|
520
|
+
lambda.operands[0],
|
|
521
|
+
"Map lambda expression must provide a projection"
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const variable = assertDefined(
|
|
525
|
+
lambda.operands[1],
|
|
526
|
+
"Map lambda expression must provide a variable"
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (!isNamedOperand(projection) || !isNamedOperand(variable)) {
|
|
530
|
+
throw new UnsupportedQueryPlanError("Invalid map lambda expression structure");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const { relations } = resolveFieldReference(collection.name, context);
|
|
534
|
+
|
|
535
|
+
if (!relations || relations.length === 0) {
|
|
536
|
+
throw new UnsupportedQueryPlanError("map operator requires a relation mapping");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const scopedContext = enterLambdaScope(
|
|
540
|
+
context,
|
|
541
|
+
collection.name,
|
|
542
|
+
newLambdaScope(variable.name, relations)
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const resolved = resolveFieldReference(projection.name, scopedContext);
|
|
546
|
+
const fieldName = getLeafField(resolved.path);
|
|
547
|
+
const lastRelation = relations[relations.length - 1]!;
|
|
548
|
+
|
|
549
|
+
return wrapInRelations(relations, {
|
|
550
|
+
[relationOperator(lastRelation)]: {
|
|
551
|
+
select: { [fieldName]: true },
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Helper function to handle "hasIntersection" operator
|
|
558
|
+
*/
|
|
559
|
+
export function handleHasIntersectionOperator(
|
|
560
|
+
operands: PlanExpressionOperand[],
|
|
561
|
+
context: TranslationContext
|
|
562
|
+
): PrismaFilter {
|
|
563
|
+
if (operands.length !== 2) {
|
|
564
|
+
throw new UnsupportedQueryPlanError("hasIntersection requires exactly two operands");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Intersection is symmetric, and the planner preserves policy source order — e.g.
|
|
568
|
+
// `hasIntersection(["a"], R.attr.tags)` puts the constant list FIRST. Normalize to
|
|
569
|
+
// field/map-first (see #256).
|
|
570
|
+
({ operands } = normalizeBinaryOperands("hasIntersection", operands));
|
|
571
|
+
|
|
572
|
+
const leftOperand = assertDefined(
|
|
573
|
+
operands[0],
|
|
574
|
+
"hasIntersection requires a left operand"
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const rightOperand = assertDefined(
|
|
578
|
+
operands[1],
|
|
579
|
+
"hasIntersection requires a right operand"
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
if (isOperatorOperand(leftOperand) && leftOperand.operator === "map") {
|
|
583
|
+
return handleMapIntersection(leftOperand.operands, rightOperand, context);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!isNamedOperand(leftOperand)) {
|
|
587
|
+
throw new UnsupportedQueryPlanError(
|
|
588
|
+
"First operand of hasIntersection must be a field reference or map expression"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (!isValueOperand(rightOperand)) {
|
|
593
|
+
throw new UnsupportedQueryPlanError("Second operand of hasIntersection must be a value");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const fieldRef = resolveFieldReference(leftOperand.name, context);
|
|
597
|
+
|
|
598
|
+
if (!Array.isArray(rightOperand.value)) {
|
|
599
|
+
throw new UnsupportedQueryPlanError("hasIntersection requires an array value");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (rightOperand.value.length === 0 && fieldRef.relations?.length === 1) {
|
|
603
|
+
return { OR: [] };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (fieldRef.relations && fieldRef.relations.length > 0) {
|
|
607
|
+
// The whole reference, not just its path: the mapped valueType is what refuses a literal of
|
|
608
|
+
// another type (`hasIntersection(numberList, ["2", 3])`), which CEL's heterogeneous equality
|
|
609
|
+
// never matches and which a store would otherwise be handed to coerce.
|
|
610
|
+
return buildMembershipFilter(context, fieldRef, rightOperand.value);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (fieldRef.valueType !== undefined) {
|
|
614
|
+
throw new UnsupportedQueryPlanError(
|
|
615
|
+
"hasIntersection over a scalar attribute is a CEL error, which a Prisma filter cannot express"
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return { [getLeafField(fieldRef.path)]: { some: rightOperand.value } };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** `hasIntersection(collection.map(x, x.field), [literals])`. */
|
|
623
|
+
function handleMapIntersection(
|
|
624
|
+
mapOperands: PlanExpressionOperand[],
|
|
625
|
+
rightOperand: PlanExpressionOperand,
|
|
626
|
+
context: TranslationContext
|
|
627
|
+
): PrismaFilter {
|
|
628
|
+
if (!isValueOperand(rightOperand)) {
|
|
629
|
+
throw new UnsupportedQueryPlanError("Second operand of hasIntersection must be a value");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const collection = assertDefined(
|
|
633
|
+
mapOperands[0],
|
|
634
|
+
"Map expression must include a collection reference"
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const lambda = assertDefined(
|
|
638
|
+
mapOperands[1],
|
|
639
|
+
"Map expression must include a lambda expression"
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
if (!isNamedOperand(collection)) {
|
|
643
|
+
throw new UnsupportedQueryPlanError("First operand of map must be a collection reference");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!isOperatorOperand(lambda)) {
|
|
647
|
+
throw new UnsupportedQueryPlanError("Lambda expression must have operands");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
assertSingleVariableLambda("map", lambda);
|
|
651
|
+
|
|
652
|
+
const variable = assertDefined(
|
|
653
|
+
lambda.operands[1],
|
|
654
|
+
"Lambda variable must have a name"
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
if (!isNamedOperand(variable)) {
|
|
658
|
+
throw new UnsupportedQueryPlanError("Lambda variable must have a name");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const { relations } = resolveFieldReference(collection.name, context);
|
|
662
|
+
|
|
663
|
+
if (!relations || relations.length === 0) {
|
|
664
|
+
throw new UnsupportedQueryPlanError("Map operation requires relations");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const projection = assertDefined(
|
|
668
|
+
lambda.operands[0],
|
|
669
|
+
"Invalid map lambda expression structure"
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
if (!isNamedOperand(projection)) {
|
|
673
|
+
throw new UnsupportedQueryPlanError("Invalid map lambda expression structure");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Resolve the projection through the scoped mapper, collecting nullable element columns.
|
|
677
|
+
const scope = newLambdaScope(variable.name, relations);
|
|
678
|
+
|
|
679
|
+
const resolved = resolveFieldReference(
|
|
680
|
+
projection.name,
|
|
681
|
+
enterLambdaScope(context, collection.name, scope)
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
const fieldName = getLeafField(resolved.path);
|
|
685
|
+
|
|
686
|
+
if (!Array.isArray(rightOperand.value))
|
|
687
|
+
throw new UnsupportedQueryPlanError("hasIntersection requires a literal list");
|
|
688
|
+
|
|
689
|
+
const base = wrapInRelations(
|
|
690
|
+
relations,
|
|
691
|
+
buildMembershipFilter(
|
|
692
|
+
context,
|
|
693
|
+
{ ...resolved, path: [fieldName], relations: [] },
|
|
694
|
+
rightOperand.value
|
|
695
|
+
)
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
if (scope.nullableFields.size === 0) {
|
|
699
|
+
return base;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// CEL map() errors if ANY element is missing the projected attribute — even alongside a
|
|
703
|
+
// matching element — and Cerbos treats that error as deny. Exclude rows holding an
|
|
704
|
+
// element whose projected (nullable) column is NULL.
|
|
705
|
+
return {
|
|
706
|
+
AND: [
|
|
707
|
+
base,
|
|
708
|
+
{ NOT: wrapInRelations(relations, buildNullWitnessFilter(scope.nullableFields)) },
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Helper function to handle "lambda" operator
|
|
715
|
+
*/
|
|
716
|
+
export function handleLambdaOperator(
|
|
717
|
+
operands: PlanExpressionOperand[],
|
|
718
|
+
context: TranslationContext
|
|
719
|
+
): PrismaFilter {
|
|
720
|
+
const condition = assertDefined(
|
|
721
|
+
operands[0],
|
|
722
|
+
"Lambda requires a condition operand"
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
const variable = assertDefined(
|
|
726
|
+
operands[1],
|
|
727
|
+
"Lambda requires a variable operand"
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
if (!isNamedOperand(variable)) {
|
|
731
|
+
throw new UnsupportedQueryPlanError("Lambda variable must have a name");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return buildPrismaFilterFromCerbosExpression(condition, {
|
|
735
|
+
...context,
|
|
736
|
+
mapper: (key: string) => ({ field: key.replace(`${variable.name}.`, "") }),
|
|
737
|
+
});
|
|
738
|
+
}
|