@cerbos/orm-prisma 3.2.0 → 3.3.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 +11 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +404 -104
- package/lib/index.js.map +1 -1
- package/package.json +13 -6
- package/src/generated/prisma/client.ts +3 -1
- package/src/generated/prisma/internal/class.ts +17 -5
- package/src/generated/prisma/internal/prismaNamespace.ts +4 -4
- package/src/generated/prisma/models/Category.ts +5 -0
- package/src/generated/prisma/models/Label.ts +5 -0
- package/src/generated/prisma/models/NestedResource.ts +5 -0
- package/src/generated/prisma/models/NextLevelNestedResource.ts +5 -0
- package/src/generated/prisma/models/Resource.ts +5 -0
- package/src/generated/prisma/models/SubCategory.ts +5 -0
- package/src/generated/prisma/models/Tag.ts +5 -0
- package/src/generated/prisma/models/User.ts +5 -0
- package/src/index.test.ts +852 -100
- package/src/index.ts +592 -124
package/src/index.ts
CHANGED
|
@@ -7,6 +7,15 @@ import {
|
|
|
7
7
|
|
|
8
8
|
export { PlanKind };
|
|
9
9
|
|
|
10
|
+
const CERBOS_TO_PRISMA_OPERATOR: Record<string, string> = {
|
|
11
|
+
eq: "equals",
|
|
12
|
+
ne: "not",
|
|
13
|
+
lt: "lt",
|
|
14
|
+
le: "lte",
|
|
15
|
+
gt: "gt",
|
|
16
|
+
ge: "gte",
|
|
17
|
+
};
|
|
18
|
+
|
|
10
19
|
// Type Definitions
|
|
11
20
|
export type PrismaFilter = Record<string, any>;
|
|
12
21
|
|
|
@@ -127,6 +136,78 @@ function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue {
|
|
|
127
136
|
return "value" in operand;
|
|
128
137
|
}
|
|
129
138
|
|
|
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
|
+
function requireResolvedValue(
|
|
172
|
+
operand: ResolvedOperand,
|
|
173
|
+
message: string
|
|
174
|
+
): ResolvedValue {
|
|
175
|
+
if (!isResolvedValue(operand)) {
|
|
176
|
+
throw new Error(message);
|
|
177
|
+
}
|
|
178
|
+
return operand;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function wrapRelations(
|
|
182
|
+
relations: RelationConfig[] | undefined,
|
|
183
|
+
filter: PrismaFilter
|
|
184
|
+
): PrismaFilter {
|
|
185
|
+
if (!relations || relations.length === 0) {
|
|
186
|
+
return filter;
|
|
187
|
+
}
|
|
188
|
+
return buildNestedRelationFilter(relations, filter);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildFieldEqualsFilter(
|
|
192
|
+
fieldRef: ResolvedFieldReference,
|
|
193
|
+
value: Value
|
|
194
|
+
): PrismaFilter {
|
|
195
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
196
|
+
return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildFieldDirectOrInFilter(
|
|
200
|
+
fieldRef: ResolvedFieldReference,
|
|
201
|
+
values: Value[]
|
|
202
|
+
): PrismaFilter {
|
|
203
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
204
|
+
const baseFilter =
|
|
205
|
+
values.length === 1
|
|
206
|
+
? { [fieldName]: values[0] }
|
|
207
|
+
: { [fieldName]: { in: values } };
|
|
208
|
+
return wrapRelations(fieldRef.relations, baseFilter);
|
|
209
|
+
}
|
|
210
|
+
|
|
130
211
|
/**
|
|
131
212
|
* Converts a Cerbos query plan to a Prisma filter.
|
|
132
213
|
*/
|
|
@@ -302,12 +383,35 @@ function resolveOperand(
|
|
|
302
383
|
} else if (isValueOperand(operand)) {
|
|
303
384
|
return { value: operand.value };
|
|
304
385
|
} else if (isOperatorOperand(operand)) {
|
|
386
|
+
const folded = tryFoldValueExpression(operand, mapper);
|
|
387
|
+
if (folded !== null) return { value: folded };
|
|
305
388
|
const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
|
|
306
389
|
return { value: nestedResult };
|
|
307
390
|
}
|
|
308
391
|
throw new Error("Operand must have name, value, or be an expression");
|
|
309
392
|
}
|
|
310
393
|
|
|
394
|
+
function tryFoldValueExpression(
|
|
395
|
+
expr: OperatorOperand,
|
|
396
|
+
mapper: Mapper
|
|
397
|
+
): Value | null {
|
|
398
|
+
if (expr.operator !== "add") return null;
|
|
399
|
+
const leftOp = expr.operands[0];
|
|
400
|
+
const rightOp = expr.operands[1];
|
|
401
|
+
if (!leftOp || !rightOp) return null;
|
|
402
|
+
|
|
403
|
+
const left = resolveOperand(leftOp, mapper);
|
|
404
|
+
if (!isResolvedValue(left)) return null;
|
|
405
|
+
const right = resolveOperand(rightOp, mapper);
|
|
406
|
+
if (!isResolvedValue(right)) return null;
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
return foldAdd(left.value, right.value);
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
311
415
|
/**
|
|
312
416
|
* Creates a scoped mapper for collection operations
|
|
313
417
|
*/
|
|
@@ -381,13 +485,8 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
381
485
|
): PrismaFilter {
|
|
382
486
|
// A bare named operand represents a boolean field reference (e.g. `R.attr.booleanAttr`)
|
|
383
487
|
if (isNamedOperand(expression)) {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
const fieldFilter = { [fieldName]: { equals: true } };
|
|
387
|
-
if (relations && relations.length > 0) {
|
|
388
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
389
|
-
}
|
|
390
|
-
return fieldFilter;
|
|
488
|
+
const fieldRef = resolveFieldReference(expression.name, mapper);
|
|
489
|
+
return buildFieldEqualsFilter(fieldRef, true);
|
|
391
490
|
}
|
|
392
491
|
|
|
393
492
|
if (!isOperatorOperand(expression)) {
|
|
@@ -418,13 +517,12 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
418
517
|
throw new Error("not operator requires an operand");
|
|
419
518
|
}
|
|
420
519
|
if (isNamedOperand(operand)) {
|
|
421
|
-
const {
|
|
520
|
+
const { relations, ...fieldRef } = resolveFieldReference(
|
|
422
521
|
operand.name,
|
|
423
522
|
mapper
|
|
424
523
|
);
|
|
425
524
|
if (!relations || relations.length === 0) {
|
|
426
|
-
|
|
427
|
-
return { [fieldName]: { equals: false } };
|
|
525
|
+
return buildFieldEqualsFilter(fieldRef, false);
|
|
428
526
|
}
|
|
429
527
|
}
|
|
430
528
|
return {
|
|
@@ -475,6 +573,18 @@ function buildPrismaFilterFromCerbosExpression(
|
|
|
475
573
|
return handleMapOperator(operands, mapper);
|
|
476
574
|
}
|
|
477
575
|
|
|
576
|
+
case "overlaps": {
|
|
577
|
+
return handleOverlapsOperator(operands, mapper);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case "ancestorOf": {
|
|
581
|
+
return handleAncestorDescendantOperator(operands, mapper, "ancestor");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
case "descendentOf": {
|
|
585
|
+
return handleAncestorDescendantOperator(operands, mapper, "descendant");
|
|
586
|
+
}
|
|
587
|
+
|
|
478
588
|
default:
|
|
479
589
|
throw new Error(`Unsupported operator: ${operator}`);
|
|
480
590
|
}
|
|
@@ -542,14 +652,7 @@ function handleRelationalOperator(
|
|
|
542
652
|
operands: PlanExpressionOperand[],
|
|
543
653
|
mapper: Mapper
|
|
544
654
|
): PrismaFilter {
|
|
545
|
-
const prismaOperator =
|
|
546
|
-
eq: "equals",
|
|
547
|
-
ne: "not",
|
|
548
|
-
lt: "lt",
|
|
549
|
-
le: "lte",
|
|
550
|
-
gt: "gt",
|
|
551
|
-
ge: "gte",
|
|
552
|
-
}[operator];
|
|
655
|
+
const prismaOperator = CERBOS_TO_PRISMA_OPERATOR[operator];
|
|
553
656
|
|
|
554
657
|
if (!prismaOperator) {
|
|
555
658
|
throw new Error(`Unsupported operator: ${operator}`);
|
|
@@ -567,29 +670,24 @@ function handleRelationalOperator(
|
|
|
567
670
|
return handleSizeComparison(operator, leftOperand, rightOperand, mapper);
|
|
568
671
|
}
|
|
569
672
|
|
|
673
|
+
const addOperand = [leftOperand, rightOperand].find(
|
|
674
|
+
(o): o is OperatorOperand =>
|
|
675
|
+
isOperatorOperand(o) && o.operator === "add"
|
|
676
|
+
);
|
|
677
|
+
if (addOperand) {
|
|
678
|
+
const otherOperand =
|
|
679
|
+
addOperand === leftOperand ? rightOperand : leftOperand;
|
|
680
|
+
return handleAddComparison(operator, addOperand, otherOperand, mapper);
|
|
681
|
+
}
|
|
682
|
+
|
|
570
683
|
const left = resolveOperand(leftOperand, mapper);
|
|
571
|
-
const right =
|
|
684
|
+
const right = requireResolvedValue(
|
|
685
|
+
resolveOperand(rightOperand, mapper),
|
|
686
|
+
"Right operand must be a value"
|
|
687
|
+
);
|
|
572
688
|
|
|
573
689
|
if (isResolvedFieldReference(left)) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (!isResolvedValue(right)) {
|
|
577
|
-
throw new Error("Right operand must be a value");
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const filterValue = { [prismaOperator]: right.value };
|
|
581
|
-
const fieldName = getLeafField(path);
|
|
582
|
-
const fieldFilter = { [fieldName]: filterValue };
|
|
583
|
-
|
|
584
|
-
if (relations && relations.length > 0) {
|
|
585
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
return fieldFilter;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (!isResolvedValue(right)) {
|
|
592
|
-
throw new Error("Right operand must be a value");
|
|
690
|
+
return buildFieldFilter(left, prismaOperator, right.value);
|
|
593
691
|
}
|
|
594
692
|
|
|
595
693
|
return { [prismaOperator]: right.value };
|
|
@@ -602,39 +700,18 @@ function handleInOperator(
|
|
|
602
700
|
operands: PlanExpressionOperand[],
|
|
603
701
|
mapper: Mapper
|
|
604
702
|
): PrismaFilter {
|
|
605
|
-
const nameOperand = operands
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const { path, relations } = resolved;
|
|
617
|
-
const resolvedValue = resolveOperand(valueOperand, mapper);
|
|
618
|
-
|
|
619
|
-
if (!isResolvedValue(resolvedValue)) {
|
|
620
|
-
throw new Error("Value operand must resolve to a value");
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const { value } = resolvedValue;
|
|
703
|
+
const nameOperand = getNamedOperand(operands, "Name operand is undefined");
|
|
704
|
+
const valueOperand = getValueOperand(operands, "Value operand is undefined");
|
|
705
|
+
const fieldRef = requireResolvedFieldReference(
|
|
706
|
+
resolveOperand(nameOperand, mapper),
|
|
707
|
+
"Name operand must resolve to a field reference"
|
|
708
|
+
);
|
|
709
|
+
const { value } = requireResolvedValue(
|
|
710
|
+
resolveOperand(valueOperand, mapper),
|
|
711
|
+
"Value operand must resolve to a value"
|
|
712
|
+
);
|
|
624
713
|
const values = Array.isArray(value) ? value : [value];
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (relations && relations.length > 0) {
|
|
628
|
-
const fieldFilter =
|
|
629
|
-
values.length === 1
|
|
630
|
-
? { [fieldName]: values[0] }
|
|
631
|
-
: { [fieldName]: { in: values } };
|
|
632
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return values.length === 1
|
|
636
|
-
? { [fieldName]: values[0] }
|
|
637
|
-
: { [fieldName]: { in: values } };
|
|
714
|
+
return buildFieldDirectOrInFilter(fieldRef, values);
|
|
638
715
|
}
|
|
639
716
|
|
|
640
717
|
/**
|
|
@@ -645,37 +722,24 @@ function handleStringOperator(
|
|
|
645
722
|
operands: PlanExpressionOperand[],
|
|
646
723
|
mapper: Mapper
|
|
647
724
|
): PrismaFilter {
|
|
648
|
-
const nameOperand = operands
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const valueOperand = operands.find(isValueOperand);
|
|
659
|
-
if (!valueOperand) throw new Error("Value operand is undefined");
|
|
660
|
-
|
|
661
|
-
const resolvedValue = resolveOperand(valueOperand, mapper);
|
|
662
|
-
if (!isResolvedValue(resolvedValue)) {
|
|
663
|
-
throw new Error("Value operand must resolve to a value");
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const { value } = resolvedValue;
|
|
725
|
+
const nameOperand = getNamedOperand(operands, "Name operand is undefined");
|
|
726
|
+
const valueOperand = getValueOperand(operands, "Value operand is undefined");
|
|
727
|
+
const fieldRef = requireResolvedFieldReference(
|
|
728
|
+
resolveOperand(nameOperand, mapper),
|
|
729
|
+
"Name operand must resolve to a field reference"
|
|
730
|
+
);
|
|
731
|
+
const { value } = requireResolvedValue(
|
|
732
|
+
resolveOperand(valueOperand, mapper),
|
|
733
|
+
"Value operand must resolve to a value"
|
|
734
|
+
);
|
|
667
735
|
if (typeof value !== "string") {
|
|
668
736
|
throw new Error(`${operator} operator requires string value`);
|
|
669
737
|
}
|
|
670
738
|
|
|
671
|
-
const fieldName = getLeafField(path);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
return fieldFilter;
|
|
739
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
740
|
+
return wrapRelations(fieldRef.relations, {
|
|
741
|
+
[fieldName]: { [operator]: value },
|
|
742
|
+
});
|
|
679
743
|
}
|
|
680
744
|
|
|
681
745
|
/**
|
|
@@ -685,34 +749,21 @@ function handleIsSetOperator(
|
|
|
685
749
|
operands: PlanExpressionOperand[],
|
|
686
750
|
mapper: Mapper
|
|
687
751
|
): PrismaFilter {
|
|
688
|
-
const nameOperand = operands
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const valueOperand = operands.find(isValueOperand);
|
|
699
|
-
if (!valueOperand) throw new Error("Value operand is undefined");
|
|
700
|
-
|
|
701
|
-
const resolvedValue = resolveOperand(valueOperand, mapper);
|
|
702
|
-
if (!isResolvedValue(resolvedValue)) {
|
|
703
|
-
throw new Error("Value operand must resolve to a value");
|
|
704
|
-
}
|
|
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
|
+
);
|
|
705
762
|
|
|
706
|
-
const fieldName = getLeafField(path);
|
|
707
|
-
|
|
763
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
764
|
+
return wrapRelations(fieldRef.relations, {
|
|
708
765
|
[fieldName]: resolvedValue.value ? { not: null } : { equals: null },
|
|
709
|
-
};
|
|
710
|
-
|
|
711
|
-
if (relations && relations.length > 0) {
|
|
712
|
-
return buildNestedRelationFilter(relations, fieldFilter);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
return fieldFilter;
|
|
766
|
+
});
|
|
716
767
|
}
|
|
717
768
|
|
|
718
769
|
/**
|
|
@@ -937,8 +988,13 @@ function handleCollectionOperator(
|
|
|
937
988
|
|
|
938
989
|
switch (operator) {
|
|
939
990
|
case "exists":
|
|
940
|
-
case "filter":
|
|
941
991
|
return { [relation.name]: { some: filterValue } };
|
|
992
|
+
case "filter":
|
|
993
|
+
throw new Error(
|
|
994
|
+
"The filter() collection operator returns a list, not a boolean. " +
|
|
995
|
+
"It cannot be used as a standalone condition. " +
|
|
996
|
+
"Use exists() or combine filter() with size() instead."
|
|
997
|
+
);
|
|
942
998
|
case "except":
|
|
943
999
|
return { [relation.name]: { some: { NOT: filterValue } } };
|
|
944
1000
|
case "exists_one":
|
|
@@ -1030,3 +1086,415 @@ function handleMapOperator(
|
|
|
1030
1086
|
},
|
|
1031
1087
|
});
|
|
1032
1088
|
}
|
|
1089
|
+
|
|
1090
|
+
function buildImpossibleFilter(fieldRef: ResolvedFieldReference): PrismaFilter {
|
|
1091
|
+
return buildFieldFilter(fieldRef, "in", []);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function handleAddComparison(
|
|
1095
|
+
operator: string,
|
|
1096
|
+
addExpr: OperatorOperand,
|
|
1097
|
+
otherOperand: PlanExpressionOperand,
|
|
1098
|
+
mapper: Mapper
|
|
1099
|
+
): PrismaFilter {
|
|
1100
|
+
const addLeftOp = assertDefined(
|
|
1101
|
+
addExpr.operands[0],
|
|
1102
|
+
"add operator requires a left operand"
|
|
1103
|
+
);
|
|
1104
|
+
const addRightOp = assertDefined(
|
|
1105
|
+
addExpr.operands[1],
|
|
1106
|
+
"add operator requires a right operand"
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
const addLeft = resolveOperand(addLeftOp, mapper);
|
|
1110
|
+
const addRight = resolveOperand(addRightOp, mapper);
|
|
1111
|
+
const other = resolveOperand(otherOperand, mapper);
|
|
1112
|
+
|
|
1113
|
+
if (isResolvedValue(addLeft) && isResolvedValue(addRight)) {
|
|
1114
|
+
const folded = foldAdd(addLeft.value, addRight.value);
|
|
1115
|
+
const prismaOp = CERBOS_TO_PRISMA_OPERATOR[operator];
|
|
1116
|
+
if (!prismaOp) throw new Error(`Unsupported operator: ${operator}`);
|
|
1117
|
+
|
|
1118
|
+
if (isResolvedFieldReference(other)) {
|
|
1119
|
+
return buildFieldFilter(other, prismaOp, folded);
|
|
1120
|
+
}
|
|
1121
|
+
throw new Error("add with two values requires a field reference on the other side");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (!isResolvedValue(other)) {
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
"add operator with field references requires a value on the other side of the comparison"
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
let fieldRef: ResolvedFieldReference;
|
|
1131
|
+
let addValue: Value;
|
|
1132
|
+
let fieldIsLeft: boolean;
|
|
1133
|
+
|
|
1134
|
+
if (isResolvedFieldReference(addLeft) && isResolvedValue(addRight)) {
|
|
1135
|
+
fieldRef = addLeft;
|
|
1136
|
+
addValue = addRight.value;
|
|
1137
|
+
fieldIsLeft = true;
|
|
1138
|
+
} else if (isResolvedValue(addLeft) && isResolvedFieldReference(addRight)) {
|
|
1139
|
+
fieldRef = addRight;
|
|
1140
|
+
addValue = addLeft.value;
|
|
1141
|
+
fieldIsLeft = false;
|
|
1142
|
+
} else {
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
"add operator requires exactly one field reference and one value, or two values"
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (operator !== "eq" && operator !== "ne") {
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
`Operator ${operator} is not supported with add and field references`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const solvedValue = solveAdd(other.value, addValue, fieldIsLeft);
|
|
1155
|
+
if (solvedValue === null) {
|
|
1156
|
+
if (operator === "eq") return buildImpossibleFilter(fieldRef);
|
|
1157
|
+
return {};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
return buildFieldFilter(fieldRef, CERBOS_TO_PRISMA_OPERATOR[operator]!, solvedValue);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function foldAdd(left: Value, right: Value): Value {
|
|
1164
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
1165
|
+
return String(left) + String(right);
|
|
1166
|
+
}
|
|
1167
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
1168
|
+
return left + right;
|
|
1169
|
+
}
|
|
1170
|
+
throw new Error("add operator requires string or number operands");
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function solveAdd(
|
|
1174
|
+
comparisonValue: Value,
|
|
1175
|
+
addConstant: Value,
|
|
1176
|
+
fieldIsLeft: boolean
|
|
1177
|
+
): Value | null {
|
|
1178
|
+
if (typeof comparisonValue === "string" && typeof addConstant === "string") {
|
|
1179
|
+
if (fieldIsLeft) {
|
|
1180
|
+
if (!comparisonValue.endsWith(addConstant)) return null;
|
|
1181
|
+
return comparisonValue.slice(
|
|
1182
|
+
0,
|
|
1183
|
+
comparisonValue.length - addConstant.length
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
if (!comparisonValue.startsWith(addConstant)) return null;
|
|
1187
|
+
return comparisonValue.slice(addConstant.length);
|
|
1188
|
+
}
|
|
1189
|
+
if (typeof comparisonValue === "number" && typeof addConstant === "number") {
|
|
1190
|
+
return comparisonValue - addConstant;
|
|
1191
|
+
}
|
|
1192
|
+
throw new Error("Type mismatch in add comparison");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
type ConstantSegment = { type: "constant"; value: string };
|
|
1196
|
+
type FieldSegment = { type: "field"; fieldRef: ResolvedFieldReference };
|
|
1197
|
+
type HierarchySegment = ConstantSegment | FieldSegment;
|
|
1198
|
+
|
|
1199
|
+
type ConstantHierarchy = {
|
|
1200
|
+
type: "constant";
|
|
1201
|
+
segments: string[];
|
|
1202
|
+
raw: string;
|
|
1203
|
+
delimiter: string;
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
type FieldHierarchy = {
|
|
1207
|
+
type: "field";
|
|
1208
|
+
fieldRef: ResolvedFieldReference;
|
|
1209
|
+
delimiter: string;
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
type SegmentedHierarchy = {
|
|
1213
|
+
type: "segmented";
|
|
1214
|
+
segments: HierarchySegment[];
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
type ResolvedHierarchy = ConstantHierarchy | FieldHierarchy | SegmentedHierarchy;
|
|
1218
|
+
|
|
1219
|
+
function resolveHierarchy(
|
|
1220
|
+
expr: OperatorOperand,
|
|
1221
|
+
mapper: Mapper
|
|
1222
|
+
): ResolvedHierarchy {
|
|
1223
|
+
const operands = expr.operands;
|
|
1224
|
+
|
|
1225
|
+
if (operands.length === 2) {
|
|
1226
|
+
const strOperand = assertDefined(operands[0], "hierarchy requires operands");
|
|
1227
|
+
const delimOperand = assertDefined(
|
|
1228
|
+
operands[1],
|
|
1229
|
+
"hierarchy requires a delimiter"
|
|
1230
|
+
);
|
|
1231
|
+
if (!isValueOperand(delimOperand)) {
|
|
1232
|
+
throw new Error("hierarchy delimiter must be a value");
|
|
1233
|
+
}
|
|
1234
|
+
const delimiter = String(delimOperand.value);
|
|
1235
|
+
|
|
1236
|
+
if (isValueOperand(strOperand)) {
|
|
1237
|
+
const raw = String(strOperand.value);
|
|
1238
|
+
return { type: "constant", segments: raw.split(delimiter), raw, delimiter };
|
|
1239
|
+
}
|
|
1240
|
+
if (isNamedOperand(strOperand)) {
|
|
1241
|
+
return {
|
|
1242
|
+
type: "field",
|
|
1243
|
+
fieldRef: resolveFieldReference(strOperand.name, mapper),
|
|
1244
|
+
delimiter,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
throw new Error("hierarchy(string, delimiter) requires a value or field operand");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (operands.length === 1) {
|
|
1251
|
+
const inner = assertDefined(operands[0], "hierarchy requires an operand");
|
|
1252
|
+
|
|
1253
|
+
if (isValueOperand(inner)) {
|
|
1254
|
+
const raw = String(inner.value);
|
|
1255
|
+
return { type: "constant", segments: raw.split("."), raw, delimiter: "." };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (isNamedOperand(inner)) {
|
|
1259
|
+
return {
|
|
1260
|
+
type: "field",
|
|
1261
|
+
fieldRef: resolveFieldReference(inner.name, mapper),
|
|
1262
|
+
delimiter: ".",
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (isOperatorOperand(inner) && inner.operator === "list") {
|
|
1267
|
+
const segments = inner.operands.map((op): HierarchySegment => {
|
|
1268
|
+
const resolved = resolveOperand(op, mapper);
|
|
1269
|
+
if (isResolvedValue(resolved)) {
|
|
1270
|
+
return { type: "constant", value: String(resolved.value) };
|
|
1271
|
+
}
|
|
1272
|
+
return { type: "field", fieldRef: resolved };
|
|
1273
|
+
});
|
|
1274
|
+
return { type: "segmented", segments };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
throw new Error("hierarchy requires a value, field, or list operand");
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
throw new Error("hierarchy requires 1 or 2 operands");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function toSegments(resolved: ResolvedHierarchy): HierarchySegment[] {
|
|
1284
|
+
switch (resolved.type) {
|
|
1285
|
+
case "constant":
|
|
1286
|
+
return resolved.segments.map((s) => ({ type: "constant" as const, value: s }));
|
|
1287
|
+
case "segmented":
|
|
1288
|
+
return resolved.segments;
|
|
1289
|
+
case "field":
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
"Cannot get segments from a field-reference hierarchy"
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function normalizeHierarchy(
|
|
1297
|
+
h: ResolvedHierarchy,
|
|
1298
|
+
defaultDelimiter = "."
|
|
1299
|
+
): ResolvedHierarchy {
|
|
1300
|
+
if (h.type !== "segmented") return h;
|
|
1301
|
+
const allConstant = h.segments.every(
|
|
1302
|
+
(s): s is ConstantSegment => s.type === "constant"
|
|
1303
|
+
);
|
|
1304
|
+
if (!allConstant) return h;
|
|
1305
|
+
const segments = (h.segments as ConstantSegment[]).map((s) => s.value);
|
|
1306
|
+
return {
|
|
1307
|
+
type: "constant",
|
|
1308
|
+
segments,
|
|
1309
|
+
raw: segments.join(defaultDelimiter),
|
|
1310
|
+
delimiter: defaultDelimiter,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function checkPrefixConditions(
|
|
1315
|
+
shorter: HierarchySegment[],
|
|
1316
|
+
longer: HierarchySegment[]
|
|
1317
|
+
): PrismaFilter | null {
|
|
1318
|
+
if (shorter.length > longer.length) return null;
|
|
1319
|
+
|
|
1320
|
+
const conditions: PrismaFilter[] = [];
|
|
1321
|
+
|
|
1322
|
+
for (let i = 0; i < shorter.length; i++) {
|
|
1323
|
+
const s = shorter[i]!;
|
|
1324
|
+
const l = longer[i]!;
|
|
1325
|
+
|
|
1326
|
+
if (s.type === "constant" && l.type === "constant") {
|
|
1327
|
+
if (s.value !== l.value) return null;
|
|
1328
|
+
} else if (s.type === "field" && l.type === "constant") {
|
|
1329
|
+
conditions.push(buildFieldFilter(s.fieldRef, "equals", l.value));
|
|
1330
|
+
} else if (s.type === "constant" && l.type === "field") {
|
|
1331
|
+
conditions.push(buildFieldFilter(l.fieldRef, "equals", s.value));
|
|
1332
|
+
} else {
|
|
1333
|
+
throw new Error(
|
|
1334
|
+
"Cannot compare two field references in hierarchy overlap"
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (conditions.length === 0) return {};
|
|
1340
|
+
if (conditions.length === 1) return conditions[0]!;
|
|
1341
|
+
return { AND: conditions };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function handleOverlapsOperator(
|
|
1345
|
+
operands: PlanExpressionOperand[],
|
|
1346
|
+
mapper: Mapper
|
|
1347
|
+
): PrismaFilter {
|
|
1348
|
+
const [left, right] = extractHierarchyOperands("overlaps", operands, mapper);
|
|
1349
|
+
|
|
1350
|
+
if (left.type === "field" || right.type === "field") {
|
|
1351
|
+
return handleFieldOverlaps(left, right);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const leftSegs = toSegments(left);
|
|
1355
|
+
const rightSegs = toSegments(right);
|
|
1356
|
+
|
|
1357
|
+
const leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs);
|
|
1358
|
+
const rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs);
|
|
1359
|
+
|
|
1360
|
+
const validConditions = [leftPrefixOfRight, rightPrefixOfLeft].filter(
|
|
1361
|
+
(c): c is PrismaFilter => c !== null
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
if (validConditions.length === 0) {
|
|
1365
|
+
const allSegs = [...leftSegs, ...rightSegs];
|
|
1366
|
+
const fieldSeg = allSegs.find(
|
|
1367
|
+
(s): s is FieldSegment => s.type === "field"
|
|
1368
|
+
);
|
|
1369
|
+
if (fieldSeg) return buildImpossibleFilter(fieldSeg.fieldRef);
|
|
1370
|
+
throw new Error("Cannot determine overlap: no field references found");
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (validConditions.some((c) => Object.keys(c).length === 0)) return {};
|
|
1374
|
+
|
|
1375
|
+
// When both directions are valid (equal-length hierarchies), they produce
|
|
1376
|
+
// identical conditions since the same segment pairs are compared in both.
|
|
1377
|
+
return validConditions[0]!;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function handleFieldOverlaps(
|
|
1381
|
+
left: ResolvedHierarchy,
|
|
1382
|
+
right: ResolvedHierarchy
|
|
1383
|
+
): PrismaFilter {
|
|
1384
|
+
if (left.type === "field" && right.type === "field") {
|
|
1385
|
+
throw new Error("overlaps: cannot compare two field-reference hierarchies");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const field = (left.type === "field" ? left : right) as FieldHierarchy;
|
|
1389
|
+
const other = left.type === "field" ? right : left;
|
|
1390
|
+
|
|
1391
|
+
if (other.type !== "constant") {
|
|
1392
|
+
throw new Error("overlaps: segmented hierarchies with field hierarchies are not supported");
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const delimiter = field.delimiter;
|
|
1396
|
+
const otherRaw = other.segments.join(delimiter);
|
|
1397
|
+
const strictPrefixes = getStrictPrefixes(other.segments, delimiter);
|
|
1398
|
+
|
|
1399
|
+
const conditions: PrismaFilter[] = [];
|
|
1400
|
+
if (strictPrefixes.length > 0) {
|
|
1401
|
+
conditions.push(buildFieldFilter(field.fieldRef, "in", strictPrefixes));
|
|
1402
|
+
}
|
|
1403
|
+
conditions.push(buildFieldFilter(field.fieldRef, "equals", otherRaw));
|
|
1404
|
+
conditions.push(buildFieldFilter(field.fieldRef, "startsWith", otherRaw + delimiter));
|
|
1405
|
+
|
|
1406
|
+
if (conditions.length === 1) return conditions[0]!;
|
|
1407
|
+
return { OR: conditions };
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function extractHierarchyOperands(
|
|
1411
|
+
operatorName: string,
|
|
1412
|
+
operands: PlanExpressionOperand[],
|
|
1413
|
+
mapper: Mapper
|
|
1414
|
+
): [ResolvedHierarchy, ResolvedHierarchy] {
|
|
1415
|
+
if (operands.length !== 2) {
|
|
1416
|
+
throw new Error(`${operatorName} requires exactly two operands`);
|
|
1417
|
+
}
|
|
1418
|
+
const leftOp = assertDefined(operands[0], `${operatorName} requires a left operand`);
|
|
1419
|
+
const rightOp = assertDefined(operands[1], `${operatorName} requires a right operand`);
|
|
1420
|
+
|
|
1421
|
+
if (
|
|
1422
|
+
!isOperatorOperand(leftOp) || leftOp.operator !== "hierarchy" ||
|
|
1423
|
+
!isOperatorOperand(rightOp) || rightOp.operator !== "hierarchy"
|
|
1424
|
+
) {
|
|
1425
|
+
throw new Error(`${operatorName} requires two hierarchy operands`);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
return [
|
|
1429
|
+
normalizeHierarchy(resolveHierarchy(leftOp, mapper)),
|
|
1430
|
+
normalizeHierarchy(resolveHierarchy(rightOp, mapper)),
|
|
1431
|
+
];
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function getStrictPrefixes(segments: string[], delimiter: string): string[] {
|
|
1435
|
+
if (segments.length <= 1) return [];
|
|
1436
|
+
const prefixes: string[] = [];
|
|
1437
|
+
let current = segments[0]!;
|
|
1438
|
+
prefixes.push(current);
|
|
1439
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
1440
|
+
current = current + delimiter + segments[i]!;
|
|
1441
|
+
prefixes.push(current);
|
|
1442
|
+
}
|
|
1443
|
+
return prefixes;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function handleAncestorDescendantOperator(
|
|
1447
|
+
operands: PlanExpressionOperand[],
|
|
1448
|
+
mapper: Mapper,
|
|
1449
|
+
direction: "ancestor" | "descendant"
|
|
1450
|
+
): PrismaFilter {
|
|
1451
|
+
const operatorName = direction === "ancestor" ? "ancestorOf" : "descendentOf";
|
|
1452
|
+
const [left, right] = extractHierarchyOperands(operatorName, operands, mapper);
|
|
1453
|
+
|
|
1454
|
+
// ancestorOf(A, B) = A is strict prefix of B
|
|
1455
|
+
// descendentOf(A, B) = B is strict prefix of A
|
|
1456
|
+
const ancestor = direction === "ancestor" ? left : right;
|
|
1457
|
+
const descendant = direction === "ancestor" ? right : left;
|
|
1458
|
+
|
|
1459
|
+
if (ancestor.type === "constant" && descendant.type === "field") {
|
|
1460
|
+
const prefix = ancestor.segments.join(descendant.delimiter) + descendant.delimiter;
|
|
1461
|
+
return buildFieldFilter(descendant.fieldRef, "startsWith", prefix);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (ancestor.type === "field" && descendant.type === "constant") {
|
|
1465
|
+
const delimiter = ancestor.delimiter;
|
|
1466
|
+
const prefixes = getStrictPrefixes(descendant.segments, delimiter);
|
|
1467
|
+
if (prefixes.length === 0) {
|
|
1468
|
+
return buildImpossibleFilter(ancestor.fieldRef);
|
|
1469
|
+
}
|
|
1470
|
+
if (prefixes.length === 1) {
|
|
1471
|
+
return buildFieldFilter(ancestor.fieldRef, "equals", prefixes[0]!);
|
|
1472
|
+
}
|
|
1473
|
+
return buildFieldFilter(ancestor.fieldRef, "in", prefixes);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (ancestor.type === "constant" && descendant.type === "constant") {
|
|
1477
|
+
const ancestorSegs = ancestor.segments;
|
|
1478
|
+
const descendantSegs = descendant.segments;
|
|
1479
|
+
if (
|
|
1480
|
+
descendantSegs.length > ancestorSegs.length &&
|
|
1481
|
+
ancestorSegs.every((seg, i) => seg === descendantSegs[i])
|
|
1482
|
+
) {
|
|
1483
|
+
return {};
|
|
1484
|
+
}
|
|
1485
|
+
throw new Error(`${operatorName}: constants do not satisfy ${direction} relationship`);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
throw new Error(`${operatorName}: unsupported hierarchy type combination`);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function buildFieldFilter(
|
|
1492
|
+
fieldRef: ResolvedFieldReference,
|
|
1493
|
+
prismaOp: string,
|
|
1494
|
+
value: Value
|
|
1495
|
+
): PrismaFilter {
|
|
1496
|
+
const fieldName = getLeafField(fieldRef.path);
|
|
1497
|
+
return wrapRelations(fieldRef.relations, {
|
|
1498
|
+
[fieldName]: { [prismaOp]: value },
|
|
1499
|
+
});
|
|
1500
|
+
}
|