@cerbos/orm-prisma 3.3.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 (74) hide show
  1. package/README.md +243 -10
  2. package/lib/index.d.ts +102 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1754 -155
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -15
  7. package/src/adversarial.test.ts +1281 -0
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +54 -0
  10. package/src/generated/prisma-adversarial/client.ts +76 -0
  11. package/src/generated/{prisma → prisma-adversarial}/commonInputTypes.ts +131 -27
  12. package/src/generated/prisma-adversarial/enums.ts +15 -0
  13. package/src/generated/prisma-adversarial/internal/class.ts +264 -0
  14. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1332 -0
  15. package/src/generated/{prisma → prisma-adversarial}/internal/prismaNamespaceBrowser.ts +45 -47
  16. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  17. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  18. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  19. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  20. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1789 -0
  21. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  22. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  23. package/src/generated/prisma-adversarial/models.ts +18 -0
  24. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  25. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  26. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +356 -0
  27. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  28. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1371 -0
  29. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +177 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  31. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  32. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  33. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  34. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  35. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  36. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  37. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  38. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  39. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  40. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  41. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  42. package/src/generated/prisma-adversarial-v6/index.d.ts +11769 -0
  43. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  44. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  45. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  46. package/src/index.ts +2515 -219
  47. package/src/test-setup.adversarial-url.ts +18 -0
  48. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  49. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  50. package/src/test-setup.adversarial.v6.ts +5 -0
  51. package/src/test-setup.adversarial.v7.ts +12 -0
  52. package/src/translator.test.ts +1586 -0
  53. package/src/generated/prisma/internal/class.ts +0 -274
  54. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1333
  55. package/src/generated/prisma/models/Category.ts +0 -1412
  56. package/src/generated/prisma/models/Label.ts +0 -1276
  57. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  58. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  59. package/src/generated/prisma/models/Resource.ts +0 -2136
  60. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  61. package/src/generated/prisma/models/Tag.ts +0 -1276
  62. package/src/generated/prisma/models/User.ts +0 -1503
  63. package/src/generated/prisma/models.ts +0 -19
  64. package/src/generated/prisma-v6/index.d.ts +0 -13639
  65. package/src/index.test.ts +0 -6322
  66. package/src/test-setup.v6.ts +0 -5
  67. package/src/test-setup.v7.ts +0 -7
  68. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  69. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  70. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  71. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  72. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  73. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  74. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -0
