@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/lib/index.js CHANGED
@@ -240,32 +240,16 @@ function getFilterEntry(filter) {
240
240
  }
241
241
  return entry;
242
242
  }
243
+ /** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
244
+ function isExplicitNullReference(fieldRef) {
245
+ return fieldRef.nullAttributeRepresentation === "explicit";
246
+ }
243
247
  function isResolvedFieldReference(operand) {
244
248
  return "path" in operand;
245
249
  }
246
250
  function isResolvedValue(operand) {
247
251
  return "value" in operand;
248
252
  }
249
- function getNamedOperand(operands, message) {
250
- const operand = operands.find(isNamedOperand);
251
- if (!operand) {
252
- throw new Error(message);
253
- }
254
- return operand;
255
- }
256
- function getValueOperand(operands, message) {
257
- const operand = operands.find(isValueOperand);
258
- if (!operand) {
259
- throw new Error(message);
260
- }
261
- return operand;
262
- }
263
- function requireResolvedFieldReference(operand, message) {
264
- if (!isResolvedFieldReference(operand)) {
265
- throw new Error(message);
266
- }
267
- return operand;
268
- }
269
253
  function requireResolvedValue(operand, message) {
270
254
  if (!isResolvedValue(operand)) {
271
255
  throw new Error(message);
@@ -282,20 +266,50 @@ function buildFieldEqualsFilter(fieldRef, value) {
282
266
  const fieldName = getLeafField(fieldRef.path);
283
267
  return wrapRelations(fieldRef.relations, { [fieldName]: { equals: value } });
284
268
  }
285
- function buildFieldDirectOrInFilter(fieldRef, values) {
269
+ function buildDirectOrInLeaf(fieldRef, values) {
286
270
  const fieldName = getLeafField(fieldRef.path);
287
- const baseFilter = values.length === 1
271
+ return values.length === 1
288
272
  ? { [fieldName]: values[0] }
289
273
  : { [fieldName]: { in: values } };
290
- return wrapRelations(fieldRef.relations, baseFilter);
274
+ }
275
+ function buildFieldDirectOrInFilter(fieldRef, values) {
276
+ return wrapRelations(fieldRef.relations, buildDirectOrInLeaf(fieldRef, values));
277
+ }
278
+ /**
279
+ * `field IS NOT NULL AND <leaf>`, so a NULL column makes the whole thing definitely FALSE rather
280
+ * than merely unmatched — which is what lets an enclosing `NOT` include the row.
281
+ *
282
+ * Prisma has no three-valued logic to expose: a record either matches a `where` or it does not.
283
+ * That is exactly why the guard has to be explicit — `{ NOT: { field: { equals: "x" } } }` leans on
284
+ * the generated SQL's UNKNOWN, which excludes the record under both polarities, and an
285
+ * explicit-null attribute needs the negation to include it.
286
+ *
287
+ * The guard is ANDed with the leaf BEFORE the relation wrapping, so both halves are evaluated
288
+ * against the same element rather than against two independently-matched ones.
289
+ */
290
+ function guardedFieldFilter(fieldRef, leaf) {
291
+ const fieldName = getLeafField(fieldRef.path);
292
+ return wrapRelations(fieldRef.relations, {
293
+ AND: [{ [fieldName]: { not: null } }, leaf],
294
+ });
291
295
  }
