@cerbos/orm-prisma 3.2.1 → 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/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 { path, relations } = resolveFieldReference(expression.name, mapper);
385
- const fieldName = getLeafField(path);
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 { path, relations } = resolveFieldReference(
520
+ const { relations, ...fieldRef } = resolveFieldReference(
422
521
  operand.name,
423
522
  mapper
424
523
  );
425
524
  if (!relations || relations.length === 0) {
426
- const fieldName = getLeafField(path);
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 = resolveOperand(rightOperand, mapper);
684
+ const right = requireResolvedValue(
685
+ resolveOperand(rightOperand, mapper),
686
+ "Right operand must be a value"
687
+ );
572
688
 
573
689
  if (isResolvedFieldReference(left)) {
574
- const { path, relations } = left;
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.find(isNamedOperand);
606
- if (!nameOperand) throw new Error("Name operand is undefined");
607
-
608
- const valueOperand = operands.find(isValueOperand);
609
- if (!valueOperand) throw new Error("Value operand is undefined");
610
-
611
- const resolved = resolveOperand(nameOperand, mapper);
612
- if (!isResolvedFieldReference(resolved)) {
613
- throw new Error("Name operand must resolve to a field reference");
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
- const fieldName = getLeafField(path);
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.find(isNamedOperand);
649
- if (!nameOperand) throw new Error("Name operand is undefined");
650
-
651
- const resolved = resolveOperand(nameOperand, mapper);
652
- if (!isResolvedFieldReference(resolved)) {
653
- throw new Error("Name operand must resolve to a field reference");
654
- }
655
-
656
- const { path, relations } = resolved;
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
- const fieldFilter = { [fieldName]: { [operator]: value } };
673
-
674
- if (relations && relations.length > 0) {
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.find(isNamedOperand);
689
- if (!nameOperand) throw new Error("Name operand is undefined");
690
-
691
- const resolved = resolveOperand(nameOperand, mapper);
692
- if (!isResolvedFieldReference(resolved)) {
693
- throw new Error("Name operand must resolve to a field reference");
694
- }
695
-
696
- const { path, relations } = resolved;
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
- const fieldFilter = {
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
  /**
@@ -1035,3 +1086,415 @@ function handleMapOperator(
1035
1086
  },
1036
1087
  });
1037
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
+ }