package/README.md CHANGED
@@ -10,19 +10,36 @@ An adapter library that takes a [Cerbos](https://cerbos.dev) Query Plan ([PlanRe
10
10
 
11
11
  - Logical operators: `and`, `or`, `not`
12
12
  - Comparison operators: `eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`
13
- - String operations: `startsWith`, `endsWith`, `contains`, `isSet`
13
+ - String operations: `startsWith`, `endsWith`, `contains`
14
+ - Null checks: `eq`/`ne` against a null value map to `{ equals: null }` / `{ not: null }`. The
15
+ Cerbos planner emits no existence operator — `R.attr.x != null` arrives as a plain `ne`.
14
16
 
15
17
  #### Relation Operators
16
18
 
17
19
  - One-to-one: `is`, `isNot`
18
20
  - One-to-many/Many-to-many: `some`, `none`, `every`
19
- - Collection operators: `exists`, `exists_one`, `all`, `filter`, `except`
21
+ - Collection operators: `exists`, `all`, `except` (`exists_one` requires counting matches,
22
+ which Prisma where-filters cannot express — it throws rather than silently degrading to
23
+ `exists`; `filter` only appears inside other expressions)
20
24
  - Set operations: `hasIntersection`
21
25
 
22
- #### String Concatenation
26
+ #### Arithmetic
23
27
 
24
- - `add` operator for string/numeric concatenation in conditions
25
- - Algebraic solving: `P.attr.context == "projects:" + R.attr.id` → `{ id: { equals: "123" } }`
28
+ - `add`, `sub`, `mult`, `div` with a constant side are solved algebraically to a plain column
29
+ comparison: `R.attr.aNumber + 1 > 2` → `{ aNumber: { gt: 1 } }` (Prisma where-filters cannot
30
+ express column arithmetic). Multiplying/dividing by a negative constant mirrors directional
31
+ operators. Arithmetic on both sides of a comparison, division BY a column, and equality or
32
+ inequality over fractional addition throw. The latter cannot be solved reversibly in IEEE-754
33
+ arithmetic.
34
+ - String concatenation solving: `P.attr.context == "projects:" + R.attr.id` → `{ id: { equals: "123" } }`
35
+
36
+ #### Field-to-field comparisons
37
+
38
+ Comparisons between two columns of the same model compile to
39
+ [Prisma field references](https://www.prisma.io/docs/orm/reference/prisma-client-reference#compare-columns-in-the-same-table).
40
+ Pass the Prisma model name via the `model` option (root columns) or `relation.model` in the
41
+ mapper (columns of a related model inside a collection expression). Comparisons across models
42
+ throw — Prisma only supports references between fields of the same model.
26
43
 
27
44
  #### Hierarchy Operators
28
45
 
@@ -37,6 +54,202 @@ An adapter library that takes a [Cerbos](https://cerbos.dev) Query Plan ([PlanRe
37
54
  - Collection mapping and filtering
38
55
  - Complex condition combinations
39
56
  - Type-safe field mappings
57
+ - Timestamp comparisons against Prisma `DateTime` columns. Mark the field mapping with
58
+ `valueType: "dateTime"`; applying `timestamp()` to an untyped/string mapping throws.
59
+ Literals must be strict RFC 3339 instants in CEL's supported year 0001–9999 range and
60
+ exactly representable at millisecond precision (fractional digits after the third may
61
+ only be zero). The mapped column/database must preserve that precision.
62
+ - Outer-column references inside collection expressions (e.g.
63
+ `R.attr.tags.exists(t, t.name == "x" && R.attr.aBool)`) are hoisted or case-split so every
64
+ filter lands on the model it belongs to
65
+ - Three-valued-logic guards for nullable element columns: mark a relation field as
66
+ `nullable: true` in the mapper and collection macros (`all`, negated `exists`,
67
+ `hasIntersection` over `map`) exclude rows whose elements hold `NULL` in that column,
68
+ matching Cerbos's treatment of a missing attribute as a deny
69
+
70
+ #### Known limitations (loud failures, never silently-wrong filters)
71
+
72
+ - LIKE wildcards: Prisma emits `LIKE` without an `ESCAPE` clause, so `contains`/`startsWith`/
73
+ `endsWith` with a needle containing `%` or `_`, or with a column-valued needle, throws.
74
+ (A constant *receiver* with a column needle — `"a-b".startsWith(R.attr.x)` — is translated
75
+ exactly by enumerating candidate needles into an `in` filter.)
76
+ - Hierarchy prefixes: `ancestorOf`, `descendentOf` and `overlaps` narrow a column with a
77
+ `startsWith`, so they throw when the constant hierarchy contains `%`, `_` or `[`. `[` is
78
+ rejected as well as the two LIKE wildcards because SQL Server opens a character class on
79
+ `[` even when an `ESCAPE` clause is declared, so it cannot be matched literally at all.
80
+ - Counting: `exists_one`, `size()` thresholds other than empty/non-empty, and string-length
81
+ comparisons throw (`_count` is not supported inside Prisma `where`).
82
+ - Cross-model column comparisons throw (Prisma field references are same-model only).
83
+ This includes membership between an outer scalar column and a related collection column.
84
+
85
+ #### Database collation is an authorization invariant
86
+
87
+ Cerbos string comparisons are case-sensitive. Prisma delegates comparison semantics to the
88
+ database collation, so a case-insensitive or accent-insensitive collation can make a generated
89
+ authorization filter return rows that Cerbos would deny. Treat the database collation used by
90
+ mapped authorization columns as part of the policy contract:
91
+
92
+ - PostgreSQL: use a deterministic, case-sensitive collation and avoid `citext` or an
93
+ insensitive Prisma query mode for mapped fields.
94
+ - MySQL/MariaDB: choose a case-sensitive (`_cs`) or binary collation rather than the common
95
+ case-insensitive (`_ci`) defaults.
96
+ - SQL Server: use a case-sensitive (`_CS_`) collation rather than a case-insensitive (`_CI_`)
97
+ collation.
98
+ - SQLite: do not apply `COLLATE NOCASE` to mapped fields, and **also set
99
+ `PRAGMA case_sensitive_like = ON`** — see below.
100
+
101
+ **On SQLite, collation is not enough.** `contains`, `startsWith` and `endsWith` lower to `LIKE`,
102
+ and SQLite's `LIKE` is case-insensitive for ASCII *regardless of the column's collation*: a
103
+ `COLLATE BINARY` column answers `= 'one'` case-sensitively and `LIKE '%one%'` case-insensitively
104
+ in the same query. Only `PRAGMA case_sensitive_like = ON` changes it, and the pragma is
105
+ per-connection, so it must be set on every connection the application uses — not once at schema
106
+ creation.
107
+
108
+ That distinction is why this was missed for so long: the corpus's `cs-eq` action proved equality
109
+ was case-sensitive on SQLite, and equality is the one operator collation *does* govern. The
110
+ `cs-contains`, `cs-startswith` and `cs-endswith` actions now prove the string operators
111
+ separately, and without the pragma this adapter over-grants every one of them on SQLite.
112
+
113
+ The adapter cannot override a column's collation, or set a pragma, from inside a Prisma `where`
114
+ filter. See Prisma's
115
+ [case-sensitivity documentation](https://docs.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity)
116
+ for provider-specific details.
117
+
118
+ ### NULL attribute representation
119
+
120
+ `R.attr.x == null` compiles to the same `eq(x, null)` plan node however your application represents
121
+ a NULL column in the attributes it sends to `check()`, so the adapter cannot infer the convention
122
+ and has to be told which one you use.
123
+
124
+ | attributes you send for a NULL column | `check()` on that row | `IS NULL` filter |
125
+ | --- | --- | --- |
126
+ | `{"x": null}` — explicit null | allow | selects it — aligned |
127
+ | `{}` — attribute omitted | **deny** (CEL missing-attribute error) | selects it — **over-grants** |
128
+
129
+ `nullAttributeRepresentation` defaults to `"explicit"`, preserving the historical `IS NULL`
130
+ translation. If your application omits attributes for NULL columns, set it to `"omitted"`: the
131
+ adapter then rejects every null comparison operand instead of emitting a filter that returns rows
132
+ the PDP denies.
133
+
134
+ ```ts
135
+ queryPlanToPrisma({ queryPlan, mapper, nullAttributeRepresentation: "omitted" });
136
+ ```
137
+
138
+ The rejection is deliberately wider than the shapes that actually over-grant — `x != null` and
139
+ `!(x == null)` are aligned under both conventions — because Prisma applies negation by wrapping
140
+ (`{ NOT: ... }`) rather than pushing it into the leaf, so a leaf cannot tell whether an enclosing
141
+ `not` will flip `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is
142
+ correct under any nesting. See [#302](https://github.com/cerbos/query-plan-adapters/issues/302).
143
+
144
+ ### Declare the convention per attribute
145
+
146
+ The option above is a whole-call default, and one policy suite can legitimately use both
147
+ conventions: the same column mapped twice, sent as an explicit null under one attribute name and
148
+ omitted under another. Declare it on the mapping instead and the call-level option only covers what
149
+ the mapping does not:
150
+
151
+ ```ts
152
+ const mapper = {
153
+ // sent as an explicit null when the column is NULL
154
+ "request.resource.attr.owner": {
155
+ field: "ownerId",
156
+ nullAttributeRepresentation: "explicit",
157
+ },
158
+ // omitted when the column is NULL — the call-level default applies
159
+ "request.resource.attr.department": { field: "department" },
160
+ };
161
+ ```
162
+
163
+ Declaring `"explicit"` asserts two things: the column can be NULL, **and** a NULL reaches `check()`
164
+ as an explicit null. The equality family (`eq`, `ne`, `in`) over that attribute is then rendered so
165
+ it can never be SQL UNKNOWN — CEL holds a null *value* under this convention, so `null != "x"` is
166
+ TRUE and the row must come back, while UNKNOWN would drop it under *both* polarities. Ordering and
167
+ string operators are left alone: a null receiver raises a no-overload error in CEL, which denies
168
+ exactly as UNKNOWN does.
169
+
170
+ Leaving an attribute undeclared keeps the historical rendering — so nothing changes for a mapping
171
+ that says nothing, and `!=` against a constant keeps under-granting the NULL rows until you declare
172
+ it.
173
+
174
+ **Declare both sides of a field-to-field comparison, or neither.** Mixing the conventions across one
175
+ comparison has no faithful rendering — the declared side needs a definite answer for its NULL, the
176
+ undeclared side needs UNKNOWN — so the adapter throws rather than picking a direction. See
177
+ [#308](https://github.com/cerbos/query-plan-adapters/issues/308) and
178
+ [ADR 0004](../docs/adr/0004-the-null-convention-is-a-property-of-the-attribute.md).
179
+
180
+
181
+ ### Conformance contract
182
+
183
+ The adapter is differentially tested against Cerbos PDP 0.54.0 `checkResource` decisions using 21 hostile seed rows, both Prisma 6 and 7, and both SQLite and PostgreSQL. The Spring Data adapter defines the reference semantics for this compatibility snapshot.
184
+
185
+ | Classification | Coverage |
186
+ | --- | --- |
187
+ | Oracle-tested | 136 reference actions |
188
+ | Fail-closed | 51 reference actions plus the 10 reference-unsupported shapes (61 actions total) |
189
+ | Representation-dependent | `null-eq-missing` — rejected under `nullAttributeRepresentation: "omitted"`; translated as `IS NULL` under the default, which over-grants if the caller omits attributes for NULL columns |
190
+ | Attribute NULL convention | The equality family (`eq`, `ne`, `in`) over an attribute the caller sends as an explicit null renders definitely, so a NULL row is included where CEL's null *value* says it should be. Declare it per attribute — `nullAttributeRepresentation: "explicit"` on the mapper entry — or the historical rendering applies and `!=` against a constant under-grants those rows (cerbos/query-plan-adapters#308) |
191
+ | Known planner divergence | `has()` on a missing attribute is folded by the Cerbos planner to `ALWAYS_ALLOWED`, while `checkResource` denies the missing-attribute rows. Until the planner is fixed, use `R.attr.x != null` for database-backed attributes instead of `has(R.attr.x)` |
192
+
193
+ The fail-closed set consists of literal `LIKE` cases Prisma cannot escape safely, cross-model field references, arbitrary relation counts and string lengths, `exists_one`, unsolved column arithmetic, sub-millisecond `now()` thresholds, the reference probes for regex, ordered indexing, and `timestamp()` over a string field, `mod`, a positional read of a scalar list, and list equality over a `map()` projection. Supported timestamp plans require a mapper entry with `valueType: "dateTime"` and a strict, millisecond-exact RFC 3339 literal in CEL's supported instant range. These shapes throw instead of producing a broader authorization filter. Every fail-closed shape's error message is pinned in the shared corpus (`conformance/actions.json`) and asserted by this adapter's conformance run, so a classification proves the throw names its declared mechanism rather than merely that something threw.
194
+
195
+ The `where` input each of these actions produces is pinned separately, in the translator unit test (`npm test`) — every corpus action, classified there exactly once as an emitted filter, an unconditional plan kind, or a throw. That is what makes a change to the emitted SQL show up as a diff even when it selects the same rows from the corpus seeds.
196
+
197
+ #### Providers the contract is proved on
198
+
199
+ The classification above holds where the corpus is **executed**, not where the emitted filter merely looks plausible. Until [#320](https://github.com/cerbos/query-plan-adapters/issues/320) it was executed on SQLite only, and the Prisma 6/7 matrix is an *engine* matrix, not a provider one — it says nothing about how a provider coerces a value or reads a `LIKE` pattern. The suite now runs on SQLite and PostgreSQL:
200
+
201
+ The store and the Prisma major are independent dimensions, so there are four runs and CI does all four:
202
+
203
+ ```bash
204
+ npm run test:adversarial:v7 # SQLite, Prisma 7
205
+ npm run test:adversarial:v6 # SQLite, Prisma 6
206
+ npm run test:adversarial:postgres:v7 # PostgreSQL, Prisma 7 (testcontainers)
207
+ npm run test:adversarial:postgres:v6 # PostgreSQL, Prisma 6 (testcontainers)
208
+ ```
209
+
210
+ MySQL, SQL Server and CockroachDB are **not** executed. Where a fail-closed reason names one of them, it is reasoned from that provider's documented `LIKE` and escaping behaviour rather than observed.
211
+
212
+ > **Breaking change in this release.** `endsWith`/`contains`/`startsWith` with a needle containing a **backslash**, and hierarchy prefixes containing one, now throw instead of returning a filter. A backslash is the default `LIKE` escape character on PostgreSQL and MySQL and has no meaning at all on SQLite, so one needle meant two different things: `contains("a\\b")` matched `"ab"` on PostgreSQL — a row the PDP denies — and `endsWith("\\")` failed the query outright with `SQLSTATE 22025`. There is no needle spelling that is correct on every provider without an `ESCAPE` clause Prisma does not emit, so the shape is refused. If you match on backslashes, compare the whole value with `==` or move the predicate out of the policy.
213
+
214
+ ### Mapping hazards
215
+
216
+ The conformance contract above proves the *plan* side — given a policy shape, does the filter select the rows `check()` allows. The other half is the *mapping*: **the records the nested filter reads must be the records the application put into the resource attributes.** Six ways that can break are catalogued in the shared corpus, and every adapter has to record a position on each of them.
217
+
218
+ This adapter names a Prisma relation, so the mapping *looks* as though the ORM will apply the application's own narrowing to the nested `some`/`every`/`none`. **It will not.** Prisma has no schema-level filtered relation and no `@Where` equivalent; a `where` injected by a client extension or by `$extends`/middleware rewrites the top-level query and leaves nested relation filters untouched. In corpus terms this adapter is class 1 — a **bare-table subquery** — despite naming a relation.
219
+
220
+ Where the application narrows its own reads of a related model, declare the same predicate as [`subqueryFilter`](#declaring-the-applications-own-predicate) on the relation and the adapter reproduces it. Declaring nothing emits exactly the filter this adapter emitted before the field existed — it cannot detect the omission, so silence is not a warning.
221
+
222
+ | Hazard | Position | Mechanism to check |
223
+ |---|---|---|
224
+ | Filtered association | **Caller-owned**, reproducible with `subqueryFilter` | A `$extends`/`$use` client extension or middleware that injects a `where` for the related model, or a repository helper that always appends one. None of them rewrite the nested filter this adapter returns |
225
+ | Default scope on the target model | **Caller-owned**, reproducible with `subqueryFilter` | A soft-delete column (`deletedAt: null`), a tenant column, a `published` flag — anything every application read of the related model filters on. Prisma has no default-scope construct, so the convention lives in your own query code and only you can see it |
226
+ | Subtype discrimination | **Caller-owned**, reproducible with `subqueryFilter` | A `type`/`kind` discriminator column where one model holds several row kinds. Declare `{ type: "…" }` |
227
+ | To-one relation used as a collection | **Rejected by Prisma** | A `type: "one"` mapping compiles to `is`, an argument Prisma only accepts on a relation its own schema declares to-one, and `@relation(references: …)` must already point at a unique field. Mapping a to-many relation as `type: "one"` is therefore a query-validation error from Prisma, not a silently wider subquery |
228
+ | Composite association key | **Reproduced by Prisma** | Prisma resolves multi-column foreign keys itself from `@relation(fields: […], references: […])`. The mapping names the relation, never its columns, so there is no key for the adapter to get wrong |
229
+ | Absent to-one parent | **Reproduced**, and proved by the corpus (`w1-all-chain`, `rel-not-bool-hop` and siblings) | None — every operator reached through a relation requires its to-one hops separately, so a missing parent stays denied under both polarities ([#309](https://github.com/cerbos/query-plan-adapters/issues/309), [#315](https://github.com/cerbos/query-plan-adapters/issues/315), [#375](https://github.com/cerbos/query-plan-adapters/issues/375)). **Behaviour change in #375:** this previously held only for a chain of two or more relations. A negation over a SINGLE to-one hop — `!R.attr.parent.aBool` against a `type: "one"` mapping — returned every row whose relation was absent. It now emits `AND: [{ parent: { is: {} } }, { NOT: … }]` and returns fewer rows: an over-grant fix, and consumer-visible for any policy with that shape |
230
+
231
+ #### Declaring the application's own predicate
232
+
233
+ ```ts
234
+ const result = queryPlanToPrisma({
235
+ queryPlan,
236
+ mapper: {
237
+ "request.resource.attr.tags": {
238
+ relation: {
239
+ name: "tags",
240
+ type: "many",
241
+ field: "name",
242
+ // Exactly the predicate your own reads of `Tag` apply.
243
+ subqueryFilter: { deletedAt: null, kind: "label" },
244
+ },
245
+ },
246
+ },
247
+ });
248
+ ```
249
+
250
+ `subqueryFilter` is a Prisma where-input over the *related* model. It is ANDed into the nested filter, so it narrows the records the subquery *examines* rather than the records it requires, and it applies to every operator reached through the relation — `exists`, `all`, `except`, membership, `hasIntersection`, emptiness checks — and to the hop-existence guard, so an intermediate hop must exist *and* be visible.
251
+
252
+ `all()` is the one operator that cannot simply absorb the predicate: `every: AND(declared, P)` would *require* every record to satisfy the declaration, which is the opposite of ignoring what the application hides. It is rewritten to `none: AND(declared, NOT P)` — no visible record violates `P`. Note the consequence, which is correct rather than surprising: if the declaration hides every record of a relation, `all()` over it is vacuously true, exactly as it is for the application, which sends `check()` an empty list for the same reason.
40
253
 
41
254
  ## Requirements
42
255
 
@@ -46,7 +259,7 @@ An adapter library that takes a [Cerbos](https://cerbos.dev) Query Plan ([PlanRe
46
259
 
47
260
  ## System Requirements
48
261
 
49
- - Node.js >= 20.0.
262
+ - Node.js >= 22.0.
50
263
  - Prisma CLI & Client >= 6.0 (v7 supported)
51
264
  - A database supported by Prisma (SQLite/PostgreSQL/MySQL/etc.) so the Prisma client can communicate with stored data
52
265
 
@@ -217,7 +430,9 @@ const result = queryPlanToPrisma({
217
430
 
218
431
  ### Relations Mapping
219
432
 
220
- Relations are mapped with their types and optional field configurations. Fields can be automatically inferred from the path if not explicitly mapped:
433
+ Relations are mapped with their types and optional field configurations. Fields can be automatically inferred from the path if not explicitly mapped.
434
+
435
+ The nested filters this produces read the related model unfiltered — Prisma has no schema-level filtered relation, and an injected `where` does not reach them. If your own reads of that model apply a predicate, declare it as `subqueryFilter` on the relation. See [Mapping hazards](#mapping-hazards).
221
436
 
222
437
  ```ts
223
438
  const result = queryPlanToPrisma({
@@ -385,9 +600,11 @@ const result = await primsa.resource.findMany({
385
600
  npm test
386
601
  ```
387
602
 
388
- > **Note:** The suite seeds `prisma/dev.db` and invokes `prisma db push --force-reset`. Only run it against disposable development databases.
603
+ This is the **translator unit test**: for every action in the shared conformance corpus, the `where` input this adapter emits. It reads its plans from `conformance/wire-fixtures/` — the golden `PlanResources` responses captured against the pinned Cerbos version — so it needs no Cerbos sidecar, no database, and no generated Prisma client, and it is engine-agnostic (there is no v6/v7 split). It also pins the shapes the adapter refuses, with the message `conformance/actions.json` records, and the parts of the mapper contract no policy can reach: the `nullAttributeRepresentation` boundary, `subqueryFilter`, and malformed input.
604
+
605
+ Every wire fixture must be classified there exactly once, so adding a corpus action fails this suite until someone records the filter it produces. See [ADR 0006](../docs/adr/0006-translator-unit-tests-take-their-plans-from-wire-fixtures.md).
389
606
 
390
- The tests populate Prisma with fixture data and assert query results directly against those fixtures, covering scalar and relation `in` operators, collection behaviour (including `except`), nested relations, and lambda expressions.
607
+ Whether those filters return the rows the PDP allows is a separate question, answered by the adversarial suite — see [Conformance contract](#conformance-contract) above, which lists the four runs and what each one covers. That suite does need a Cerbos sidecar, and resets `prisma/dev-adversarial.db` with `prisma db push --force-reset`, so run it only against disposable development databases.
391
608
 
392
609
  ## Types
393
610
 
@@ -432,9 +649,12 @@ The mapper configuration is also fully typed:
432
649
  ```ts
433
650
  type MapperConfig = {
434
651
  field?: string;
652
+ valueType?: "dateTime";
653
+ nullable?: boolean;
435
654
  relation?: {
436
655
  name: string;
437
656
  type: "one" | "many";
657
+ model?: string;
438
658
  field?: string;
439
659
  fields?: {
440
660
  [key: string]: MapperConfig; // Recursive for nested fields
@@ -449,6 +669,19 @@ type Mapper = { [key: string]: MapperConfig } | ((key: string) => MapperConfig);
449
669
 
450
670
  A complete example application using this adapter can be found at [https://github.com/cerbos/express-prisma-cerbos](https://github.com/cerbos/express-prisma-cerbos)
451
671
 
672
+ This repository also carries a runnable [`example/`](example/), which installs the adapter from the
673
+ artifact `npm publish` would upload and exercises it against a live PDP over the shared
674
+ [demo domain](../demo/README.md):
675
+
676
+ ```bash
677
+ # from the repository root
678
+ demo/scripts/run-example.sh prisma
679
+ ```
680
+
681
+ Unlike the test suites, it resolves the adapter through its **published** surface — the `exports`
682
+ map, `types`, the `files` allowlist, and the peer range — and covers usage shapes past a single
683
+ flat query: pagination, and the adapter's filter composed with an application-owned filter.
684
+
452
685
  ## Resources
453
686
 
454
687
  ### Documentation
@@ -474,4 +707,4 @@ A complete example application using this adapter can be found at [https://githu
474
707
 
475
708
  ## License
476
709
 
477
- Apache 2.0 - See [LICENSE](../LICENSE) for more information.
710
+ Apache 2.0.
package/lib/index.d.ts CHANGED
@@ -1,19 +1,119 @@
1
- import { PlanResourcesResponse, PlanKind } from "@cerbos/core";
1
+ import { PlanKind } from "@cerbos/core";
2
+ import type { PlanResourcesResponse } from "@cerbos/core";
2
3
  export { PlanKind };
3
4
  export type PrismaFilter = Record<string, any>;
4
5
  export type MapperConfig = {
5
6
  field?: string;
7
+ /**
8
+ * Declares the database value kind when the Cerbos expression wraps a field in a typed
9
+ * constructor. Date-time metadata is required for timestamp(field) so a string column is
10
+ * never silently treated as a temporal column merely because it happens to contain ISO text.
11
+ */
12
+ valueType?: "dateTime";
13
+ /**
14
+ * Marks the mapped column as nullable in the database. Cerbos treats a missing attribute as
15
+ * an evaluation error (deny), which matches SQL three-valued logic for simple predicates —
16
+ * but relation subqueries (some/every/none) collapse UNKNOWN to false at the EXISTS boundary,
17
+ * so collection macros over elements with NULL fields need an explicit guard to stay
18
+ * deny-aligned. Declaring nullability here enables those guards.
19
+ */
20
+ nullable?: boolean;
21
+ /**
22
+ * Declares that this column can be SQL NULL **and** how the caller represents that NULL in the
23
+ * attributes it sends to `check()`. Declaring it asserts both facts; leaving it undeclared means
24
+ * "treat this column as NOT NULL", which is the historical rendering.
25
+ *
26
+ * This is per attribute rather than per call because one policy suite can legitimately mix the
27
+ * two conventions — the same column can be mapped twice, sent as an explicit null under one
28
+ * attribute name and omitted under another. The call-level option cannot express that, which is
29
+ * what made cerbos/query-plan-adapters#308 unfixable with #302's option alone.
30
+ *
31
+ * - `"explicit"` — a NULL column is sent as an explicit `null` attribute, so CEL holds a null
32
+ * VALUE. `null != "x"` is then TRUE and `null == "x"` is FALSE, both definite, while Prisma's
33
+ * `{ not: "x" }` drops the row under both polarities. The equality family (`equals`, `not`,
34
+ * `in`) is therefore rendered so it can never depend on SQL's UNKNOWN.
35
+ * - `"omitted"` — a NULL column sends no attribute, so CEL raises a missing-attribute error and
36
+ * `check()` denies. Dropping the row is already right, so the rendering is unchanged; what the
37
+ * declaration adds is the same null-operand rejection the call-level `"omitted"` performs,
38
+ * scoped to this attribute.
39
+ *
40
+ * Only the equality family is affected. `lt`/`lte`/`gt`/`gte` and the string operators raise a
41
+ * no-overload error on a null receiver in CEL, which denies under both polarities exactly as a
42
+ * dropped row does, so they are left alone.
43
+ *
44
+ * Distinct from `nullable` above, which declares that a *relation element* column can be NULL so
45
+ * collection macros gain their three-valued guards. The two are independent: `nullable` is about
46
+ * what a subquery does with an UNKNOWN element, this is about what the caller sent to the PDP.
47
+ *
48
+ * See https://github.com/cerbos/query-plan-adapters/issues/308.
49
+ */
50
+ nullAttributeRepresentation?: NullAttributeRepresentation;
6
51
  relation?: {
7
52
  name: string;
8
53
  type: "one" | "many";
54
+ /**
55
+ * The Prisma model name of the related record (e.g. "Tag"). Required only for
56
+ * field-to-field comparisons between two columns of the related model, which compile to
57
+ * Prisma field references and need the model name as their container.
58
+ */
59
+ model?: string;
9
60
  field?: string;
10
61
  fields?: Record<string, MapperConfig>;
62
+ /**
63
+ * The predicate the application itself applies when it reads this relation — a soft-delete
64
+ * flag, a tenant column, a subtype discriminator, whatever narrows the rows that became the
65
+ * resource attributes.
66
+ *
67
+ * Prisma has no schema-level filtered relation. A `where` injected by a client extension or
68
+ * middleware rewrites the *top-level* query, never the nested `some`/`every`/`none` this
69
+ * adapter generates, so a narrowing the application applies to its own reads does not reach
70
+ * the subquery — and the subquery then matches records the application never serialised, a
71
+ * filter that returns rows the PDP denies. Declaring the predicate here restores the
72
+ * equality; the adapter cannot detect the omission, which is why the field is optional and
73
+ * silence is not a warning. See "Mapping hazards" in the README.
74
+ *
75
+ * It is a Prisma where-input over the RELATED model, ANDed into the nested filter, and it
76
+ * constrains every operator reached through this relation under both polarities. `every` is
77
+ * rewritten to the equivalent `none` so the restriction narrows the records examined rather
78
+ * than the records required to match.
79
+ *
80
+ * ```ts
81
+ * relation: { name: "tags", type: "many", subqueryFilter: { deletedAt: null } }
82
+ * ```
83
+ */
84
+ subqueryFilter?: PrismaFilter;
11
85
  };
12
86
  };
13
87
  export type Mapper = Record<string, MapperConfig> | ((key: string) => MapperConfig);
88
+ /**
89
+ * How the caller represents a NULL column when building the attributes it sends to `check()`.
90
+ *
91
+ * The planner emits the same `eq(attr, null)` node either way, so the plan cannot reveal which
92
+ * convention is in use and the adapter has to be told.
93
+ *
94
+ * - `"explicit"` (default) — a NULL column is sent as an explicit `null` attribute. CEL compares
95
+ * `null == null`, so `IS NULL` selects exactly the rows `check()` allows.
96
+ * - `"omitted"` — a NULL column sends no attribute at all. CEL then raises a missing-attribute
97
+ * error, which Cerbos treats as a deny, so a filter that *selects* NULL rows returns rows the
98
+ * PDP denies. Null comparison operands are rejected instead of translated.
99
+ *
100
+ * See https://github.com/cerbos/query-plan-adapters/issues/302.
101
+ */
102
+ export type NullAttributeRepresentation = "explicit" | "omitted";
14
103
  export interface QueryPlanToPrismaArgs {
15
104
  queryPlan: PlanResourcesResponse;
16
105
  mapper?: Mapper;
106
+ /**
107
+ * The Prisma model name the generated filter targets (e.g. "Resource"). Required only for
108
+ * field-to-field comparisons between two root columns, which compile to Prisma field
109
+ * references and need the model name as their container.
110
+ */
111
+ model?: string;
112
+ /**
113
+ * Which NULL-column representation the caller uses when building `check()` attributes.
114
+ * Defaults to `"explicit"`, preserving the historical `IS NULL` translation.
115
+ */
116
+ nullAttributeRepresentation?: NullAttributeRepresentation;
17
117
  }
18
118
  export type QueryPlanToPrismaResult = {
19
119
  kind: PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED;
@@ -24,5 +124,5 @@ export type QueryPlanToPrismaResult = {
24
124
  /**
25
125
  * Converts a Cerbos query plan to a Prisma filter.
26
126
  */
27
- export declare function queryPlanToPrisma({ queryPlan, mapper, }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult;
127
+ export declare function queryPlanToPrisma({ queryPlan, mapper, model, nullAttributeRepresentation, }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult;
28
128
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EAErB,QAAQ,EAET,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,QAAQ,EAAE,CAAC;AAYpB,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAE/C,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE;QACT,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;KACvC,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,MAAM,GACd,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,GAC5B,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC,CAAC;AAEpC,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,qBAAqB,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,uBAAuB,GAC/B;IACE,IAAI,EAAE,QAAQ,CAAC,cAAc,GAAG,QAAQ,CAAC,aAAa,CAAC;CACxD,GACD;IACE,IAAI,EAAE,QAAQ,CAAC,WAAW,CAAC;IAC3B,OAAO,EAAE,YAAY,CAAC;CACvB,CAAC;AAmKN;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,MAAW,GACZ,EAAE,qBAAqB,GAAG,uBAAuB,CAiBjD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACxC,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,QAAQ,EAAE,CAAC;AAqGpB,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAE/C,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,2BAA2B,CAAC,EAAE,2BAA2B,CAAC;IAC1D,QAAQ,CAAC,EAAE;QACT,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;QACrB;;;;WAIG;QACH,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QACtC;;;;;;;;;;;;;;;;;;;;;WAqBG;QACH,cAAc,CAAC,EAAE,YAAY,CAAC;KAC/B,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,MAAM,GACd,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,GAC5B,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC,CAAC;AAEpC;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,2BAA2B,GAAG,UAAU,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,qBAAqB,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,2BAA2B,CAAC,EAAE,2BAA2B,CAAC;CAC3D;AAED,MAAM,MAAM,uBAAuB,GAC/B;IACE,IAAI,EAAE,QAAQ,CAAC,cAAc,GAAG,QAAQ,CAAC,aAAa,CAAC;CACxD,GACD;IACE,IAAI,EAAE,QAAQ,CAAC,WAAW,CAAC;IAC3B,OAAO,EAAE,YAAY,CAAC;CACvB,CAAC;AAmcN;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,MAAW,EACX,KAAK,EACL,2BAAwC,GACzC,EAAE,qBAAqB,GAAG,uBAAuB,CA6CjD"}