292
296
  function buildMembershipFilter(fieldRef, values) {
293
297
  const nonNullValues = values.filter((value) => value !== null);
298
+ const carriesNull = nonNullValues.length !== values.length;
299
+ if (carriesNull) {
300
+ assertNullOperandTranslatable("a null element in an `in` list", fieldRef.nullAttributeRepresentation);
301
+ }
294
302
  const filters = [];
295
303
  if (nonNullValues.length > 0 || values.length === 0) {
296
- filters.push(buildFieldDirectOrInFilter(fieldRef, nonNullValues));
297
- }
298
- if (nonNullValues.length !== values.length) {
304
+ // Without a null element nothing has made the membership definite yet: a NULL column is
305
+ // dropped by `{ in: [...] }` under BOTH polarities, while CEL compares a null VALUE against
306
+ // each element and gets a definite false, so its negation is TRUE. With a null element the
307
+ // `[null]` disjunct below already settles it.
308
+ filters.push(carriesNull || !isExplicitNullReference(fieldRef)
309
+ ? buildFieldDirectOrInFilter(fieldRef, nonNullValues)
310
+ : guardedFieldFilter(fieldRef, buildDirectOrInLeaf(fieldRef, nonNullValues)));
311
+ }
312
+ if (carriesNull) {
299
313
  filters.push(buildFieldDirectOrInFilter(fieldRef, [null]));
300
314
  }
301
315
  if (filters.length === 1) {
@@ -308,10 +322,32 @@ function buildAlwaysTrueFilter() {
308
322
  }
309
323
  let lambdaScopes = [];
310
324
  let rootModelName;
325
+ let nullRepresentation = "explicit";
326
+ /**
327
+ * Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
328
+ * operand.
329
+ *
330
+ * Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
331
+ * missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
332
+ * the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
333
+ * null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
334
+ * than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
335
+ * `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
336
+ * under any nesting; narrowing it requires negation-parity tracking.
337
+ */
338
+ function assertNullOperandTranslatable(context, declared) {
339
+ if ((declared ?? nullRepresentation) === "omitted") {
340
+ throw new Error(`Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
341
+ "sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
342
+ "(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
343
+ 'explicit nulls and use "explicit", or keep this shape out of the policy.');
344
+ }
345
+ }
311
346
  /**
312
347
  * Converts a Cerbos query plan to a Prisma filter.
313
348
  */
314
- function queryPlanToPrisma({ queryPlan, mapper = {}, model, }) {
349
+ function queryPlanToPrisma({ queryPlan, mapper = {}, model, nullAttributeRepresentation = "explicit", }) {
350
+ nullRepresentation = nullAttributeRepresentation;
315
351
  switch (queryPlan.kind) {
316
352
  case core_1.PlanKind.ALWAYS_ALLOWED:
317
353
  return { kind: core_1.PlanKind.ALWAYS_ALLOWED };
@@ -331,6 +367,16 @@ function queryPlanToPrisma({ queryPlan, mapper = {}, model, }) {
331
367
  // are ignored.
332
368
  throw new Error("A constant-false conditional predicate must be folded by the Cerbos planner");
333
369
  }
370
+ // A `map()` at the ROOT of the condition returns a list, not a boolean. Translating it
371
+ // produces a projection filter that is not a valid Prisma `where` clause — the shape
372
+ // must be refused HERE rather than left for findMany to reject at query time, because
373
+ // a projection that happened to coerce would be a silently-wrong filter. (`filter()`
374
+ // as a condition is already rejected inside handleCollectionOperator with its own
375
+ // message; nested inside a comparison or size() both remain translatable.)
376
+ if (isOperatorOperand(condition) && condition.operator === "map") {
377
+ throw new Error("map() returns a list, not a boolean, so it cannot be a condition on its own; " +
378
+ "only comparisons or size() over its result have a boolean meaning");
379
+ }
334
380
  return {
335
381
  kind: core_1.PlanKind.CONDITIONAL,
336
382
  filters: buildPrismaFilterFromCerbosExpression(condition, mapper),
@@ -677,6 +723,7 @@ function resolveFieldReference(reference, mapper) {
677
723
  model: activeConfig.relation.model,
678
724
  field: activeConfig.relation.field,
679
725
  nestedMapper: fields,
726
+ subqueryFilter: activeConfig.relation.subqueryFilter,
680
727
  },
681
728
  ];
682
729
  // Process nested relations
@@ -699,6 +746,7 @@ function resolveFieldReference(reference, mapper) {
699
746
  model: nextConfig.relation.model,
700
747
  field: nextConfig.relation.field,
701
748
  nestedMapper: nextConfig.relation.fields,
749
+ subqueryFilter: nextConfig.relation.subqueryFilter,
702
750
  });
703
751
  currentMapper = nextConfig.relation.fields || {};
704
752
  currentParts = currentParts.slice(1);
@@ -717,12 +765,14 @@ function resolveFieldReference(reference, mapper) {
717
765
  path: field ? [field] : remainingParts,
718
766
  relations,
719
767
  valueType: activeConfig.valueType,
768
+ nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
720
769
  };
721
770
  }
722
771
  // Simple field mapping
723
772
  return {
724
773
  path: [activeConfig?.field || reference],
725
774
  valueType: activeConfig?.valueType,
775
+ nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
726
776
  };
727
777
  }
728
778
  /**
@@ -731,9 +781,137 @@ function resolveFieldReference(reference, mapper) {
731
781
  function getPrismaRelationOperator(relation) {
732
782
  return relation.type === "one" ? "is" : "some";
733
783
  }
784
+ /**
785
+ * One relation-scoped filter (`{ tags: { some: … } }`), narrowed by the store-side predicate the
786
+ * caller declared on the mapping.
787
+ *
788
+ * Every nested relation filter the adapter emits goes through here, so a declared
789
+ * `subqueryFilter` reaches the collection macros, the membership shapes, the emptiness checks and
790
+ * the hop-existence guard alike. Adding a new relation shape means routing it through here, or
791
+ * the declaration silently stops applying to it.
792
+ *
793
+ * `every` is the one operator that cannot simply absorb the predicate. `{ every: AND(W, P) }`
794
+ * would REQUIRE every record to satisfy W, which is the opposite of "ignore the records W hides",
795
+ * so it is rewritten to `{ none: AND(W, NOT P) }` — no visible record violates P. Both agree with
796
+ * `every` when nothing is declared, and both stay vacuously true over an empty relation.
797
+ */
798
+ function relationFilter(relation, operator, inner) {
799
+ const declared = relation.subqueryFilter;
800
+ if (declared === undefined) {
801
+ return { [relation.name]: { [operator]: inner } };
802
+ }
803
+ const narrow = (filter) => Object.keys(filter).length === 0 ? declared : { AND: [declared, filter] };
804
+ if (operator === "every") {
805
+ return Object.keys(inner).length === 0
806
+ ? { [relation.name]: { every: inner } }
807
+ : { [relation.name]: { none: { AND: [declared, { NOT: inner }] } } };
808
+ }
809
+ return { [relation.name]: { [operator]: narrow(inner) } };
810
+ }
734
811
  /**
735
812
  * Builds a nested relation filter for Prisma queries.
736
813
  */
814
+ /**
815
+ * "Every intermediate hop of a dotted path exists", as a Prisma filter.
816
+ *
817
+ * CEL cannot dot through a list, so each intermediate segment of `a.b.c` is a to-ONE
818
+ * parent: absent, the caller sends no attribute and CEL raises a missing-path error, which
819
+ * denies. `NOT { categories: { some: { subCategories: { some: P } } } }` is TRUE for a row
820
+ * with no category at all, so the negation returns rows the PDP denies — the parent must be
821
+ * required separately (cerbos/query-plan-adapters#309).
822
+ *
823
+ * Returns undefined when the reference has no intermediate hop, so a direct relation keeps
824
+ * its empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
825
+ */
826
+ function buildLeadingHopsExistFilter(head, restRelations) {
827
+ if (restRelations.length === 0) {
828
+ return undefined;
829
+ }
830
+ // The last entry is the collection being iterated; everything before it is a hop.
831
+ return buildHopsExistFilter([head, ...restRelations.slice(0, -1)]);
832
+ }
833
+ /**
834
+ * "Every one of these relations exists", as a nested Prisma filter with an empty innermost
835
+ * predicate — `{ parent: { is: { inner: { is: {} } } } }`.
836
+ */
837
+ function buildHopsExistFilter(hops) {
838
+ if (hops.length === 0) {
839
+ return undefined;
840
+ }
841
+ let inner = {};
842
+ for (let i = hops.length - 1; i >= 0; i--) {
843
+ const relation = assertDefined(hops[i], "Relation mapping is missing");
844
+ inner = relationFilter(relation, getPrismaRelationOperator(relation), inner);
845
+ }
846
+ return inner;
847
+ }
848
+ /**
849
+ * "Every to-one hop this expression dots through exists", as a Prisma filter, or undefined
850
+ * when it dots through none.
851
+ *
852
+ * Prisma has no UNKNOWN: a chained relation filter is exact under the POSITIVE polarity —
853
+ * `categories: { some: { subCategories: { some: P } } }` cannot hold without the category —
854
+ * but `NOT` inverts the hop requirement along with the predicate, so every operator built on
855
+ * a chain readmits the parentless rows once negated. #309 fixed that inside the collection
856
+ * macros, which left the sibling operators reached through the same chain unguarded:
857
+ * membership and `hasIntersection` (cerbos/query-plan-adapters#315), and the negated count
858
+ * spelling `!(size(chain) > 0)` (#316), all of which the macros' guard never saw.
859
+ *
860
+ * Requiring the hops OUTSIDE the negation is what fixes all of them at once, and it is
861
+ * unconditionally faithful to CEL rather than a per-operator patch: a missing hop is a
862
+ * missing-path error, which denies under BOTH polarities, so the requirement never depends
863
+ * on which operator sits above the chain.
864
+ *
865
+ * Lambda bodies are deliberately not walked — their references resolve against a scoped
866
+ * mapper, and a negated macro carries its own guard through buildNegatedCollectionFilter.
867
+ */
868
+ function buildExpressionHopsExistFilter(operand, mapper) {
869
+ const hops = [];
870
+ const seen = new Set();
871
+ const visit = (expr) => {
872
+ if (isNamedOperand(expr)) {
873
+ if (seen.has(expr.name)) {
874
+ return;
875
+ }
876
+ seen.add(expr.name);
877
+ const { relations } = resolveFieldReference(expr.name, mapper);
878
+ if (!relations || relations.length === 0) {
879
+ return;
880
+ }
881
+ // Every to-ONE relation on the path is a hop that must EXIST — including a TRAILING one,
882
+ // which is the case a chain of to-many relations never produces. `parent.aBool` resolves
883
+ // to a single to-one relation, and `NOT { parent: { is: { aBool: true } } }` is TRUE for
884
+ // a row with no parent at all, so the negation returns rows the PDP denies. A trailing
885
+ // to-MANY relation is the collection being iterated rather than a hop, and keeps its
886
+ // empty-collection semantics (`!tags.exists(...)` over zero tags is still TRUE).
887
+ const last = assertDefined(relations[relations.length - 1], "Relation mapping is missing");
888
+ const hopFilter = buildHopsExistFilter(last.type === "one" ? relations : relations.slice(0, -1));
889
+ if (hopFilter !== undefined) {
890
+ hops.push(hopFilter);
891
+ }
892
+ return;
893
+ }
894
+ if (!isOperatorOperand(expr) || expr.operator === "lambda") {
895
+ return;
896
+ }
897
+ expr.operands.forEach(visit);
898
+ };
899
+ visit(operand);
900
+ if (hops.length === 0) {
901
+ return undefined;
902
+ }
903
+ return hops.length === 1
904
+ ? assertDefined(hops[0], "Leading-hops filter is missing")
905
+ : { AND: hops };
906
+ }
907
+ /**
908
+ * `{ NOT: filter }`, with every to-one hop `operand` dots through required OUTSIDE the
909
+ * negation so an absent parent stays denied (see buildExpressionHopsExistFilter).
910
+ */
911
+ function negateRequiringHops(operand, filter, mapper) {
912
+ const hops = buildExpressionHopsExistFilter(operand, mapper);
913
+ return hops === undefined ? { NOT: filter } : { AND: [hops, { NOT: filter }] };
914
+ }
737
915
  function buildNestedRelationFilter(relations, fieldFilter) {
738
916
  if (relations.length === 0)
739
917
  return fieldFilter;
@@ -755,7 +933,7 @@ function buildNestedRelationFilter(relations, fieldFilter) {
755
933
  currentFilter = { [relation.field]: filterValue };
756
934
  }
757
935
  }
758
- currentFilter = { [relation.name]: { [relationOperator]: currentFilter } };
936
+ currentFilter = relationFilter(relation, relationOperator, currentFilter);
759
937
  }
760
938
  return currentFilter;
761
939
  }
@@ -931,9 +1109,6 @@ function buildPrismaFilterFromCerbosExpression(expression, mapper) {
931
1109
  case "endsWith": {
932
1110
  return handleStringOperator(operator, operands, mapper);
933
1111
  }
934
- case "isSet": {
935
- return handleIsSetOperator(operands, mapper);
936
- }
937
1112
  case "hasIntersection": {
938
1113
  return handleHasIntersectionOperator(operands, mapper);
939
1114
  }
@@ -972,6 +1147,20 @@ function containsCollectionOperator(expr) {
972
1147
  }
973
1148
  return expr.operands.some(containsCollectionOperator);
974
1149
  }
1150
+ /**
1151
+ * Whether `expr` dots through at least one intermediate to-one hop, so negating it needs the
1152
+ * hop requirement of buildExpressionHopsExistFilter.
1153
+ */
1154
+ function referencesChainedRelation(expr, mapper) {
1155
+ if (isNamedOperand(expr)) {
1156
+ const { relations } = resolveFieldReference(expr.name, mapper);
1157
+ return relations !== undefined && relations.length > 1;
1158
+ }
1159
+ if (!isOperatorOperand(expr) || expr.operator === "lambda") {
1160
+ return false;
1161
+ }
1162
+ return expr.operands.some((operand) => referencesChainedRelation(operand, mapper));
1163
+ }
975
1164
  /**
976
1165
  * Builds the filter for `!expr`. Plain field predicates negate correctly with Prisma's NOT
977
1166
  * (SQL three-valued logic keeps NULL rows excluded under both polarities), but relation
@@ -993,11 +1182,16 @@ function buildNegatedFilter(operand, mapper) {
993
1182
  if (!relations || relations.length === 0) {
994
1183
  return buildFieldEqualsFilter(fieldRef, false);
995
1184
  }
996
- return {
997
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
998
- };
1185
+ return negateRequiringHops(operand, buildPrismaFilterFromCerbosExpression(operand, mapper), mapper);
999
1186
  }
1000
- if (isOperatorOperand(operand) && containsCollectionOperator(operand)) {
1187
+ // `and`/`or` must be pushed through with De Morgan whenever a branch can be UNKNOWN, so
1188
+ // CEL's error absorption survives: `!(A && B)` with A erroring and B false is TRUE in CEL,
1189
+ // and `OR[!A, !B]` reproduces that where a single outer NOT over the conjunction — with the
1190
+ // hop requirement ANDed outside it — would deny. A chained relation is the second source of
1191
+ // UNKNOWN besides the collection macros, so it opens the same push-down.
1192
+ if (isOperatorOperand(operand) &&
1193
+ (containsCollectionOperator(operand) ||
1194
+ referencesChainedRelation(operand, mapper))) {
1001
1195
  switch (operand.operator) {
1002
1196
  case "and":
1003
1197
  return {
@@ -1017,9 +1211,7 @@ function buildNegatedFilter(operand, mapper) {
1017
1211
  return buildNegatedCollectionFilter(operand.operator, operand.operands, mapper);
1018
1212
  }
1019
1213
  }
1020
- return {
1021
- NOT: buildPrismaFilterFromCerbosExpression(operand, mapper),
1022
- };
1214
+ return negateRequiringHops(operand, buildPrismaFilterFromCerbosExpression(operand, mapper), mapper);
1023
1215
  }
1024
1216
  function getTernaryOperands(operands) {
1025
1217
  if (operands.length !== 3) {
@@ -1052,25 +1244,23 @@ function buildBooleanBranchFilter(branch, mapper) {
1052
1244
  }
1053
1245
  return { kind: "constant", value: branch.value };
1054
1246
  }
1247
+ /**
1248
+ * "The ternary's condition is definitively FALSE", which is exactly buildNegatedFilter's
1249
+ * contract — three-valued, so a condition that is UNKNOWN (a NULL column, an erroring lambda
1250
+ * body, an absent to-one parent) selects NEITHER branch.
1251
+ *
1252
+ * It delegates rather than spelling a second negation. A private copy is how the ternary
1253
+ * missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is TRUE when
1254
+ * the to-one parent is absent, so the else-branch was selected for rows CEL denies outright —
1255
+ * the missing path is an evaluation error, not a false condition
1256
+ * (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
1257
+ * which a chained `and`/`or` condition needs for CEL's error absorption to survive.
1258
+ */
1055
1259
  function buildFalseConditionFilter(condition, mapper) {
1056
1260
  if (isValueOperand(condition)) {
1057
1261
  throw new Error("Constant ternary conditions must be folded before use");
1058
1262
  }
1059
- if (isNamedOperand(condition)) {
1060
- const fieldRef = resolveFieldReference(condition.name, mapper);
1061
- if (!fieldRef.relations || fieldRef.relations.length === 0) {
1062
- return buildFieldEqualsFilter(fieldRef, false);
1063
- }
1064
- return {
1065
- NOT: buildFieldEqualsFilter(fieldRef, true),
1066
- };
1067
- }
1068
- if (isOperatorOperand(condition) && condition.operator === "not") {
1069
- return buildPrismaFilterFromCerbosExpression(assertDefined(condition.operands[0], "not operator requires an operand"), mapper);
1070
- }
1071
- return {
1072
- NOT: buildPrismaFilterFromCerbosExpression(condition, mapper),
1073
- };
1263
+ return buildNegatedFilter(condition, mapper);
1074
1264
  }
1075
1265
  function buildGuardedTernaryFilter({ condition, thenFilter, elseFilter, mapper, }) {
1076
1266
  const conditionTrue = buildPrismaFilterFromCerbosExpression(condition, mapper);
@@ -1291,7 +1481,7 @@ function handleSizeComparison(operator, sizeOperand, valueOperand, mapper) {
1291
1481
  throw new Error("size operator requires a relation mapping");
1292
1482
  }
1293
1483
  const prismaOp = isNonEmpty ? "some" : "none";
1294
- const leafFilter = { [deepest.name]: { [prismaOp]: {} } };
1484
+ const leafFilter = relationFilter(deepest, prismaOp, {});
1295
1485
  if (relations.length === 1) {
1296
1486
  return leafFilter;
1297
1487
  }
@@ -1360,6 +1550,26 @@ function handleRelationalOperator(operator, operands, mapper) {
1360
1550
  */
1361
1551
  function buildComparisonFilter(fieldRef, operator, value) {
1362
1552
  const prismaOperator = assertDefined(CERBOS_TO_PRISMA_OPERATOR[operator], `Unsupported operator: ${operator}`);
1553
+ if (value === null) {
1554
+ assertNullOperandTranslatable(`\`${operator}\` against a null operand`, fieldRef.nullAttributeRepresentation);
1555
+ }
1556
+ // An attribute the caller sends as an explicit null holds a null VALUE in CEL, so equality
1557
+ // against a non-null operand is a definite FALSE and inequality a definite TRUE. Prisma leans
1558
+ // on SQL's UNKNOWN for both, which drops the row under either polarity, so the presence of the
1559
+ // column has to become part of the predicate. Confined to the equality family: an ordering
1560
+ // comparison against a null receiver is a no-overload error in CEL, which denies exactly as the
1561
+ // dropped row does.
1562
+ if (value !== null && isExplicitNullReference(fieldRef)) {
1563
+ const fieldName = getLeafField(fieldRef.path);
1564
+ if (operator === "eq") {
1565
+ return guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } });
1566
+ }
1567
+ if (operator === "ne") {
1568
+ return {
1569
+ NOT: guardedFieldFilter(fieldRef, { [fieldName]: { equals: value } }),
1570
+ };
1571
+ }
1572
+ }
1363
1573
  if (typeof value === "number" && !Number.isInteger(value)) {
1364
1574
  const fieldName = getLeafField(fieldRef.path);
1365
1575
  const lower = Math.floor(value);
@@ -1446,11 +1656,49 @@ function buildFieldToFieldFilter(operator, leftOperand, left, rightOperand, righ
1446
1656
  }
1447
1657
  const leftField = getLeafField(left.path);
1448
1658
  const rightField = getLeafField(right.path);
1449
- return {
1450
- [leftField]: {
1451
- [prismaOperator]: { _ref: rightField, _container: container },
1452
- },
1453
- };
1659
+ const fieldReference = { _ref: rightField, _container: container };
1660
+ // Two explicit nulls are EQUAL in CEL, and a null against a value is definitely NOT equal.
1661
+ // A bare field reference gives UNKNOWN for both, which drops the row under either polarity.
1662
+ const leftExplicit = isExplicitNullReference(left);
1663
+ const rightExplicit = isExplicitNullReference(right);
1664
+ // Mixing the two conventions across one comparison has no faithful rendering. The declared side
1665
+ // needs a definite answer for its NULL (CEL holds a null VALUE); the undeclared side needs
1666
+ // UNKNOWN for its NULL (a missing attribute, which CEL denies under both polarities). A definite
1667
+ // predicate returns rows the PDP refuses; a plain one drops rows the PDP allows. Refuse it
1668
+ // rather than pick a direction — declare both attributes, or neither.
1669
+ if (leftExplicit !== rightExplicit && (operator === "eq" || operator === "ne")) {
1670
+ throw new Error(`Cannot translate \`${operator}\` between two columns under mixed null conventions: ` +
1671
+ "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.");
1672
+ }
1673
+ if (leftExplicit && rightExplicit && (operator === "eq" || operator === "ne")) {
1674
+ const present = [];
1675
+ if (leftExplicit) {
1676
+ present.push({ [leftField]: { not: null } });
1677
+ }
1678
+ if (rightExplicit) {
1679
+ present.push({ [rightField]: { not: null } });
1680
+ }
1681
+ const equality = leftExplicit && rightExplicit
1682
+ ? {
1683
+ OR: [
1684
+ { AND: [{ [leftField]: null }, { [rightField]: null }] },
1685
+ { AND: [...present, { [leftField]: { equals: fieldReference } }] },
1686
+ ],
1687
+ }
1688
+ : { AND: [...present, { [leftField]: { equals: fieldReference } }] };
1689
+ return operator === "eq" ? equality : { NOT: equality };
1690
+ }
1691
+ // `ne` is the one operator whose Prisma spelling cannot carry a field reference: `not` nests a
1692
+ // NestedStringFilter, which has no `_ref` form, so `{ col: { not: { _ref } } }` is rejected by
1693
+ // the client rather than executed. Hoisting the negation to the top-level `NOT` combinator says
1694
+ // the same thing with a shape that does accept one — the same rewrite the explicit-null branch
1695
+ // above already performs. Three-valued logic is unchanged by the hoist: a NULL on either side
1696
+ // makes the inner equality UNKNOWN, and `NOT UNKNOWN` is still UNKNOWN, so the row stays out
1697
+ // under both polarities, which is what a missing attribute does on the check side.
1698
+ if (operator === "ne") {
1699
+ return { NOT: { [leftField]: { equals: fieldReference } } };
1700
+ }
1701
+ return { [leftField]: { [prismaOperator]: fieldReference } };
1454
1702
  }
