@cerbos/orm-prisma 4.0.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 +168 -12
- package/lib/index.d.ts +74 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +344 -90
- package/lib/index.js.map +1 -1
- package/package.json +10 -7
- package/src/adversarial.test.ts +948 -253
- package/src/corpus.ts +414 -0
- package/src/generated/prisma-adversarial/browser.ts +10 -0
- package/src/generated/prisma-adversarial/client.ts +10 -0
- package/src/generated/prisma-adversarial/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +178 -2
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +27 -1
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +124 -0
- package/src/generated/prisma-adversarial/models.ts +2 -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 → prisma-adversarial-pg}/commonInputTypes.ts +153 -71
- package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespace.ts +339 -371
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +56 -48
- 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/index.d.ts +3173 -44
- package/src/index.ts +508 -119
- 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/translator.test.ts +1586 -0
- package/src/generated/prisma/internal/class.ts +0 -274
- 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-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
|
@@ -124,6 +124,36 @@ export type MapperConfig = {
|
|
|
124
124
|
* deny-aligned. Declaring nullability here enables those guards.
|
|
125
125
|
*/
|
|
126
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;
|
|
127
157
|
relation?: {
|
|
128
158
|
name: string;
|
|
129
159
|
type: "one" | "many";
|
|
@@ -135,6 +165,29 @@ export type MapperConfig = {
|
|
|
135
165
|
model?: string;
|
|
136
166
|
field?: string;
|
|
137
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;
|
|
138
191
|
};
|
|
139
192
|
};
|
|
140
193
|
|
|
@@ -142,6 +195,22 @@ export type Mapper =
|
|
|
142
195
|
| Record<string, MapperConfig>
|
|
143
196
|
| ((key: string) => MapperConfig);
|
|
144
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
|
+
|
|
145
214
|
export interface QueryPlanToPrismaArgs {
|
|
146
215
|
queryPlan: PlanResourcesResponse;
|
|
147
216
|
mapper?: Mapper;
|
|
@@ -151,6 +220,11 @@ export interface QueryPlanToPrismaArgs {
|
|
|
151
220
|
* references and need the model name as their container.
|
|
152
221
|
*/
|
|
153
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;
|
|
154
228
|
}
|
|
155
229
|
|
|
156
230
|
export type QueryPlanToPrismaResult =
|
|
@@ -426,14 +500,21 @@ type RelationConfig = {
|
|
|
426
500
|
model?: string;
|
|
427
501
|
field?: string;
|
|
428
502
|
nestedMapper?: Record<string, MapperConfig>;
|
|
503
|
+
subqueryFilter?: PrismaFilter;
|
|
429
504
|
};
|
|
430
505
|
|
|
431
506
|
type ResolvedFieldReference = {
|
|
432
507
|
path: string[];
|
|
433
508
|
relations?: RelationConfig[];
|
|
434
509
|
valueType?: "dateTime";
|
|
510
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
435
511
|
};
|
|
436
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
|
+
|
|
437
518
|
type ResolvedValue = {
|
|
438
519
|
value: any;
|
|
439
520
|
};
|
|
@@ -450,38 +531,6 @@ function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue {
|
|
|
450
531
|
return "value" in operand;
|
|
451
532
|
}
|
|
452
533
|
|
|
453
|
-
function getNamedOperand(
|
|
454
|
-
operands: PlanExpressionOperand[],
|
|
455
|
-
message: string
|
|
456
|
-
): NamedOperand {
|
|
457
|
-
const operand = operands.find(isNamedOperand);
|
|
458
|
-
if (!operand) {
|
|
459
|
-
throw new Error(message);
|
|
460
|
-
}
|
|
461
|
-
return operand;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function getValueOperand(
|
|
465
|
-
operands: PlanExpressionOperand[],
|
|
466
|
-
message: string
|
|
467
|
-
): ValueOperand {
|
|
468
|
-
const operand = operands.find(isValueOperand);
|
|
469
|
-
if (!operand) {
|
|
470
|
-
throw new Error(message);
|
|
471
|
-
}
|
|
472
|
-
return operand;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function requireResolvedFieldReference(
|
|
476
|
-
operand: ResolvedOperand,
|
|
477
|
-
message: string
|
|
478
|
-
): ResolvedFieldReference {
|
|
479
|
-
if (!isResolvedFieldReference(operand)) {
|
|
480
|
-
throw new Error(message);
|
|
481
|
-
}
|
|
482
|
-
return operand;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
534
|
function requireResolvedValue(
|
|
486
535
|
operand: ResolvedOperand,
|
|
487
536
|
message: string
|
|
@@ -510,16 +559,43 @@ function buildFieldEqualsFilter(
|
|
|
510
559
|
return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
|
|
511
560
|
}
|
|
512
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
|
+
|
|
513
572
|
function buildFieldDirectOrInFilter(
|
|
514
573
|
fieldRef: ResolvedFieldReference,
|
|
515
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
|
|
516
594
|
): PrismaFilter {
|
|
517
595
|
const fieldName = getLeafField(fieldRef.path);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
: { [fieldName]: { in: values } };
|
|
522
|
-
return wrapRelations(fieldRef.relations, baseFilter);
|
|
596
|
+
return wrapRelations(fieldRef.relations, {
|
|
597
|
+
AND: [{ [fieldName]: { not: null } }, leaf],
|
|
598
|
+
});
|
|
523
599
|
}
|
|
524
600
|
|
|
525
601
|
function buildMembershipFilter(
|
|
@@ -527,12 +603,30 @@ function buildMembershipFilter(
|
|
|
527
603
|
values: Value[]
|
|
528
604
|
): PrismaFilter {
|
|
529
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
|
+
}
|
|
530
613
|
const filters: PrismaFilter[] = [];
|
|
531
614
|
|
|
532
615
|
if (nonNullValues.length > 0 || values.length === 0) {
|
|
533
|
-
|
|
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
|
+
);
|
|
534
628
|
}
|
|
535
|
-
if (
|
|
629
|
+
if (carriesNull) {
|
|
536
630
|
filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
|
|
537
631
|
}
|
|
538
632
|
|
|
@@ -563,6 +657,33 @@ type LambdaScope = {
|
|
|
563
657
|
};
|
|
564
658
|
let lambdaScopes: LambdaScope[] = [];
|
|
565
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
|
+
}
|
|
686
|
+
}
|
|
566
687
|
|
|
567
688
|
/**
|
|
568
689
|
* Converts a Cerbos query plan to a Prisma filter.
|
|
@@ -571,7 +692,9 @@ export function queryPlanToPrisma({
|
|
|
571
692
|
queryPlan,
|
|
572
693
|
mapper = {},
|
|
573
694
|
model,
|
|
695
|
+
nullAttributeRepresentation = "explicit",
|
|
574
696
|
}: QueryPlanToPrismaArgs): QueryPlanToPrismaResult {
|
|
697
|
+
nullRepresentation = nullAttributeRepresentation;
|
|
575
698
|
switch (queryPlan.kind) {
|
|
576
699
|
case PlanKind.ALWAYS_ALLOWED:
|
|
577
700
|
return { kind: PlanKind.ALWAYS_ALLOWED };
|
|
@@ -595,6 +718,18 @@ export function queryPlanToPrisma({
|
|
|
595
718
|
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
596
719
|
);
|
|
597
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
|
+
}
|
|
598
733
|
return {
|
|
599
734
|
kind: PlanKind.CONDITIONAL,
|
|
600
735
|
filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
|
|
@@ -1020,6 +1155,7 @@ function resolveFieldReference(
|
|
|
1020
1155
|
model: activeConfig.relation.model,
|
|
1021
1156
|
field: activeConfig.relation.field,
|
|
1022
1157
|
nestedMapper: fields,
|
|
1158
|
+
subqueryFilter: activeConfig.relation.subqueryFilter,
|
|
1023
1159
|
},
|
|
1024
1160
|
];
|
|
1025
1161
|
|
|
@@ -1046,6 +1182,7 @@ function resolveFieldReference(
|
|
|
1046
1182
|
model: nextConfig.relation.model,
|
|
1047
1183
|
field: nextConfig.relation.field,
|
|
1048
1184
|
nestedMapper: nextConfig.relation.fields,
|
|
1185
|
+
subqueryFilter: nextConfig.relation.subqueryFilter,
|
|
1049
1186
|
});
|
|
1050
1187
|
currentMapper = nextConfig.relation.fields || {};
|
|
1051
1188
|
currentParts = currentParts.slice(1);
|
|
@@ -1064,6 +1201,7 @@ function resolveFieldReference(
|
|
|
1064
1201
|
path: field ? [field] : remainingParts,
|
|
1065
1202
|
relations,
|
|
1066
1203
|
valueType: activeConfig.valueType,
|
|
1204
|
+
nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
|
|
1067
1205
|
};
|
|
1068
1206
|
}
|
|
1069
1207
|
|
|
@@ -1071,6 +1209,7 @@ function resolveFieldReference(
|
|
|
1071
1209
|
return {
|
|
1072
1210
|
path: [activeConfig?.field || reference],
|
|
1073
1211
|
valueType: activeConfig?.valueType,
|
|
1212
|
+
nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
|
|
1074
1213
|
};
|
|
1075
1214
|
}
|
|
1076
1215
|
|
|
@@ -1085,9 +1224,167 @@ function getPrismaRelationOperator(relation: {
|
|
|
1085
1224
|
return relation.type === "one" ? "is" : "some";
|
|
1086
1225
|
}
|
|
1087
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
|
+
|
|
1088
1260
|
/**
|
|
1089
1261
|
* Builds a nested relation filter for Prisma queries.
|
|
1090
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
|
+
|
|
1091
1388
|
function buildNestedRelationFilter(
|
|
1092
1389
|
relations: RelationConfig[],
|
|
1093
1390
|
fieldFilter: any
|
|
@@ -1114,7 +1411,7 @@ function buildNestedRelationFilter(
|
|
|
1114
1411
|
}
|
|
1115
1412
|
}
|
|
1116
1413
|
|
|
1117
|
-
currentFilter =
|
|
1414
|
+
currentFilter = relationFilter(relation, relationOperator, currentFilter);
|
|
1118
1415
|
}
|
|
1119
1416
|
|
|
1120
1417
|
return currentFilter;
|
|
@@ -1339,10 +1636,6 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
1339
1636
|
return handleStringOperator(operator, operands, mapper);
|
|
1340
1637
|
}
|
|
1341
1638
|
|
|
1342
|
-
case "isSet": {
|
|
1343
|
-
return handleIsSetOperator(operands, mapper);
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
1639
|
case "hasIntersection": {
|
|
1347
1640
|
return handleHasIntersectionOperator(operands, mapper);
|
|
1348
1641
|
}
|
|
@@ -1390,6 +1683,26 @@ function containsCollectionOperator(expr: PlanExpressionOperand): boolean {
|
|
|
1390
1683
|
return expr.operands.some(containsCollectionOperator);
|
|
1391
1684
|
}
|
|
1392
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
|
+
|
|
1393
1706
|
/**
|
|
1394
1707
|
* Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
|
|
1395
1708
|
* (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
|
|
@@ -1417,12 +1730,23 @@ function buildNegatedFilter(
|
|
|
1417
1730
|
if (!relations || relations.length === 0) {
|
|
1418
1731
|
return buildFieldEqualsFilter(fieldRef, false);
|
|
1419
1732
|
}
|
|
1420
|
-
return
|
|
1421
|
-
|
|
1422
|
-
|
|
1733
|
+
return negateRequiringHops(
|
|
1734
|
+
operand,
|
|
1735
|
+
buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1736
|
+
mapper
|
|
1737
|
+
);
|
|
1423
1738
|
}
|
|
1424
1739
|
|
|
1425
|
-
|
|
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
|
+
) {
|
|
1426
1750
|
switch (operand.operator) {
|
|
1427
1751
|
case "and":
|
|
1428
1752
|
return {
|
|
@@ -1453,9 +1777,11 @@ function buildNegatedFilter(
|
|
|
1453
1777
|
}
|
|
1454
1778
|
}
|
|
1455
1779
|
|
|
1456
|
-
return
|
|
1457
|
-
|
|
1458
|
-
|
|
1780
|
+
return negateRequiringHops(
|
|
1781
|
+
operand,
|
|
1782
|
+
buildPrismaFilterFromCerbosExpression(operand, mapper),
|
|
1783
|
+
mapper
|
|
1784
|
+
);
|
|
1459
1785
|
}
|
|
1460
1786
|
|
|
1461
1787
|
function getTernaryOperands(operands: PlanExpressionOperand[]): {
|
|
@@ -1515,6 +1841,18 @@ function buildBooleanBranchFilter(
|
|
|
1515
1841
|
return { kind: "constant", value: branch.value };
|
|
1516
1842
|
}
|
|
1517
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
|
+
*/
|
|
1518
1856
|
function buildFalseConditionFilter(
|
|
1519
1857
|
condition: PlanExpressionOperand,
|
|
1520
1858
|
mapper: Mapper
|
|
@@ -1523,26 +1861,7 @@ function buildFalseConditionFilter(
|
|
|
1523
1861
|
throw new Error("Constant ternary conditions must be folded before use");
|
|
1524
1862
|
}
|
|
1525
1863
|
|
|
1526
|
-
|
|
1527
|
-
const fieldRef = resolveFieldReference(condition.name, mapper);
|
|
1528
|
-
if (!fieldRef.relations || fieldRef.relations.length === 0) {
|
|
1529
|
-
return buildFieldEqualsFilter(fieldRef, false);
|
|
1530
|
-
}
|
|
1531
|
-
return {
|
|
1532
|
-
NOT: buildFieldEqualsFilter(fieldRef, true),
|
|
1533
|
-
};
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
if (isOperatorOperand(condition) && condition.operator === "not") {
|
|
1537
|
-
return buildPrismaFilterFromCerbosExpression(
|
|
1538
|
-
assertDefined(condition.operands[0], "not operator requires an operand"),
|
|
1539
|
-
mapper
|
|
1540
|
-
);
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
return {
|
|
1544
|
-
NOT: buildPrismaFilterFromCerbosExpression(condition, mapper),
|
|
1545
|
-
};
|
|
1864
|
+
return buildNegatedFilter(condition, mapper);
|
|
1546
1865
|
}
|
|
1547
1866
|
|
|
1548
1867
|
function buildGuardedTernaryFilter({
|
|
@@ -1886,7 +2205,7 @@ function handleSizeComparison(
|
|
|
1886
2205
|
}
|
|
1887
2206
|
|
|
1888
2207
|
const prismaOp = isNonEmpty ? "some" : "none";
|
|
1889
|
-
const leafFilter =
|
|
2208
|
+
const leafFilter = relationFilter(deepest, prismaOp, {});
|
|
1890
2209
|
|
|
1891
2210
|
if (relations.length === 1) {
|
|
1892
2211
|
return leafFilter;
|
|
@@ -2023,6 +2342,31 @@ function buildComparisonFilter(
|
|
|
2023
2342
|
`Unsupported operator: ${operator}`
|
|
2024
2343
|
);
|
|
2025
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
|
+
|
|
2026
2370
|
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
2027
2371
|
const fieldName = getLeafField(fieldRef.path);
|
|
2028
2372
|
const lower = Math.floor(value);
|
|
@@ -2135,11 +2479,55 @@ function buildFieldToFieldFilter(
|
|
|
2135
2479
|
|
|
2136
2480
|
const leftField = getLeafField(left.path);
|
|
2137
2481
|
const rightField = getLeafField(right.path);
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
+
}
|
|
2518
|
+
|
|
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 } } };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
return { [leftField]: { [prismaOperator]: fieldReference } };
|
|
2143
2531
|
}
|
|
2144
2532
|
|
|
2145
2533
|
/**
|
|
@@ -2220,10 +2608,18 @@ function handleStringOperator(
|
|
|
2220
2608
|
// Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
|
|
2221
2609
|
// so a needle containing % or _ would match as a pattern instead of literally (e.g.
|
|
2222
2610
|
// startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
|
|
2223
|
-
|
|
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)) {
|
|
2224
2619
|
throw new Error(
|
|
2225
|
-
`Cannot translate ${operator} with a needle containing LIKE metacharacters (
|
|
2226
|
-
"Prisma does not escape wildcards in string filters"
|
|
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"
|
|
2227
2623
|
);
|
|
2228
2624
|
}
|
|
2229
2625
|
const fieldName = getLeafField(receiver.path);
|
|
@@ -2282,30 +2678,6 @@ function handleStringOperator(
|
|
|
2282
2678
|
);
|
|
2283
2679
|
}
|
|
2284
2680
|
|
|
2285
|
-
/**
|
|
2286
|
-
* Helper function to handle "isSet" operator
|
|
2287
|
-
*/
|
|
2288
|
-
function handleIsSetOperator(
|
|
2289
|
-
operands: PlanExpressionOperand[],
|
|
2290
|
-
mapper: Mapper
|
|
2291
|
-
): PrismaFilter {
|
|
2292
|
-
const nameOperand = getNamedOperand(operands, "Name operand is undefined");
|
|
2293
|
-
const valueOperand = getValueOperand(operands, "Value operand is undefined");
|
|
2294
|
-
const fieldRef = requireResolvedFieldReference(
|
|
2295
|
-
resolveOperand(nameOperand, mapper),
|
|
2296
|
-
"Name operand must resolve to a field reference"
|
|
2297
|
-
);
|
|
2298
|
-
const resolvedValue = requireResolvedValue(
|
|
2299
|
-
resolveOperand(valueOperand, mapper),
|
|
2300
|
-
"Value operand must resolve to a value"
|
|
2301
|
-
);
|
|
2302
|
-
|
|
2303
|
-
const fieldName = getLeafField(fieldRef.path);
|
|
2304
|
-
return wrapRelations(fieldRef.relations, {
|
|
2305
|
-
[fieldName]: resolvedValue.value ? { not: null } : { equals: null },
|
|
2306
|
-
});
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
2681
|
/**
|
|
2310
2682
|
* Helper function to handle "hasIntersection" operator
|
|
2311
2683
|
*/
|
|
@@ -2635,7 +3007,7 @@ function wrapCollectionElementFilter(
|
|
|
2635
3007
|
parts.restRelations.length > 0
|
|
2636
3008
|
? buildNestedRelationFilter(parts.restRelations, elementFilter)
|
|
2637
3009
|
: elementFilter;
|
|
2638
|
-
return
|
|
3010
|
+
return relationFilter(parts.head, "some", inner);
|
|
2639
3011
|
}
|
|
2640
3012
|
|
|
2641
3013
|
function throwUnsupportedCollectionOperator(operator: string): never {
|
|
@@ -2680,7 +3052,7 @@ function handleCollectionOperator(
|
|
|
2680
3052
|
case "exists": {
|
|
2681
3053
|
// A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
|
|
2682
3054
|
// false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
|
|
2683
|
-
const filter =
|
|
3055
|
+
const filter = relationFilter(head, "some", filterValue);
|
|
2684
3056
|
const unknownElement = buildUnknownElementFilter(parts);
|
|
2685
3057
|
if (unknownElement !== undefined) {
|
|
2686
3058
|
recordNestedCollectionUnknownFilter({
|
|
@@ -2693,14 +3065,14 @@ function handleCollectionOperator(
|
|
|
2693
3065
|
return filter;
|
|
2694
3066
|
}
|
|
2695
3067
|
case "except":
|
|
2696
|
-
return
|
|
3068
|
+
return relationFilter(head, "some", { NOT: filterValue });
|
|
2697
3069
|
case "all": {
|
|
2698
3070
|
if (parts.restRelations.length > 0) {
|
|
2699
3071
|
throw new Error(
|
|
2700
3072
|
"all() over a multi-hop relation chain is not supported"
|
|
2701
3073
|
);
|
|
2702
3074
|
}
|
|
2703
|
-
const base =
|
|
3075
|
+
const base = relationFilter(head, "every", filterValue);
|
|
2704
3076
|
if (nullableFields.size === 0) {
|
|
2705
3077
|
return base;
|
|
2706
3078
|
}
|
|
@@ -2711,7 +3083,7 @@ function handleCollectionOperator(
|
|
|
2711
3083
|
return {
|
|
2712
3084
|
AND: [
|
|
2713
3085
|
base,
|
|
2714
|
-
|
|
3086
|
+
relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
|
|
2715
3087
|
],
|
|
2716
3088
|
};
|
|
2717
3089
|
}
|
|
@@ -2745,10 +3117,22 @@ function buildNegatedCollectionFilter(
|
|
|
2745
3117
|
|
|
2746
3118
|
const parts = buildCollectionLambdaParts(operator, operands, mapper);
|
|
2747
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] };
|
|
2748
3130
|
|
|
2749
3131
|
switch (operator) {
|
|
2750
3132
|
case "exists": {
|
|
2751
|
-
const base = {
|
|
3133
|
+
const base = requireLeadingHops({
|
|
3134
|
+
NOT: relationFilter(head, "some", filterValue),
|
|
3135
|
+
});
|
|
2752
3136
|
const unknownElement = buildUnknownElementFilter(parts);
|
|
2753
3137
|
if (unknownElement === undefined) {
|
|
2754
3138
|
return base;
|
|
@@ -2770,17 +3154,19 @@ function buildNegatedCollectionFilter(
|
|
|
2770
3154
|
case "all":
|
|
2771
3155
|
// !all is TRUE only with a definitive false witness, which also absorbs error
|
|
2772
3156
|
// elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
|
|
2773
|
-
return
|
|
3157
|
+
return relationFilter(head, "some", { NOT: filterValue });
|
|
2774
3158
|
case "except": {
|
|
2775
3159
|
// !except(c,P) = every element definitively matches P.
|
|
2776
|
-
const base =
|
|
3160
|
+
const base = requireLeadingHops(
|
|
3161
|
+
relationFilter(head, "none", { NOT: filterValue })
|
|
3162
|
+
);
|
|
2777
3163
|
if (nullableFields.size === 0) {
|
|
2778
3164
|
return base;
|
|
2779
3165
|
}
|
|
2780
3166
|
return {
|
|
2781
3167
|
AND: [
|
|
2782
3168
|
base,
|
|
2783
|
-
|
|
3169
|
+
relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
|
|
2784
3170
|
],
|
|
2785
3171
|
};
|
|
2786
3172
|
}
|
|
@@ -3267,15 +3653,18 @@ function handleOverlapsOperator(
|
|
|
3267
3653
|
*
|
|
3268
3654
|
* `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
|
|
3269
3655
|
* match as wildcards instead of literally and widen the filter past what the PDP allows.
|
|
3270
|
-
*
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
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.
|
|
3273
3661
|
*/
|
|
3274
3662
|
function assertLikeSafePrefix(prefix: string): void {
|
|
3275
|
-
if (/[%_\[]/.test(prefix)) {
|
|
3663
|
+
if (/[%_\[\\]/.test(prefix)) {
|
|
3276
3664
|
throw new Error(
|
|
3277
|
-
"Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
|
|
3278
|
-
"Prisma emits LIKE without an ESCAPE clause,
|
|
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"
|
|
3279
3668
|
);
|
|
3280
3669
|
}
|
|
3281
3670
|
}
|