@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.
Files changed (59) hide show
  1. package/README.md +168 -12
  2. package/lib/index.d.ts +74 -1
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +344 -90
  5. package/lib/index.js.map +1 -1
  6. package/package.json +10 -7
  7. package/src/adversarial.test.ts +948 -253
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +10 -0
  10. package/src/generated/prisma-adversarial/client.ts +10 -0
  11. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  12. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +178 -2
  13. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +27 -1
  14. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  15. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  16. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +124 -0
  17. package/src/generated/prisma-adversarial/models.ts +2 -0
  18. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  19. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  20. package/src/generated/{prisma → prisma-adversarial-pg}/commonInputTypes.ts +153 -71
  21. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  22. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespace.ts +339 -371
  23. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +56 -48
  24. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  25. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  26. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  27. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  28. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  29. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  31. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  32. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  33. package/src/generated/prisma-adversarial-v6/index.d.ts +3173 -44
  34. package/src/index.ts +508 -119
  35. package/src/test-setup.adversarial-url.ts +18 -0
  36. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  37. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  38. package/src/translator.test.ts +1586 -0
  39. package/src/generated/prisma/internal/class.ts +0 -274
  40. package/src/generated/prisma/models/Category.ts +0 -1412
  41. package/src/generated/prisma/models/Label.ts +0 -1276
  42. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  43. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  44. package/src/generated/prisma/models/Resource.ts +0 -2196
  45. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  46. package/src/generated/prisma/models/Tag.ts +0 -1276
  47. package/src/generated/prisma/models/User.ts +0 -1503
  48. package/src/generated/prisma/models.ts +0 -19
  49. package/src/generated/prisma-v6/index.d.ts +0 -13726
  50. package/src/index.test.ts +0 -8885
  51. package/src/test-setup.v6.ts +0 -5
  52. package/src/test-setup.v7.ts +0 -7
  53. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  54. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  55. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  56. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  57. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  58. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  59. /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
- const baseFilter =
519
- values.length === 1
520
- ? { [fieldName]: values[0] }
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
- filters.push(buildFieldDirectOrInFilter(fieldRef, nonNullValues));
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 (nonNullValues.length !== values.length) {
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 = { [relation.name]: { [relationOperator]: 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
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
1422
- };
1733
+ return negateRequiringHops(
1734
+ operand,
1735
+ buildPrismaFilterFromCerbosExpression(operand, mapper),
1736
+ mapper
1737
+ );
1423
1738
  }
1424
1739
 
1425
- if (isOperatorOperand(operand) && containsCollectionOperator(operand)) {
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
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
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
- if (isNamedOperand(condition)) {
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 = { [deepest.name]: { [prismaOp]: {} } };
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
- return {
2139
- [leftField]: {
2140
- [prismaOperator]: { _ref: rightField, _container: container },
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
- if (/[%_]/.test(value)) {
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 (% or _): ` +
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 { [parts.head.name]: { some: inner } };
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 = { [head.name]: { some: filterValue } };
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 { [head.name]: { some: { NOT: filterValue } } };
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 = { [head.name]: { every: filterValue } };
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
- { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
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 = { NOT: { [head.name]: { some: filterValue } } };
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 { [head.name]: { some: { NOT: filterValue } } };
3157
+ return relationFilter(head, "some", { NOT: filterValue });
2774
3158
  case "except": {
2775
3159
  // !except(c,P) = every element definitively matches P.
2776
- const base = { [head.name]: { none: { NOT: filterValue } } };
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
- { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
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
- * `[` is unsafe for a separate reason: SQL Server opens a character class on `[` even when
3271
- * an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly rather
3272
- * than emit a filter that admits denied rows.
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, and [ opens a character class on SQL Server even with one"
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
  }