1455
1703
  /**
1456
1704
  * Helper function to handle "in" operator
@@ -1500,9 +1748,17 @@ function handleStringOperator(operator, operands, mapper) {
1500
1748
  // Prisma emits LIKE without an ESCAPE clause and does not escape wildcard characters,
1501
1749
  // so a needle containing % or _ would match as a pattern instead of literally (e.g.
1502
1750
  // startsWith("100%") wrongly matches "100xdone"). Fail loudly rather than filter wrong.
1503
- if (/[%_]/.test(value)) {
1504
- throw new Error(`Cannot translate ${operator} with a needle containing LIKE metacharacters (% or _): ` +
1505
- "Prisma does not escape wildcards in string filters");
1751
+ //
1752
+ // A backslash is the same hazard reached from the other direction, and only an executed
1753
+ // PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
1754
+ // character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
1755
+ // DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
1756
+ // ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
1757
+ // silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
1758
+ if (/[%_\\]/.test(value)) {
1759
+ throw new Error(`Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
1760
+ "Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
1761
+ "escape character on PostgreSQL and MySQL");
1506
1762
  }
1507
1763
  const fieldName = getLeafField(receiver.path);
1508
1764
  return wrapRelations(receiver.relations, {
@@ -1552,19 +1808,6 @@ function handleStringOperator(operator, operands, mapper) {
1552
1808
  }
1553
1809
  throw new Error(`${operator} between two constants must be folded by the Cerbos planner`);
1554
1810
  }
1555
- /**
1556
- * Helper function to handle "isSet" operator
1557
- */
1558
- function handleIsSetOperator(operands, mapper) {
1559
- const nameOperand = getNamedOperand(operands, "Name operand is undefined");
1560
- const valueOperand = getValueOperand(operands, "Value operand is undefined");
1561
- const fieldRef = requireResolvedFieldReference(resolveOperand(nameOperand, mapper), "Name operand must resolve to a field reference");
1562
- const resolvedValue = requireResolvedValue(resolveOperand(valueOperand, mapper), "Value operand must resolve to a value");
1563
- const fieldName = getLeafField(fieldRef.path);
1564
- return wrapRelations(fieldRef.relations, {
1565
- [fieldName]: resolvedValue.value ? { not: null } : { equals: null },
1566
- });
1567
- }
1568
1811
  /**
1569
1812
  * Helper function to handle "hasIntersection" operator
1570
1813
  */
@@ -1775,7 +2018,7 @@ function wrapCollectionElementFilter(parts, elementFilter) {
1775
2018
  const inner = parts.restRelations.length > 0
1776
2019
  ? buildNestedRelationFilter(parts.restRelations, elementFilter)
1777
2020
  : elementFilter;
1778
- return { [parts.head.name]: { some: inner } };
2021
+ return relationFilter(parts.head, "some", inner);
1779
2022
  }
1780
2023
  function throwUnsupportedCollectionOperator(operator) {
1781
2024
  if (operator === "exists_one") {
@@ -1802,7 +2045,7 @@ function handleCollectionOperator(operator, operands, mapper) {
1802
2045
  case "exists": {
1803
2046
  // A NULL element keeps the per-element predicate UNKNOWN, so it can never create a
1804
2047
  // false positive here; a CEL error (deny) coincides with "no match" — no guard needed.
1805
- const filter = { [head.name]: { some: filterValue } };
2048
+ const filter = relationFilter(head, "some", filterValue);
1806
2049
  const unknownElement = buildUnknownElementFilter(parts);
1807
2050
  if (unknownElement !== undefined) {
1808
2051
  recordNestedCollectionUnknownFilter({
@@ -1815,12 +2058,12 @@ function handleCollectionOperator(operator, operands, mapper) {
1815
2058
  return filter;
1816
2059
  }
1817
2060
  case "except":
1818
- return { [head.name]: { some: { NOT: filterValue } } };
2061
+ return relationFilter(head, "some", { NOT: filterValue });
1819
2062
  case "all": {
1820
2063
  if (parts.restRelations.length > 0) {
1821
2064
  throw new Error("all() over a multi-hop relation chain is not supported");
1822
2065
  }
1823
- const base = { [head.name]: { every: filterValue } };
2066
+ const base = relationFilter(head, "every", filterValue);
1824
2067
  if (nullableFields.size === 0) {
1825
2068
  return base;
1826
2069
  }
@@ -1831,7 +2074,7 @@ function handleCollectionOperator(operator, operands, mapper) {
1831
2074
  return {
1832
2075
  AND: [
1833
2076
  base,
1834
- { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
2077
+ relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
1835
2078
  ],
1836
2079
  };
1837
2080
  }
@@ -1853,9 +2096,17 @@ function buildNegatedCollectionFilter(operator, operands, mapper) {
1853
2096
  }
1854
2097
  const parts = buildCollectionLambdaParts(operator, operands, mapper);
1855
2098
  const { head, filterValue, nullableFields } = parts;
2099
+ // An absent to-one parent must stay denied under negation rather than satisfying it
2100
+ // vacuously (#309).
2101
+ const leadingHopsExist = buildLeadingHopsExistFilter(head, parts.restRelations);
2102
+ const requireLeadingHops = (filter) => leadingHopsExist === undefined
2103
+ ? filter
2104
+ : { AND: [leadingHopsExist, filter] };
1856
2105
  switch (operator) {
1857
2106
  case "exists": {
1858
- const base = { NOT: { [head.name]: { some: filterValue } } };
2107
+ const base = requireLeadingHops({
2108
+ NOT: relationFilter(head, "some", filterValue),
2109
+ });
1859
2110
  const unknownElement = buildUnknownElementFilter(parts);
1860
2111
  if (unknownElement === undefined) {
1861
2112
  return base;
@@ -1874,17 +2125,17 @@ function buildNegatedCollectionFilter(operator, operands, mapper) {
1874
2125
  case "all":
1875
2126
  // !all is TRUE only with a definitive false witness, which also absorbs error
1876
2127
  // elements — exactly `some(NOT P)` in SQL (NULL columns keep NOT P UNKNOWN).
1877
- return { [head.name]: { some: { NOT: filterValue } } };
2128
+ return relationFilter(head, "some", { NOT: filterValue });
1878
2129
  case "except": {
1879
2130
  // !except(c,P) = every element definitively matches P.
1880
- const base = { [head.name]: { none: { NOT: filterValue } } };
2131
+ const base = requireLeadingHops(relationFilter(head, "none", { NOT: filterValue }));
1881
2132
  if (nullableFields.size === 0) {
1882
2133
  return base;
1883
2134
  }
1884
2135
  return {
1885
2136
  AND: [
1886
2137
  base,
1887
- { [head.name]: { none: buildNullWitnessFilter(nullableFields) } },
2138
+ relationFilter(head, "none", buildNullWitnessFilter(nullableFields)),
1888
2139
  ],
1889
2140
  };
1890
2141
  }
@@ -2223,14 +2474,17 @@ function handleOverlapsOperator(operands, mapper) {
2223
2474
  *
2224
2475
  * `startsWith` compiles to `LIKE` with no `ESCAPE` clause, so `%` and `_` in the prefix
2225
2476
  * match as wildcards instead of literally and widen the filter past what the PDP allows.
2226
- * `[` is unsafe for a separate reason: SQL Server opens a character class on `[` even when
2227
- * an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly rather
2228
- * than emit a filter that admits denied rows.
2477
+ * `\` is unsafe because PostgreSQL and MySQL treat it as the DEFAULT escape character with no
2478
+ * `ESCAPE` clause present, where SQLite has none at all — so the same prefix means different
2479
+ * things per provider. `[` is unsafe for a third reason: SQL Server opens a character class on
2480
+ * `[` even when an `ESCAPE` clause is declared, so it cannot be made literal at all. Fail loudly
2481
+ * rather than emit a filter that admits denied rows.
2229
2482
  */
2230
2483
  function assertLikeSafePrefix(prefix) {
2231
- if (/[%_\[]/.test(prefix)) {
2232
- throw new Error("Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
2233
- "Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one");
2484
+ if (/[%_\[\\]/.test(prefix)) {
2485
+ throw new Error("Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _, \\ or [): " +
2486
+ "Prisma emits LIKE without an ESCAPE clause, \\ is the default escape character on " +
2487
+ "PostgreSQL and MySQL, and [ opens a character class on SQL Server even with one");
2234
2488
  }
2235
2489
  }
2236
2490
  function handleFieldOverlaps(left, right) {