@cerbos/orm-prisma 4.0.0 → 5.0.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 (203) hide show
  1. package/README.md +611 -460
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +91 -8
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2301
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +31 -18
  95. package/src/adversarial.test.ts +488 -488
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +597 -0
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +20 -0
  104. package/src/generated/prisma-adversarial/client.ts +20 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +44 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
  114. package/src/generated/prisma-adversarial/models.ts +4 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
  117. package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
  118. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  121. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  130. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  131. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  132. package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
  133. package/src/generated/prisma-adversarial-pg/client.ts +86 -0
  134. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
  135. package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
  136. package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
  137. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
  138. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
  139. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  140. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  141. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  142. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  143. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  144. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  148. package/src/generated/prisma-adversarial-pg/models.ts +20 -0
  149. package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
  150. package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
  151. package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
  152. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
  153. package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
  154. package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
  155. package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
  156. package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
  157. package/src/hierarchy.ts +405 -0
  158. package/src/index.ts +134 -3325
  159. package/src/literals.ts +363 -0
  160. package/src/mapping.ts +474 -0
  161. package/src/narrowing.ts +33 -0
  162. package/src/plan.ts +125 -0
  163. package/src/presence.ts +114 -0
  164. package/src/regex.ts +596 -0
  165. package/src/relations.ts +226 -0
  166. package/src/rewrite.ts +524 -0
  167. package/src/solve.ts +96 -0
  168. package/src/strings.ts +152 -0
  169. package/src/ternary.ts +359 -0
  170. package/src/test-setup.adversarial-url.ts +21 -0
  171. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  172. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  173. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  174. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  175. package/src/test-setup.adversarial.v7.ts +1 -0
  176. package/src/timestamp.ts +308 -0
  177. package/src/translate.ts +419 -0
  178. package/src/translator.test.ts +766 -0
  179. package/src/types.ts +1310 -0
  180. package/src/value.ts +16 -0
  181. package/src/generated/prisma/browser.ts +0 -59
  182. package/src/generated/prisma/internal/class.ts +0 -274
  183. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
  184. package/src/generated/prisma/models/Category.ts +0 -1412
  185. package/src/generated/prisma/models/Label.ts +0 -1276
  186. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  187. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  188. package/src/generated/prisma/models/Resource.ts +0 -2196
  189. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  190. package/src/generated/prisma/models/Tag.ts +0 -1276
  191. package/src/generated/prisma/models/User.ts +0 -1503
  192. package/src/generated/prisma/models.ts +0 -19
  193. package/src/generated/prisma-v6/index.d.ts +0 -13726
  194. package/src/index.test.ts +0 -8885
  195. package/src/test-setup.v6.ts +0 -5
  196. package/src/test-setup.v7.ts +0 -7
  197. /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
  198. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
  199. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
  200. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
  201. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
  202. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
  203. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
package/README.md CHANGED
@@ -1,553 +1,704 @@
1
1
  # Cerbos + Prisma ORM Adapter
2
2
 
3
- An adapter library that takes a [Cerbos](https://cerbos.dev) Query Plan ([PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)) response and converts it into a [Prisma](https://prisma.io) where clause object. This is designed to work alongside a project using the [Cerbos Javascript SDK](https://github.com/cerbos/cerbos-sdk-javascript).
3
+ Converts a [Cerbos](https://cerbos.dev) query plan ([PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan))
4
+ into a [Prisma](https://prisma.io) `where` input, so `findMany` returns only the records a principal
5
+ is allowed to see. Use it with the [Cerbos JavaScript SDK](https://github.com/cerbos/cerbos-sdk-javascript).
4
6
 
5
- ## Features
6
-
7
- ### Supported Operators
8
-
9
- #### Basic Operators
10
-
11
- - Logical operators: `and`, `or`, `not`
12
- - Comparison operators: `eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`
13
- - String operations: `startsWith`, `endsWith`, `contains`, `isSet`
14
-
15
- #### Relation Operators
16
-
17
- - One-to-one: `is`, `isNot`
18
- - One-to-many/Many-to-many: `some`, `none`, `every`
19
- - Collection operators: `exists`, `all`, `except` (`exists_one` requires counting matches,
20
- which Prisma where-filters cannot express — it throws rather than silently degrading to
21
- `exists`; `filter` only appears inside other expressions)
22
- - Set operations: `hasIntersection`
23
-
24
- #### Arithmetic
25
-
26
- - `add`, `sub`, `mult`, `div` with a constant side are solved algebraically to a plain column
27
- comparison: `R.attr.aNumber + 1 > 2` → `{ aNumber: { gt: 1 } }` (Prisma where-filters cannot
28
- express column arithmetic). Multiplying/dividing by a negative constant mirrors directional
29
- operators. Arithmetic on both sides of a comparison, division BY a column, and equality or
30
- inequality over fractional addition throw. The latter cannot be solved reversibly in IEEE-754
31
- arithmetic.
32
- - String concatenation solving: `P.attr.context == "projects:" + R.attr.id` → `{ id: { equals: "123" } }`
33
-
34
- #### Field-to-field comparisons
35
-
36
- Comparisons between two columns of the same model compile to
37
- [Prisma field references](https://www.prisma.io/docs/orm/reference/prisma-client-reference#compare-columns-in-the-same-table).
38
- Pass the Prisma model name via the `model` option (root columns) or `relation.model` in the
39
- mapper (columns of a related model inside a collection expression). Comparisons across models
40
- throw — Prisma only supports references between fields of the same model.
41
-
42
- #### Hierarchy Operators
43
-
44
- - `hierarchy(string)`, `hierarchy(string, delimiter)`, `hierarchy([segments])`
45
- - `overlaps`: segment-wise prefix comparison between two hierarchies
46
- - `ancestorOf` / `descendentOf`: strict prefix relationship between hierarchies
47
-
48
- #### Advanced Features
49
-
50
- - Deep nested relations support
51
- - Automatic field inference
52
- - Collection mapping and filtering
53
- - Complex condition combinations
54
- - Type-safe field mappings
55
- - Timestamp comparisons against Prisma `DateTime` columns. Mark the field mapping with
56
- `valueType: "dateTime"`; applying `timestamp()` to an untyped/string mapping throws.
57
- Literals must be strict RFC 3339 instants in CEL's supported year 0001–9999 range and
58
- exactly representable at millisecond precision (fractional digits after the third may
59
- only be zero). The mapped column/database must preserve that precision.
60
- - Outer-column references inside collection expressions (e.g.
61
- `R.attr.tags.exists(t, t.name == "x" && R.attr.aBool)`) are hoisted or case-split so every
62
- filter lands on the model it belongs to
63
- - Three-valued-logic guards for nullable element columns: mark a relation field as
64
- `nullable: true` in the mapper and collection macros (`all`, negated `exists`,
65
- `hasIntersection` over `map`) exclude rows whose elements hold `NULL` in that column,
66
- matching Cerbos's treatment of a missing attribute as a deny
67
-
68
- #### Known limitations (loud failures, never silently-wrong filters)
69
-
70
- - LIKE wildcards: Prisma emits `LIKE` without an `ESCAPE` clause, so `contains`/`startsWith`/
71
- `endsWith` with a needle containing `%` or `_`, or with a column-valued needle, throws.
72
- (A constant *receiver* with a column needle — `"a-b".startsWith(R.attr.x)` — is translated
73
- exactly by enumerating candidate needles into an `in` filter.)
74
- - Hierarchy prefixes: `ancestorOf`, `descendentOf` and `overlaps` narrow a column with a
75
- `startsWith`, so they throw when the constant hierarchy contains `%`, `_` or `[`. `[` is
76
- rejected as well as the two LIKE wildcards because SQL Server opens a character class on
77
- `[` even when an `ESCAPE` clause is declared, so it cannot be matched literally at all.
78
- - Counting: `exists_one`, `size()` thresholds other than empty/non-empty, and string-length
79
- comparisons throw (`_count` is not supported inside Prisma `where`).
80
- - Cross-model column comparisons throw (Prisma field references are same-model only).
81
- This includes membership between an outer scalar column and a related collection column.
82
-
83
- #### Database collation is an authorization invariant
84
-
85
- Cerbos string comparisons are case-sensitive. Prisma delegates comparison semantics to the
86
- database collation, so a case-insensitive or accent-insensitive collation can make a generated
87
- authorization filter return rows that Cerbos would deny. Treat the database collation used by
88
- mapped authorization columns as part of the policy contract:
89
-
90
- - PostgreSQL: use a deterministic, case-sensitive collation and avoid `citext` or an
91
- insensitive Prisma query mode for mapped fields.
92
- - MySQL/MariaDB: choose a case-sensitive (`_cs`) or binary collation rather than the common
93
- case-insensitive (`_ci`) defaults.
94
- - SQL Server: use a case-sensitive (`_CS_`) collation rather than a case-insensitive (`_CI_`)
95
- collation.
96
- - SQLite: do not apply `COLLATE NOCASE` to mapped fields, and verify the exact comparison
97
- behavior of any database configuration or extension used in production.
98
-
99
- The adapter cannot override a column's collation in a Prisma `where` filter. See Prisma's
100
- [case-sensitivity documentation](https://docs.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity)
101
- for provider-specific details.
102
-
103
- ### Conformance contract
104
-
105
- The adapter is differentially tested against Cerbos PDP 0.54.0 `checkResource` decisions using 20 hostile seed rows and both Prisma 6 and 7. The Spring Data adapter defines the reference semantics for this compatibility snapshot.
106
-
107
- | Classification | Coverage |
108
- | --- | --- |
109
- | Oracle-tested | 83 reference actions |
110
- | Fail-closed | 31 reference actions plus the 3 reference-unsupported shapes (34 actions total) |
111
- | 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)` |
112
-
113
- 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, and the reference probes for regex, ordered indexing, and `timestamp()` over a string field. 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.
114
-
115
- ## Requirements
116
-
117
- - Cerbos > v0.40
118
- - `@cerbos/http` or `@cerbos/grpc` client
119
- - Prisma >= v6.0 (v7 supported)
120
-
121
- ## System Requirements
122
-
123
- - Node.js >= 22.0.
124
- - Prisma CLI & Client >= 6.0 (v7 supported)
125
- - A database supported by Prisma (SQLite/PostgreSQL/MySQL/etc.) so the Prisma client can communicate with stored data
126
-
127
- ## Installation
7
+ ## Install
128
8
 
129
9
  ```bash
130
- npm install @cerbos/orm-prisma
10
+ npm install @cerbos/orm-prisma @cerbos/core
11
+ npm install @cerbos/grpc # or @cerbos/http — whichever client your deployment uses
131
12
  ```
132
13
 
133
- ## Usage
14
+ - **Node.js** 22 or later.
15
+ - **`@prisma/client`** 5, 6 or 7 (peer dependency). CI runs Prisma 6 and 7.
16
+ - **`@cerbos/core`** `^0.32.0 || ^0.33.0` (peer dependency). It carries the query plan types, and
17
+ your application and the adapter must share one copy. npm 7+ installs peers automatically; pnpm
18
+ and Yarn expect you to declare it.
19
+ - **A Cerbos client** (`@cerbos/grpc` or `@cerbos/http`). The adapter depends on neither.
20
+ - **Cerbos PDP** 0.55 or later is recommended; the contract below is tested against 0.57.0.
21
+ - **A byte-exact database collation** on mapped columns, and on SQLite
22
+ `PRAGMA case_sensitive_like = ON`. See
23
+ [Database collation is an authorization invariant](#database-collation-is-an-authorization-invariant).
134
24
 
135
- The package exports a function:
25
+ ## Quick start
136
26
 
137
27
  ```ts
138
- import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
28
+ import { GRPC } from "@cerbos/grpc";
29
+ import { PlanKind, queryPlanToPrisma } from "@cerbos/orm-prisma";
139
30
 
140
- queryPlanToPrisma({
141
- queryPlan, // The Cerbos query plan response
142
- mapper, // Map Cerbos field names to Prisma field names
143
- }): {
144
- kind: PlanKind,
145
- filters?: any // Prisma where conditions
146
- }
147
- ```
31
+ import { prisma } from "./db"; // your PrismaClient instance
148
32
 
149
- ### Basic Example
150
-
151
- 1. Create a basic policy file in the `policies` directory:
152
-
153
- ```yaml
154
- apiVersion: api.cerbos.dev/v1
155
- resourcePolicy:
156
- resource: resource
157
- version: default
158
- rules:
159
- - actions: ["view"]
160
- effect: EFFECT_ALLOW
161
- roles: ["USER"]
162
- condition:
163
- match:
164
- expr: request.resource.attr.status == "active"
165
- ```
166
-
167
- 2. Start Cerbos PDP:
168
-
169
- ```bash
170
- docker run --rm -i -p 3592:3592 -v $(pwd)/policies:/policies ghcr.io/cerbos/cerbos:latest
171
- ```
172
-
173
- 3. Create Prisma schema (`prisma/schema.prisma`):
174
-
175
- ```prisma
176
- datasource db {
177
- provider = "sqlite"
178
- url = env("DATABASE_URL")
179
- }
180
-
181
- generator client {
182
- provider = "prisma-client-js"
183
- }
33
+ const cerbos = new GRPC("localhost:3593", { tls: false });
184
34
 
185
- model Resource {
186
- id Int @id @default(autoincrement())
187
- title String
188
- status String
189
- }
190
- ```
191
-
192
- 4. Implement the mapper
193
-
194
- ```ts
195
- import { GRPC as Cerbos } from "@cerbos/grpc";
196
- import { PrismaClient } from "@prisma/client";
197
- import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
198
-
199
- const prisma = new PrismaClient();
200
- const cerbos = new Cerbos("localhost:3592", { tls: false });
201
-
202
- // Fetch query plan from Cerbos
203
35
  const queryPlan = await cerbos.planResources({
204
36
  principal: { id: "user1", roles: ["USER"] },
205
- resource: { kind: "resource" },
37
+ resource: { kind: "document" },
206
38
  action: "view",
207
39
  });
208
40
 
209
- // Convert query plan to Prisma filters
210
41
  const result = queryPlanToPrisma({
211
42
  queryPlan,
212
43
  mapper: {
213
- "request.resource.attr.title": { field: "title" },
44
+ "request.resource.attr.ownerId": { field: "ownerId" },
214
45
  "request.resource.attr.status": { field: "status" },
215
46
  },
216
47
  });
217
48
 
218
- if (result.kind === PlanKind.ALWAYS_DENIED) {
219
- return [];
49
+ let documents;
50
+ switch (result.kind) {
51
+ case PlanKind.ALWAYS_DENIED:
52
+ documents = [];
53
+ break;
54
+ case PlanKind.ALWAYS_ALLOWED:
55
+ documents = await prisma.document.findMany({ where: { archived: false } });
56
+ break;
57
+ case PlanKind.CONDITIONAL:
58
+ documents = await prisma.document.findMany({
59
+ where: { AND: [result.filters, { archived: false }] },
60
+ });
61
+ break;
220
62
  }
221
-
222
- // Use filters in Prisma query
223
- const records = await prisma.resource.findMany({
224
- where: result.filters,
225
- });
226
-
227
- // Use filters in Prisma query with other conditions
228
- const records = await prisma.resource.findMany({
229
- where: {
230
- AND: [
231
- {
232
- status: "DRAFT"
233
- },
234
- result.filters,
235
- ]
236
- });
237
63
  ```
238
64
 
239
- ### Collection Operators
65
+ `queryPlanToPrisma` returns `{ kind }` for `ALWAYS_ALLOWED` / `ALWAYS_DENIED` and
66
+ `{ kind: CONDITIONAL, filters }` otherwise. `filters` is a plain where-input for the model, so you
67
+ can `AND` it with your own predicates, paginate, or add `select`/`orderBy`. Short-circuit
68
+ `ALWAYS_DENIED` instead of querying. A shape the adapter cannot express throws an `Error`; it never
69
+ returns a broader filter.
240
70
 
241
- The adapter understands the full Cerbos collection operator set, including `except`. For example, the configuration below ensures a resource’s categories do not have any sub-category named `finance`:
71
+ ### Arguments
242
72
 
243
- ```ts
244
- const result = queryPlanToPrisma({
245
- queryPlan,
246
- mapper: {
247
- "request.resource.attr.categories": {
248
- relation: {
249
- name: "categories",
250
- type: "many",
251
- fields: {
252
- subCategories: {
253
- relation: {
254
- name: "subCategories",
255
- type: "many",
256
- fields: {
257
- name: { field: "name" },
258
- },
259
- },
260
- },
261
- },
262
- },
263
- },
264
- },
265
- });
266
- ```
73
+ | Argument | Required | Description |
74
+ | --- | --- | --- |
75
+ | `queryPlan` | yes | The `PlanResourcesResponse` from `planResources`. |
76
+ | `mapper` | no | Maps Cerbos attribute paths to Prisma fields and relations. Defaults to `{}`. |
77
+ | `model` | no | Prisma model name of the queried model (e.g. `"Resource"`). Needed only for [field-to-field comparisons](#field-to-field-comparisons) between root columns. |
78
+ | `nullAttributeRepresentation` | no | `"omitted"` (default) or `"explicit"`. See [NULL attribute representation](#null-attribute-representation). |
267
79
 
268
- `queryPlanToPrisma` emits the necessary nested `NOT` structure so Prisma receives a valid filter for the entire relation chain.
80
+ Exported types: `QueryPlanToPrismaArgs`, `QueryPlanToPrismaResult`, `Mapper`, `MapperConfig`,
81
+ `NullAttributeRepresentation`, `PrismaFilter`, and `PlanKind` (re-exported from `@cerbos/core`).
269
82
 
270
- ### Field Name Mapping
83
+ ## Mapping attributes
271
84
 
272
- Fields can be mapped using either an object or a function:
85
+ Cerbos attribute paths are not column names, so you map them. The mapper is an object keyed by the
86
+ full attribute path, or a function from path to config:
273
87
 
274
88
  ```ts
275
- // Object mapping
276
- const result = queryPlanToPrisma({
277
- queryPlan,
278
- mapper: {
279
- "request.resource.attr.fieldName": { field: "prismaFieldName" },
280
- },
281
- });
89
+ // Object
90
+ mapper: {
91
+ "request.resource.attr.title": { field: "title" },
92
+ "request.resource.attr.createdAt": { field: "createdAt", valueType: "dateTime" },
93
+ }
282
94
 
283
- // Function mapping
284
- const result = queryPlanToPrisma({
285
- queryPlan,
286
- mapper: (fieldName) => ({
287
- field: fieldName.replace("request.resource.attr.", ""),
288
- }),
289
- });
95
+ // Function
96
+ mapper: (path) => ({ field: path.replace("request.resource.attr.", "") })
290
97
  ```
291
98
 
292
- ### Relations Mapping
99
+ An unmapped path is used verbatim as the Prisma field name, which makes the query fail. Without a
100
+ mapping of its own, a path resolves through its longest mapped prefix only when that prefix names a
101
+ relation; a key read beneath a prefix mapped to a scalar field throws `UnsupportedQueryPlanError`.
293
102
 
294
- Relations are mapped with their types and optional field configurations. Fields can be automatically inferred from the path if not explicitly mapped:
103
+ ### `MapperConfig`
104
+
105
+ | Key | Description |
106
+ | --- | --- |
107
+ | `field` | Prisma field name. |
108
+ | `valueType` | `"string"`, `"number"`, `"boolean"` or `"dateTime"`. Lets the adapter settle comparisons the type already decides before Prisma sees them: CEL answers `R.attr.aNumber == "5"` false for every row and `R.attr.aNumber.contains("2")` with an error, so neither literal is bound for the store to coerce. Required (`"dateTime"`) for `timestamp()` comparisons. Undeclared keeps the untyped behaviour; the adapter cannot read your schema. |
109
+ | `nullable` | `false` declares the column cannot be NULL, which drops the NULL guards on [relation elements](#relation-element-nullability) and hierarchy segments. |
110
+ | `nullAttributeRepresentation` | Per-attribute NULL convention. See [Declare the convention per attribute](#declare-the-convention-per-attribute). |
111
+ | `relation.name` | Prisma relation field name. |
112
+ | `relation.type` | `"one"` (compiles to `is`/`isNot`) or `"many"` (`some`/`every`/`none`). |
113
+ | `relation.field` | Column to compare when the policy compares the relation itself (e.g. `"x" in R.attr.tags`). |
114
+ | `relation.fields` | Mappings for fields of the related model, recursively (a nested entry can itself have a `relation`). Unmapped fields are inferred from the path. |
115
+ | `relation.model` | Prisma model name of the related model. Needed only for field-to-field comparisons inside a collection expression. |
116
+ | `relation.subqueryFilter` | A where-input over the related model that your application applies to its own reads. See [Declaring the application's own predicate](#declaring-the-applications-own-predicate). |
117
+
118
+ ### Relations
295
119
 
296
120
  ```ts
297
- const result = queryPlanToPrisma({
298
- queryPlan,
299
- mapper: {
300
- // Simple relation mapping - fields will be inferred
301
- "request.resource.attr.owner": {
302
- relation: {
303
- name: "owner",
304
- type: "one", // "one" for one-to-one, "many" for one-to-many
305
- },
306
- },
121
+ mapper: {
122
+ // to-one; nested fields are inferred from the path (owner.id -> owner.is.id)
123
+ "request.resource.attr.owner": { relation: { name: "owner", type: "one" } },
307
124
 
308
- // Relation with explicit field mapping
309
- "request.resource.attr.tags": {
310
- relation: {
311
- name: "tags",
312
- type: "many",
313
- field: "name", // Optional: specify field for direct comparisons
314
- },
315
- },
125
+ // to-many compared as a list of names: "x" in R.attr.tags
126
+ "request.resource.attr.tags": { relation: { name: "tags", type: "many", field: "name" } },
316
127
 
317
- // Relation with nested field mappings
318
- "request.resource.attr.nested": {
319
- relation: {
320
- name: "nested",
321
- type: "one",
322
- fields: {
323
- // Optional: specify mappings for nested fields
324
- aBool: { field: "aBool" },
325
- aNumber: { field: "aNumber" },
326
- },
128
+ // to-many used in collection macros: R.attr.comments.exists(c, c.status == "approved")
129
+ "request.resource.attr.comments": {
130
+ relation: {
131
+ name: "comments",
132
+ type: "many",
133
+ fields: {
134
+ id: { field: "id", nullable: false },
135
+ status: { field: "status" },
136
+ author: { relation: { name: "author", type: "one" } },
327
137
  },
328
138
  },
329
139
  },
330
- });
140
+ }
331
141
  ```
332
142
 
333
- ### Field Inference Example
334
-
335
- When using relations, fields are automatically inferred from the path unless explicitly mapped:
143
+ A policy like `R.attr.status == "active" && R.attr.owner.id == P.id && "tag1" in R.attr.tags`
144
+ translates to:
336
145
 
337
146
  ```ts
338
- // These mappers are equivalent for handling: request.resource.attr.nested.aNumber
339
147
  {
340
- "request.resource.attr.nested": {
341
- relation: {
342
- name: "nested",
343
- type: "one",
344
- fields: {
345
- aNumber: { field: "aNumber" }
346
- }
347
- }
348
- }
349
- }
350
-
351
- // Shorter version - aNumber will be inferred from the path
352
- {
353
- "request.resource.attr.nested": {
354
- relation: {
355
- name: "nested",
356
- type: "one"
357
- }
358
- }
148
+ AND: [
149
+ { status: { equals: "active" } },
150
+ { owner: { is: { id: { equals: "user1" } } } },
151
+ { tags: { some: { name: { equals: "tag1" } } } },
152
+ ],
359
153
  }
360
154
  ```
361
155
 
362
- ### Handling `in` Operators
363
-
364
- `queryPlanToPrisma` normalises Cerbos `in` expressions to match Prisma expectations:
156
+ The nested filters read the related model **unfiltered**. If your own reads of that model apply a
157
+ predicate (soft delete, tenant, subtype), declare it as `subqueryFilter`. See
158
+ [Mapping hazards](#mapping-hazards).
365
159
 
366
- - Single values become equality comparisons (`{ field: "value" }`).
367
- - Arrays remain `{ field: { in: [...] } }`.
368
- - Relation-backed fields retain their relation structure while still applying the appropriate equality or `in` operator at the leaf.
160
+ ### Field-to-field comparisons
369
161
 
370
- ### Complex Example with Multiple Relations and Direct Fields
162
+ Comparisons between two columns of the same model compile to
163
+ [Prisma field references](https://www.prisma.io/docs/orm/reference/prisma-client-reference#compare-columns-in-the-same-table).
164
+ Pass `model` for root columns, or `relation.model` for columns of a related model inside a
165
+ collection expression. Comparisons across models throw: Prisma field references are same-model only.
371
166
 
372
167
  ```ts
373
- const result = queryPlanToPrisma({
374
- queryPlan,
375
- mapper: {
376
- "request.resource.attr.status": { field: "status" },
377
- "request.resource.attr.owner": {
378
- relation: {
379
- name: "owner",
380
- type: "one",
381
- },
382
- },
383
- "request.resource.attr.tags": {
384
- relation: {
385
- name: "tags",
386
- type: "many",
387
- field: "name",
388
- },
389
- },
390
- },
391
- });
392
-
393
- // Results in Prisma filters like:
394
- const result = await primsa.resource.findMany({
395
- where: {
396
- AND: [
397
- { status: { equals: "active" } },
398
- { owner: { is: { id: { equals: "user1" } } } },
399
- { tags: { some: { name: { in: ["tag1", "tag2"] } } } },
400
- ];
401
- }
402
- })
168
+ queryPlanToPrisma({ queryPlan, mapper, model: "Resource" });
403
169
  ```
404
170
 
405
- ### Complex Examples
171
+ ### Timestamps
406
172
 
407
- #### Lambda Expression Examples
173
+ Mark `DateTime` columns with `valueType: "dateTime"` and compare them with `timestamp()`.
174
+ `timestamp()` over an untyped or string mapping throws, and so does a bare comparison between two
175
+ mapped `DateTime` columns (use `timestamp()` on both sides). Literals must be strict RFC 3339
176
+ instants in CEL's year 0001–9999 range. Your column must preserve millisecond precision, and the
177
+ attribute you send must be the millisecond Prisma returns for it. An instant between two
178
+ milliseconds — what the planner folds `now()` into — is compared against the next millisecond,
179
+ which is exact for a whole-millisecond attribute: `a < T` and `a <= T` become `< ceil(T)`, `a > T`
180
+ and `a >= T` become `>= ceil(T)`, and `a == T` never holds. Anywhere else (a list, arithmetic, two
181
+ literals) such an instant still throws. `timeSince(timestamp(x))` against a duration reads the
182
+ clock when `queryPlanToPrisma` runs, so translate the plan for the query you are about to run.
408
183
 
409
- ```ts
410
- // Using exists with lambda expressions
411
- const result = queryPlanToPrisma({
412
- queryPlan,
413
- mapper: {
414
- "request.resource.attr.comments": {
415
- relation: {
416
- name: "comments",
417
- type: "many",
418
- fields: {
419
- author: {
420
- relation: {
421
- name: "author",
422
- type: "one",
423
- },
424
- },
425
- status: { field: "status" },
426
- },
427
- },
428
- },
429
- },
430
- });
184
+ ## NULL attribute representation
431
185
 
432
- // This can handle complex exists queries like:
433
- // "Does the resource have any approved comments by specific users?"
434
- const result = await primsa.resource.findMany({
435
- where: {
436
- comments: {
437
- some: {
438
- AND: [
439
- { status: { equals: "approved" } },
440
- {
441
- author: {
442
- is: {
443
- id: { in: ["user1", "user2"] },
444
- },
445
- },
446
- },
447
- ],
448
- },
449
- },
450
- },
451
- });
452
- ```
186
+ `R.attr.x == null` produces the same plan however your application represents a NULL column in the
187
+ attributes it sends to `check()`, so tell the adapter which convention you use:
453
188
 
454
- ## Development
189
+ | Attributes you send for a NULL column | `check()` on that row | `IS NULL` filter |
190
+ | --- | --- | --- |
191
+ | `{"x": null}` — explicit null | allow | selects it — aligned |
192
+ | `{}` — attribute omitted | **deny** (CEL missing-attribute error) | selects it — **over-grants** |
455
193
 
456
- ### Running Tests
194
+ The default is `"omitted"`, the convention that fails closed when the guess is wrong. If you send
195
+ NULL columns as explicit nulls, set `"explicit"` and `x == null` renders as `IS NULL`:
457
196
 
458
- ```bash
459
- npm test
197
+ ```ts
198
+ queryPlanToPrisma({ queryPlan, mapper, nullAttributeRepresentation: "explicit" });
460
199
  ```
461
200
 
462
- > **Note:** The suite seeds `prisma/dev.db` and invokes `prisma db push --force-reset`. Only run it against disposable development databases.
201
+ Under `"omitted"` the adapter never emits a filter that selects the NULL rows. `x == null` is FALSE for a
202
+ present value and an error for an absent one, so it becomes `NOT startsWith(x, "")` (for a string
203
+ column): FALSE when present, UNKNOWN when NULL, under either polarity. `x != null` is the
204
+ presence test itself. Every other null comparison operand is rejected.
463
205
 
464
- 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.
206
+ A typed column read through to-one relations (`parent.x`) is also missing when a relation on the way is
207
+ absent, and a relation filter cannot promise UNKNOWN on the rows it fails. So the rendering follows
208
+ the comparison's polarity, which the enclosing `!`, `&&`, `||`, `exists` and `all` fix: where the
209
+ comparison needs to be TRUE, `parent.x != null` is the relation-reached presence test (the parent
210
+ exists and `x` is not NULL) and `parent.x == null` is never true; where it needs to be FALSE,
211
+ `parent.x == null` is the negated presence test and `parent.x != null` is never false. A missing
212
+ parent or a NULL `x` is then denied under any nesting of `!`. A null comparison whose polarity is
213
+ not fixed (a ternary condition, an operand of `==`, the body of `exists_one`) is still rejected.
465
214
 
466
- ## Types
215
+ The rejection covers every other null operand, including `x != null`, which is aligned under both
216
+ conventions: Prisma negates by wrapping in `{ NOT: … }`, so a leaf cannot tell whether an enclosing
217
+ `not` will flip it back. See [#302](https://github.com/cerbos/query-plan-adapters/issues/302).
467
218
 
468
- ### Query Plan Response Types
219
+ ### Declare the convention per attribute
469
220
 
470
- The adapter is fully typed and provides clear type definitions for all responses:
221
+ One policy suite can use both conventions (the same column mapped under two attribute names). Set
222
+ the convention on the mapper entry; the call-level option covers only undeclared attributes:
471
223
 
472
224
  ```ts
473
- import { PlanKind, QueryPlanToPrismaResult } from "@cerbos/orm-prisma";
474
-
475
- // The result will be one of these types:
476
- type QueryPlanToPrismaResult =
477
- | {
478
- kind: PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED;
479
- }
480
- | {
481
- kind: PlanKind.CONDITIONAL;
482
- filters: Record<string, any>;
483
- };
484
-
485
- // Example usage with type narrowing:
486
- const result = queryPlanToPrisma({ queryPlan });
487
-
488
- if (result.kind === PlanKind.CONDITIONAL) {
489
- // TypeScript knows `filters` exists here
490
- const records = await prisma.resource.findMany({
491
- where: result.filters,
492
- });
493
- } else if (result.kind === PlanKind.ALWAYS_ALLOWED) {
494
- // No filters needed
495
- const records = await prisma.resource.findMany();
496
- } else {
497
- // Must be ALWAYS_DENIED
498
- return [];
499
- }
225
+ const mapper = {
226
+ // sent as an explicit null when the column is NULL
227
+ "request.resource.attr.owner": { field: "ownerId", nullAttributeRepresentation: "explicit" },
228
+ // omitted when the column is NULL — the call-level default applies
229
+ "request.resource.attr.department": { field: "department" },
230
+ };
500
231
  ```
501
232
 
502
- ### Mapper Types
503
-
504
- The mapper configuration is also fully typed:
233
+ Declaring `"explicit"` asserts the column can be NULL **and** that NULL reaches `check()` as an
234
+ explicit null. The equality family (`eq`, `ne`, `in`) is then rendered so it is never SQL UNKNOWN:
235
+ `null != "x"` is true in CEL, so the row is returned. Ordering and string operators are unchanged,
236
+ because CEL denies them on a null receiver anyway. An undeclared attribute follows the call-level
237
+ convention; if that is `"omitted"` and the caller in fact sends an explicit null, `!=` against a
238
+ constant under-grants the NULL rows, which fails closed.
239
+
240
+ A field-to-field `==`/`!=` between an explicit-null attribute and one that is not keeps both: the
241
+ explicit side's NULL compares as a value, the other side's NULL stays an error. See
242
+ [#308](https://github.com/cerbos/query-plan-adapters/issues/308) and
243
+ [ADR 0004](../docs/adr/0004-the-null-convention-is-a-property-of-the-attribute.md).
244
+
245
+ > [!WARNING]
246
+ > **On Cerbos 0.56.0 and earlier, do not guard with `has()`: write `R.attr.x != null`.** An
247
+ > attribute the plan request omits is unknown to those planners, which assume the data layer
248
+ > supplies it, so they fold `has(R.attr.x)` to true: alone it plans as `ALWAYS_ALLOWED`, and
249
+ > `has(R.attr.x) && R.attr.y > 0` plans as `R.attr.y > 0`, which never excludes a row missing `x`.
250
+ > From 0.57.0 the planner keeps `has(R.attr.x)` in the plan, and the adapter translates it from
251
+ > `x`'s null convention or refuses it: under `omitted` a row has `x` when its value is not null, and
252
+ > under `explicit` every row has it. The planner still drops a `has(R.attr.x)` that only guards an
253
+ > access to `x`, as in `has(R.attr.x) && R.attr.x == "a"`, because that access already denies a row
254
+ > without `x`.
255
+
256
+ ## Relation element nullability
257
+
258
+ Collection macros lower to `some`/`every`/`none`, which treat SQL UNKNOWN as false. Without a guard,
259
+ `!R.attr.tags.exists(t, t.name == "x")`, `all()` and `hasIntersection()` over `map()` would return
260
+ rows holding a NULL element that `check()` denies. So a relation element column is treated as
261
+ nullable and guarded **unless its mapping says `nullable: false`**.
262
+
263
+ Declare `nullable: false` on every required element column a policy reaches through a macro:
505
264
 
506
265
  ```ts
507
- type MapperConfig = {
508
- field?: string;
509
- valueType?: "dateTime";
510
- nullable?: boolean;
511
- relation?: {
512
- name: string;
513
- type: "one" | "many";
514
- model?: string;
515
- field?: string;
516
- fields?: {
517
- [key: string]: MapperConfig; // Recursive for nested fields
518
- };
519
- };
520
- };
521
-
522
- type Mapper = { [key: string]: MapperConfig } | ((key: string) => MapperConfig);
266
+ "request.resource.attr.tags": {
267
+ relation: {
268
+ name: "tags",
269
+ type: "many",
270
+ fields: {
271
+ id: { field: "id", nullable: false }, // String — required
272
+ name: { field: "name" }, // String? — nullable, guarded
273
+ },
274
+ },
275
+ },
523
276
  ```
524
277
 
525
- ## Full Example
278
+ If you leave a required column undeclared, Prisma rejects the guard's `null` comparison
279
+ (``Argument `id` must not be null``) and the query fails. This fails closed, but it still fails.
280
+ Declarations are read from nested `relation.fields` for chained collections
281
+ (`R.attr.a.b.exists(...)`).
282
+
283
+ ## Database collation is an authorization invariant
284
+
285
+ Cerbos string comparisons are byte-exact. Prisma leaves comparison semantics to the database
286
+ collation, so a case- or accent-insensitive collation makes the filter return rows Cerbos denies.
287
+ The adapter cannot set a collation or a pragma from inside a `where`.
288
+
289
+ - **PostgreSQL:** a deterministic, case-sensitive collation. No `citext`, no `mode: "insensitive"`
290
+ on mapped fields. **String ordering** (`<`, `<=`, `>`, `>=`) also needs a byte-order collation,
291
+ `"C"`: CEL orders strings by code point, and a linguistic collation such as glibc's
292
+ `en_US.UTF-8` (the usual default on Debian images and managed services) or ICU's `en-US` sorts
293
+ `"One"` after `"a"`, so `R.attr.name > "a"` over-grants it
294
+ ([#489](https://github.com/cerbos/query-plan-adapters/issues/489)). Create the database with
295
+ `LC_COLLATE 'C'`, or set `COLLATE "C"` in a migration on each column a policy orders. The conformance PostgreSQL legs initialise with `--lc-collate=C`;
296
+ `ADAPTER_TEST_POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=en-US"` replays them
297
+ under a linguistic order and fails both `comparison/*/string-code-point-order` cases.
298
+ - **MySQL:** `utf8mb4_0900_bin` (MySQL 8.0.17+), the only collation that is byte-exact and NO PAD,
299
+ and it orders by code point.
300
+ `utf8mb4_0900_as_cs` is not enough: it ignores default-ignorable code points, so
301
+ `'o­ne' = 'one'` ([#474](https://github.com/cerbos/query-plan-adapters/issues/474)).
302
+ `utf8mb4_bin` is PAD SPACE, so `'a' = 'a '`. On MariaDB the equivalent is `utf8mb4_nopad_bin`
303
+ (not executed here).
304
+
305
+ **You must convert the tables yourself.** Prisma's migration engine writes
306
+ `COLLATE utf8mb4_unicode_ci` into every `CREATE TABLE`, ignores the server default, and has no
307
+ schema attribute to override it, so a Prisma-managed MySQL database is case- and
308
+ accent-insensitive out of the box. After migrating:
309
+
310
+ ```sql
311
+ ALTER TABLE `YourModel` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
312
+ ```
313
+
314
+ Measured on `mysql:8.4` against PDP 0.55.0's goldens: under `utf8mb4_unicode_ci`, **58 of the
315
+ 184 compared cases disagree with the PDP** (`string/equals/case-sensitive` returns `"One"` for a
316
+ policy that allowed `"one"`); under `utf8mb4_0900_as_cs`, **17** disagree, all on the
317
+ soft-hyphen seed `h6`.
318
+ - **SQL Server:** a case-sensitive (`_CS_`) collation, not `_CI_`.
319
+ - **SQLite:** no `COLLATE NOCASE` on mapped fields (the default `BINARY` orders by code point, as
320
+ CEL does), **and** `PRAGMA case_sensitive_like = ON` on
321
+ every connection. `contains`/`startsWith`/`endsWith` lower to `LIKE`, which is ASCII
322
+ case-insensitive on SQLite regardless of column collation. The pragma is per connection, not per
323
+ schema, and Prisma 6's query engine pools SQLite connections, so one `$executeRawUnsafe` reaches
324
+ only one of them: pin `connection_limit=1` in the datasource url, as this repository's Prisma 6
325
+ SQLite leg does. Without it the adapter over-grants the corpus's
326
+ `string/contains/case-sensitive`, `string/starts-with/case-sensitive` and
327
+ `string/ends-with/case-sensitive` cases.
328
+
329
+ A mapped string column must not hold a NUL character. SQLite's `LIKE` stops at the first NUL,
330
+ so `size()` and every string filter answer for the prefix before it: `size(x) < 2` returns
331
+ `"a\0x"`, which Cerbos denies. PostgreSQL `text` cannot store a NUL at all. The corpus's NUL seed
332
+ is lacked on both legs ([#675](https://github.com/cerbos/query-plan-adapters/issues/675)).
333
+
334
+ See Prisma's [case-sensitivity documentation](https://docs.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity).
335
+
336
+ ## Supported operators
337
+
338
+ | Category | Operators |
339
+ | --- | --- |
340
+ | Logical | `and`, `or`, `not` |
341
+ | Comparison | `eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`; `in` over a list built from columns (`R.attr.a in [R.attr.b, "x"]`) is the OR of the equalities, a missing element denying the row |
342
+ | Null checks | `== null` / `!= null` → `{ equals: null }` / `{ not: null }` (the planner has no existence operator) |
343
+ | Strings | `startsWith`, `endsWith`, `contains`; a `startsWith` needle holding `%`, `_` or `\` is spelled as the range of strings it bounds (`gte` the prefix and `lt` the prefix with its last code point stepped up), which needs the code-point order the [collation](#database-collation-is-an-authorization-invariant) already requires; a constant receiver with a column needle (`"a-b".startsWith(R.attr.x)`) becomes an `in` over the candidate needles; `x.upperAscii() == "LIT"` becomes an `in` over the spellings with each ASCII capital in either case (a store's `UPPER` would also fold non-ASCII letters) |
344
+ | Relations | to-one `is`/`isNot`; to-many `some`/`every`/`none` |
345
+ | Collections | `exists`, `all`, lambda `except`, `hasIntersection`, `map`/`filter` inside another expression, emptiness of a mapped relation; `isSubset` and the emptiness of the `except`/`intersect` list functions against a literal list; `"x" in c.map(...)` and `"x" in c.filter(...)` (an element whose projection or predicate errors denies the row); `exists`/`all` over a list built from columns (`[R.attr.a, R.attr.b].exists(...)`, a missing element denying the row) |
346
+ | Maps | `R.attr.m["key"]` reads like `R.attr.m.key`; `{"k1": v1, ...}[R.attr.x] == v` becomes an `in` over the keys whose value matches (a missing key denies under both polarities); `"key" in R.attr.parent` and `R.attr.parent.exists(k, k == "key")` over a to-one relation test the related row for a key its mapping declares, as `has(R.attr.parent.key)` does |
347
+ | Arithmetic | `add`, `sub`, `mult`, `div` against a constant, solved exactly over IEEE-754 doubles to a range of the column (`R.attr.n + 0.5 == 0.75` → `0.24999999999999994 <= n <= 0.25000000000000006`, every double whose rounded sum is 0.75; negative multipliers flip the direction); `x / x` and `x / ±0` split on the sign of `x`; string concatenation solving (`P.attr.ctx == "projects:" + R.attr.id` → `{ id: { equals: "…" } }`), and a concatenation of string columns and literals against a literal as one arm per split of it; list concatenation around a ternary over literal lists (`["a"] + (R.attr.b ? ["c"] : []) == [...]`) |
348
+ | Regex | `matches` against a literal RE2 pattern whose language LIKE decides exactly: literals, `^`/`$`, alternation, groups, `?` and bounded `{n,m}`, finite classes, `\d`, `[[:digit:]]`, leading `(?i)` over ASCII, and `.`/one `.*` in a pattern anchored at both ends (the value then holds no newline, as RE2's `.` requires). A pattern RE2 rejects (lookaround, a backreference) is an error on every row, as decided by [re2js](https://github.com/le0pard/re2js), a port of RE2 |
349
+ | Hierarchy | `hierarchy(string)`, `hierarchy(string, delimiter)` (an empty delimiter splits per code point, so a descendant is `startsWith(path + "_")`), `hierarchy([segments])`, `overlaps`, `ancestorOf`, `descendentOf`; a prefix holding a LIKE metacharacter is matched as a range, like a `startsWith` needle |
350
+ | Timestamps | `timestamp()` over `valueType: "dateTime"` columns; `timestamp(x) ± duration(d)` against a timestamp literal, solved for the column (a shift past CEL's 0001–9999 range denies); `timeSince(timestamp(x))` against a duration, as `x` against `now - d` read when the plan is translated. A bare `dateTime` attribute is the RFC 3339 string, which never orders against a timestamp: that comparison settles as CEL's error |
351
+
352
+ Outer-column references inside a macro (`R.attr.tags.exists(t, t.name == "x" && R.attr.aBool)`)
353
+ are hoisted or case-split so each filter lands on its own model.
354
+
355
+ ### What throws
356
+
357
+ Loud failures, never silently wrong filters. A shape the adapter cannot express throws
358
+ `UnsupportedQueryPlanError`. It is exported and extends `Error`, so existing `catch` blocks keep
359
+ working:
526
360
 
527
- 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)
361
+ ```ts
362
+ import { queryPlanToPrisma, UnsupportedQueryPlanError } from "@cerbos/orm-prisma";
363
+
364
+ try {
365
+ const result = queryPlanToPrisma({ queryPlan, mapper, model: "Resource" });
366
+ } catch (error) {
367
+ if (error instanceof UnsupportedQueryPlanError) {
368
+ // The policy uses a shape this adapter cannot translate faithfully: deny, or fall back to
369
+ // per-row check() calls.
370
+ }
371
+ throw error;
372
+ }
373
+ ```
528
374
 
529
- ## Resources
375
+ A mapper misconfiguration, such as a field-to-field comparison without the `model` option or
376
+ `relation.model`, stays a plain `Error`. The refused shapes include:
377
+
378
+ - **LIKE metacharacters.** Prisma emits `LIKE` without `ESCAPE`, so `contains`/`endsWith` with a
379
+ needle containing `%`, `_` or `\`, or a column-valued needle of any string operator, throws. A
380
+ `startsWith` needle and a hierarchy prefix (`ancestorOf`, `descendentOf`, `overlaps`) holding
381
+ one are matched as a range instead, so they never reach `LIKE`. If you match on a backslash
382
+ suffix or substring, compare the whole value with `==`.
383
+ - **Counting.** `exists_one` over a relation, `size()` of a mapped relation other than empty, non-empty or (on
384
+ a chain) reachable, and `size()` of a derived list such as `intersect()`.
385
+ - **Two-variable comprehensions** (`list.exists(i, v, ...)`, `map.all(k, v, ...)`): a relation
386
+ filter tests related rows, which carry no position, and enumerates no map keys.
387
+ - **Cross-model column comparisons**, including membership between an outer scalar column and a
388
+ related collection column, and membership in a list holding an expression over a column
389
+ (`R.attr.a in ["x", R.attr.b + "y"]`, `R.attr.a in R.attr.list + ["x"]`).
390
+ - **Unsolvable arithmetic.** Arithmetic on both sides, and division *by* a column other than
391
+ `x / x`.
392
+ - **Other malformed shapes:** the two-list `except` function compared to a non-empty list or
393
+ counted past emptiness, non-scalar comparison literals, empty `and`/`or`, and
394
+ negating a sub-condition that translates to `{}` (Prisma reads `{ NOT: {} }` as true).
395
+
396
+ #### Operators Prisma `where` cannot express
397
+
398
+ Prisma 6.19 and 7.9 expose the same filter surface on SQLite, PostgreSQL and MySQL
399
+ ([#224](https://github.com/cerbos/query-plan-adapters/issues/224)): scalar filters take a constant
400
+ or a same-model field reference, never an expression. These shapes throw:
401
+
402
+ | CEL shape | Corpus cases | Why Prisma cannot express it |
403
+ | --- | --- | --- |
404
+ | `a % b` | `arithmetic/modulo/*` | No modulo operator, and `%` is not invertible, so it cannot be solved into a plain comparison. |
405
+ | Arithmetic on both sides | `arithmetic/add/on-both-sides` | Field references compare two columns as they are; no operand is an expression. |
406
+ | `matches` beyond the LIKE-decidable subset | `regex/matches/lucene-reserved-characters-in-class` | No regex filter ([prisma/prisma#18481](https://github.com/prisma/prisma/issues/18481)), and no provider here has RE2. A repeated class (`[ab]+`), a negated class, a wildcard in a pattern not anchored at both ends, or a literal `%`/`_`/`\` inside a LIKE pattern has no exact LIKE spelling. |
407
+ | List index `l[i]` | `collection/index/*` (every positional case) | List filters test membership, emptiness or equality, never a position. |
408
+ | `int()`, `double()` or `timestamp()` parsing a string; an `int()` threshold | `cast/int/malformed-string`, `cast/double/malformed-string`, `cast/timestamp/malformed-string` and their negations | No cast operator, and SQL `CAST` would not reproduce CEL's conversion errors (SQLite reads `CAST('abc' AS INTEGER)` as `0`). `int()` of a number is translated only as `==` (or `!=` under a `!`): a threshold would need CEL's ±2^63 overflow bound spelled out, which an `Int` column rejects. The invertible casts are solved for the column instead — `string()` of a boolean column, a boolean-valued expression or a number column, `string(int(d)) == "k"` as `int(d) == k`, `int(d) == k` as the interval truncation maps to `k`, and `string()`/`double()` of a column already of that type. |
409
+ | `size()` of `filter()` over a relation | `size/equals/filtered-collection` | No count filter; `_count` exists only in `orderBy`, `select` and aggregates ([prisma/prisma#8935](https://github.com/prisma/prisma/issues/8935)). |
410
+
411
+ Raw SQL fragments, an id subquery and an in-memory post-filter were considered and rejected: Prisma
412
+ 5–7 has no raw predicate inside `where` ([prisma/prisma#5560](https://github.com/prisma/prisma/issues/5560),
413
+ [prisma/prisma#11568](https://github.com/prisma/prisma/issues/11568)), and the other two would stop
414
+ the result being a composable where-input.
415
+
416
+ **Workaround: a derived column.** Compute the value when you write the row and have the policy read
417
+ it: `isEven` instead of `R.attr.n % 2 == 0`, `tagCount` instead of `size(R.attr.tags) > 1`,
418
+ `primaryTag` instead of `R.attr.tags[0]`.
419
+
420
+ Prisma 8 (a release candidate as of September 2026) replaces `findMany({ where })` with a new client
421
+ API and is out of scope.
422
+
423
+ ## Conformance contract
424
+
425
+ The adapter is replayed against the shared [conformance corpus](../conformance/README.md): the plans
426
+ and `check()` decisions recorded from Cerbos PDP 0.57.0 (and 0.56.0), executed as real Prisma
427
+ queries over 43 of the corpus's 44 seed rows with Prisma 6 and 7 on SQLite, PostgreSQL and MySQL
428
+ (under `utf8mb4_0900_bin`). The ledger lacks `null-list`, so j4 is not loaded, and `nul-in-string` on PostgreSQL and SQLite, so k3 is not loaded there. Passed cases on the
429
+ current PDP, 0.57.0, identical on all six combinations, out of every golden case in the tier:
430
+
431
+ | Tier | Passed / total |
432
+ | --- | --- |
433
+ | core | 32 / 32 |
434
+ | extended | 84 / 110 |
435
+ | adversarial | 378 / 546 |
436
+
437
+ Every case that does not pass is refused with `UnsupportedQueryPlanError`; none returns wrong rows
438
+ on 0.56.0. [`conformance-ledger.json`](conformance-ledger.json) lists each one with its reason.
439
+ Cases the corpus marks as a planner divergence are skipped, not failed, and count in the total but
440
+ never as passed. On 0.57.0 that is three extended cases and four adversarial cases.
441
+ On 0.56.0, `null/has/missing-attribute` and `null/has/composed-with-comparison` are skipped too: that planner folds `has()` on an omitted attribute to true, while `check()` denies the row; 0.57.0 keeps `has()` in the plan. `arithmetic/add/int-literal-plus-constant` and `arithmetic/add/int-literal-negated`: the planner drops the int type of the literal in `R.attr.x + 1`, so the plan is the double spelling's, while `check()` has no double + int overload and denies every row; write `1.0`. The three `composition/*` cases with a conditional `DENY` rule: the deny
442
+ condition reads `aNumber`, which j2 lacks: the plan's `not(...)` of it denies j2, while
443
+ `checkResource` receives `aNumber` as absent and treats the erroring deny as not matching
444
+ ([#530](https://github.com/cerbos/query-plan-adapters/issues/530)). And
445
+ `type-mismatch/in/number-field-in-scalar-principal` and `type-mismatch/in/string-field-in-dyn-string`:
446
+ CEL has no `in` overload for a scalar container, so `check()` errors on that disjunct, while the
447
+ planner rewrites the `in` to an equality.
448
+
449
+ **Providers.** `ADAPTER_TEST_MYSQL_COLLATION` replays the MySQL legs under another collation, which
450
+ is how the figures in the collation section were measured, and `ADAPTER_TEST_POSTGRES_INITDB_ARGS`
451
+ replays the PostgreSQL legs under another initdb locale. MySQL adds no refused shape. SQL Server
452
+ and CockroachDB are **not** executed: refusal reasons naming them are reasoned from documented
453
+ `LIKE` behaviour.
454
+
455
+ ## Mapping hazards
456
+
457
+ The contract above proves the *plan* side. The other half is the *mapping*: **the records the
458
+ nested filter reads must be the records your application put into the resource attributes.** The
459
+ shared corpus catalogues six ways that can break.
460
+
461
+ Prisma has no filtered relation and no `@Where` equivalent, and a `where` injected by a client
462
+ extension or middleware (`$extends`/`$use`) rewrites only the top-level query. So although the
463
+ mapping names a relation, this adapter is corpus class 1 — a **bare-table subquery**. Declare your
464
+ own narrowing as `subqueryFilter`; the adapter cannot detect an omission.
465
+
466
+ | Hazard | Position | Mechanism to check |
467
+ |---|---|---|
468
+ | 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 |
469
+ | 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 |
470
+ | Subtype discrimination | **Caller-owned**, reproducible with `subqueryFilter` | A `type`/`kind` discriminator column where one model holds several row kinds. Declare `{ type: "…" }` |
471
+ | To-one relation used as a collection | **Rejected by Prisma** | `type: "one"` compiles to `is`, which Prisma accepts only on a relation its schema declares to-one. Mapping a to-many relation as `type: "one"` is a Prisma validation error, not a wider subquery |
472
+ | Composite association key | **Reproduced by Prisma** | Prisma resolves multi-column keys from `@relation(fields: […], references: […])`. The mapping names the relation, never its columns |
473
+ | Absent to-one parent | **Reproduced**, and proved by the corpus (`relation/all/to-one-chain`, `relation/bare-attribute/negated-one-hop-boolean` 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)) |
474
+
475
+ ### Declaring the application's own predicate
530
476
 
531
- ### Documentation
477
+ ```ts
478
+ queryPlanToPrisma({
479
+ queryPlan,
480
+ mapper: {
481
+ "request.resource.attr.tags": {
482
+ relation: {
483
+ name: "tags",
484
+ type: "many",
485
+ field: "name",
486
+ // Exactly the predicate your own reads of `Tag` apply.
487
+ subqueryFilter: { deletedAt: null, kind: "label" },
488
+ },
489
+ },
490
+ },
491
+ });
492
+ ```
532
493
 
533
- - [Cerbos Documentation](https://docs.cerbos.dev)
534
- - [Prisma Documentation](https://www.prisma.io/docs)
535
- - [Query Plan API Reference](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)
494
+ `subqueryFilter` is a where-input over the related model, ANDed into the nested filter. It narrows
495
+ the records the subquery *examines*, for every operator reached through the relation (`exists`,
496
+ `all`, `except`, membership, `hasIntersection`, emptiness) and for the hop-existence guard.
497
+ `all()` is rewritten from `every: AND(declared, P)` to `none: AND(declared, NOT P)`, so hidden
498
+ records are ignored rather than required. If the declaration hides every record, `all()` is
499
+ vacuously true, matching the empty list your application would send to `check()`.
500
+
501
+ ## Behaviour changes
502
+
503
+ - **Breaking:** a field declared with a `valueType` other than `"boolean"` read where CEL needs a boolean (`!R.attr.aString`) throws `UnsupportedQueryPlanError`: it is CEL's type error under both polarities, which no Prisma filter keeps out. Prisma rejected the filter before.
504
+ - Whether `matches()` raises an error on a pattern (which denies every row) is now decided by
505
+ re2js, a port of RE2, where a hand-written check read `\1`–`\9` as a backreference. RE2 reads
506
+ `\1`–`\7` followed by another octal digit as an octal escape (`\11` is a tab), so such a
507
+ pattern used to deny every row and now matches the character it names. `re2js` is a new
508
+ dependency.
509
+ - `startsWith` needles and hierarchy prefixes containing `%`, `_` or `\` no longer throw: they are
510
+ matched as a range of the column (`gte` the prefix, `lt` the first string past it), exact under
511
+ the code-point collation the adapter already requires. `R.attr.a in [R.attr.b]` compiles to the
512
+ field-to-field equality, `string(<boolean expression>) == "true"` to the expression,
513
+ `string(int(x)) == "1000000"` to `int(x) == 1000000`, `"key" in R.attr.parent` and
514
+ `R.attr.parent.exists(k, k == "key")` to `has(R.attr.parent.key)`, and an ordering between a
515
+ string column and an arithmetic result settles as CEL's no-overload error.
516
+ - **Breaking:** `nullAttributeRepresentation` defaults to `"omitted"`, where it defaulted to
517
+ `"explicit"`. A caller that sends explicit nulls and set nothing now gets a refusal on most null
518
+ comparisons, and `x == null` selects no row where it rendered `IS NULL`; pass
519
+ `nullAttributeRepresentation: "explicit"` to keep the old output
520
+ ([#499](https://github.com/cerbos/query-plan-adapters/issues/499),
521
+ [ADR 0011](../docs/adr/0011-denial-fails-closed-in-the-result.md)).
522
+ - **Fix ([#597](https://github.com/cerbos/query-plan-adapters/issues/597)):** `matches()` reads a
523
+ repetition count as RE2 does. A count with a leading zero opens no repetition, so `^one{01}$` is
524
+ the literal `one{01}`; it used to be read as `{1}` and returned the row `"one"`. Nested counts
525
+ whose copies multiply past 1000 (`(a{2}){600}`) are a pattern RE2 rejects, an error on every row;
526
+ the pattern used to be expanded into one long literal, so its negation returned every row
527
+ (over-grant fixes). An unbounded count (`a{2,}`) is no longer mistaken for one past 1000.
528
+ - **Fix:** a string function (`startsWith`, `endsWith`, `contains`) over the element
529
+ of a projected list (`R.attr.tagNames.all(t, t.startsWith("p"))`) is an error on a null element,
530
+ and `all()` and a negated `exists()` now deny the row holding one; they used to admit it.
531
+ - **Fix:** `!hasIntersection(R.attr.tags.map(t, t.name), [...])` denies a row holding an element
532
+ whose projected column is NULL: building the mapped list is an error, which CEL denies under
533
+ the negation too. The null-projection guard used to be ANDed onto the positive filter, so the
534
+ negation turned the error into a match and admitted those rows.
535
+ - **Fix, breaking:** a two-variable comprehension (`R.attr.list.exists(i, v, ...)`) throws
536
+ `UnsupportedQueryPlanError`. It used to drop the second variable and emit a filter over the
537
+ first, which returned the wrong rows.
538
+ - **Fix:** a bare `dateTime` column ordered against a timestamp (`R.attr.createdAt < now()`) settles
539
+ as the no-overload error CEL raises on the RFC 3339 string; it used to be compared as a timestamp,
540
+ admitting rows the PDP denies.
541
+ - New translations: `m["key"]` member reads, lookups into a map literal, `upperAscii() == "LIT"`,
542
+ `timeSince()` and `timestamp ± duration` comparisons, `isSubset`, the emptiness of the
543
+ `except`/`intersect` list functions, membership in `map()`/`filter()` results, a ternary inside
544
+ a list concatenation or a membership list, `exists`/`all` over a list built from columns, and a
545
+ concatenation of more than two string terms against a literal.
546
+ - An ordering of a boolean column against a boolean constant (`R.attr.flag < true`) translates:
547
+ CEL orders `false < true`, and Prisma's Boolean filter has only `equals`, so it becomes the bools
548
+ that satisfy it (`< true` is `equals: false`). It used to emit `lt` on a Boolean field, which the
549
+ Prisma client rejected with a validation error.
550
+ - A macro (`exists`, `all`, `exists_one`, `filter`, `map`) over a to-one relation throws
551
+ `UnsupportedQueryPlanError`: CEL ranges it over the related row's attribute names. It used to
552
+ emit `some` on a to-one relation, which the Prisma client rejected with a validation error.
553
+
554
+ - A comparison against NaN settles (`==` and orderings false, `!=` true, as the current PDP
555
+ evaluates them even against a string), and a comparison over `c ? a : b` with `c` a boolean
556
+ column that is never missing distributes over the branches when one of them then settles.
557
+ - `hierarchy(x, "")` translates: Cerbos splits on an empty delimiter per code point, so a
558
+ descendant of `C` is `startsWith(C + "_")`, and the empty path is an ancestor of every other.
559
+ - `matches()` translates for the RE2 patterns a combination of LIKE filters decides exactly (see
560
+ [Supported operators](#supported-operators)); `b == true` over a boolean expression is `b`.
561
+ - Arithmetic against a constant is solved exactly over IEEE-754 doubles: `x + c CMP v` (and `-`,
562
+ `*`, `/` by a constant) becomes the range of doubles `x` for which the rounded result compares,
563
+ found by bisecting the doubles in order, where it used to compute `v - c` — inexact for a
564
+ fraction, and refused for `==`/`!=`. `x + 1 == 3` now also admits `1.9999999999999998`, which CEL
565
+ rounds to 3. A comparison whose one column appears only as `x / x` or `x / ±0` splits on the sign
566
+ of `x` (those divisions are 1, ±Inf or NaN, never an error, on doubles).
567
+ - `all()` over a multi-hop chain translates (the hops must exist, and the predicate holds at the
568
+ end of every path). **Fix:** a negated `all()` over a chain put its negation outside the inner
569
+ hops (`some { NOT { some P } }`), which admitted a hop with no elements — where `all()` is
570
+ vacuously true — and missed a hop whose elements only partly matched; the false witness is now
571
+ an element at the end of the chain.
572
+ - A field-to-field `==`/`!=` across mixed null conventions translates instead of throwing, and
573
+ `a + b == "lit"` over two string columns is one arm per split of the literal.
574
+ - Macros over a literal list (a folded principal attribute) are expanded before translation:
575
+ `exists`/`all` over a `list(...)` of structs as well as of values, and `exists_one`,
576
+ `size(filter(...))` and `size(except(list, [x]))` whose body is `t == x` or `t != x`, which count
577
+ the multiplicity of `x` and become membership. `size(except(R.attr.list, literals))` against an
578
+ emptiness threshold becomes `exists`/`all` over the relation.
579
+ - Casts that can be inverted are solved for the column rather than refused: `string(aBool) ==
580
+ "true"` is `aBool == true`, `string(aDouble) == "-0.6"` is `aDouble == -0.6` (and false when no
581
+ double prints as the literal, as cel-go's `%g` decides), `int(aDouble) == 0` is
582
+ `-1 < aDouble < 1`, and `string()`/`double()` of a column of that type is the column, with a NULL
583
+ kept an error.
584
+ - A timestamp literal with digits past the millisecond — what the planner folds `now()` into — is
585
+ no longer refused when compared against a column: it is compared against the next millisecond
586
+ (see [Timestamps](#timestamps)).
587
+ - A list-valued `filter()`, `map()` or `except()` where a boolean is required is a CEL error, and
588
+ settles as one: a whole condition that is one returns `ALWAYS_DENIED` rather than throwing. Under
589
+ `nullAttributeRepresentation: "omitted"`, `x == null` and `x != null` over a typed column
590
+ translate to presence tests instead of being refused. A column reached through to-one relations
591
+ (`parent.x != null`) now translates too, by the comparison's polarity (see
592
+ [NULL attribute representation](#null-attribute-representation)), where it used to be refused
593
+ ([#551](https://github.com/cerbos/query-plan-adapters/issues/551)).
594
+ - `size()` of a `valueType: "string"` column translates for any threshold, as `LIKE` patterns of
595
+ `_` (`size(x) > 4` is `startsWith: "_____"`). A threshold of 2^32 or more, which no store can
596
+ hold, needs no pattern; one past 1024 characters short of that is refused. Fractional and
597
+ negative thresholds are normalised to the integer count they mean for relations too, and
598
+ `size(chain) >= 0` on a multi-hop chain is "the chain exists".
599
+ - A comparison whose outcome the declared `valueType` settles is no longer refused. CEL's
600
+ heterogeneous equality answers `==` false and `!=` true across types, so `aNumber == "5"`,
601
+ `aString == {"a": 1}` and `"2" in aNumberList` settle, and an `in`/`hasIntersection`
602
+ list drops the literals that can never match (`aNumber in ["5", 2]` is `aNumber in [2]`). A
603
+ no-overload call (`aNumber.contains("2")`, `size(aBool)`, `hierarchy(aNumber)`) is a CEL error,
604
+ which is settled only where the enclosing `!`, `&&`, `||`, `exists` and `all` fix how an error
605
+ decides the row. A column is trusted never to be missing only when its mapping says
606
+ `nullable: false` (or it is an explicit-null attribute); otherwise the settled comparison is a
607
+ presence test on it (`startsWith(x, "")`, `x > 0 || x <= 0`, `x || !x`), TRUE when the value is
608
+ there and UNKNOWN on NULL, so a missing attribute stays denied under a negation. A column reached
609
+ through `relation.fields` and a projected list element now carry the `valueType` declared for
610
+ them. A conditional plan that folds to `false` returns `ALWAYS_DENIED` instead of
611
+ throwing. This supersedes the `in value type does not match mapped <type> field` refusal below
612
+ for scalar literals.
613
+ - A shape the adapter refuses now throws `UnsupportedQueryPlanError`, an exported subclass of
614
+ `Error`. What it translates is unchanged, and existing `catch` blocks keep working; mapper
615
+ misconfiguration stays a plain `Error`.
616
+ - **Breaking:** `hasIntersection` over a relation-backed list checks its literals against the
617
+ `valueType` declared on the list's mapper entry, as `in` already did, and throws `in value type
618
+ does not match mapped <type> field` on a mismatch. It used to drop the declared type and hand the
619
+ literal to Prisma.
620
+ - **Breaking ([#495](https://github.com/cerbos/query-plan-adapters/issues/495)):** relation element
621
+ columns are now guarded as nullable unless declared `nullable: false` (previously opt-in with
622
+ `nullable: true`, which over-granted on NULL elements). Nullable columns left undeclared now return
623
+ fewer rows; required columns left undeclared need `nullable: false`. `nullable: true` mappings
624
+ are unchanged. Declarations now reach chained collections via nested `relation.fields`.
625
+ - **Breaking ([#495](https://github.com/cerbos/query-plan-adapters/issues/495)):** an empty
626
+ `and`/`or` (previously `{ AND: [] }` / `{ OR: [] }`, or folded to an unconditional filter) and a
627
+ negated sub-condition that translates to `{}` now throw. Two constants related by a hierarchy
628
+ operator, or an `overlaps` whose segments are all `nullable: false`, produce `{}`.
629
+ - **Breaking ([#414](https://github.com/cerbos/query-plan-adapters/issues/414)):** non-scalar
630
+ comparison/membership literals and bare comparisons between mapped `DateTime` columns now throw
631
+ (use `timestamp()` on both operands). Negated ternary comparisons and unsolvable string
632
+ concatenation now keep CEL's error behaviour.
633
+ - **Breaking:** `contains`/`startsWith`/`endsWith` needles and hierarchy prefixes containing a
634
+ backslash now throw. A backslash is a `LIKE` escape on PostgreSQL and MySQL and literal on SQLite,
635
+ so `contains("a\\b")` matched `"ab"` on PostgreSQL and `endsWith("\\")` failed with
636
+ `SQLSTATE 22025`.
637
+ - **Breaking:** a hierarchy with an empty delimiter (`hierarchy(R.attr.scope, "")`) now throws. It
638
+ was lowered as a `startsWith` that also matched the path itself (corpus case
639
+ `hierarchy/descendent-of/empty-delimiter` over-granted).
640
+ - **Breaking (Cerbos 0.55 compatibility):** ordered comparisons involving NaN evaluate to false, so
641
+ their negation can allow a row; Cerbos 0.54 denied it. Use Cerbos 0.55 when a policy can negate a
642
+ NaN comparison. Missing attributes and nulls are unchanged.
643
+ - **[#375](https://github.com/cerbos/query-plan-adapters/issues/375):** a negation over a single
644
+ to-one hop (`!R.attr.parent.aBool` with `type: "one"`) no longer returns rows whose relation is
645
+ absent. It emits `AND: [{ parent: { is: {} } }, { NOT: … }]` and returns fewer rows (over-grant
646
+ fix).
647
+ - **Fix:** a negated `&&`/`||` that reads a single to-one hop is pushed down with De Morgan, so each
648
+ operand requires only the hops it reads. `!(!aBool && parent.aString == "one")` used to require
649
+ the parent around the whole negation and denied a parentless row with `aBool` true, which CEL
650
+ allows because `!aBool` is false and decides the `&&` (under-grant fix).
651
+ - **Breaking:** a `matches()` pattern opening with a repetition count (`{2}a`, or one right after
652
+ `(` or `|`) is a pattern RE2 rejects, and settles as a CEL error on every row. It used to be read
653
+ as a literal brace, so its negation matched every row (over-grant fix).
654
+ - Projection relations resolve the scalar lambda variable to the mapped column. Negated equality in
655
+ `tagNames.exists(name, !(name == "public"))` now includes null list elements, as CEL does.
656
+ Function mappers may call `queryPlanToPrisma` recursively without clobbering the outer call's
657
+ model, null convention or collection scope.
658
+
659
+ ## Example application
660
+
661
+ [`example/`](example/) installs the packed adapter and runs it against a live PDP over the shared
662
+ [demo domain](../demo/README.md), including pagination and composition with an application filter:
536
663
 
537
- ### Examples and Tutorials
664
+ ```bash
665
+ # from the repository root
666
+ demo/scripts/run-example.sh prisma
667
+ ```
538
668
 
539
- - [Express + Prisma + Cerbos Example](https://github.com/cerbos/express-prisma-cerbos)
540
- - [Cerbos Query Planning Guide](https://docs.cerbos.dev/cerbos/latest/policies/compile.html)
541
- - [Prisma Filtering Guide](https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting)
669
+ A fuller app: [cerbos/express-prisma-cerbos](https://github.com/cerbos/express-prisma-cerbos).
542
670
 
543
- ### Related Projects
671
+ ## Development
544
672
 
545
- - [Cerbos JavaScript SDK](https://github.com/cerbos/cerbos-sdk-javascript)
673
+ Edit `src/`. `npm run build` (`tsc --build`) emits the published surface to `lib/`, which is
674
+ gitignored and leaves the tests out; `npm run typecheck` is the check that covers `src/` and every
675
+ `*.test.ts`.
676
+
677
+ | Command | What it runs | Needs |
678
+ | --- | --- | --- |
679
+ | `npm test` | Offline unit tests: caller-supplied options the corpus cannot vary (mapper forms, `subqueryFilter`, element nullability, `nullAttributeRepresentation`), the refusal type, the timestamp literal contract and malformed input | Nothing |
680
+ | `npm run typecheck` | `tsc` against Prisma 7 and 6 | Nothing |
681
+ | `npm run test:adversarial:v7` / `:v6` | Conformance harness on SQLite, Prisma 7 / 6 | Nothing |
682
+ | `npm run test:adversarial:postgres:v7` / `:v6` | Conformance harness on PostgreSQL | Docker |
683
+ | `npm run test:adversarial:mysql:v7` / `:v6` | Conformance harness on MySQL | Docker |
684
+
685
+ `npm run test:adversarial`, `…:postgres` and `…:mysql` alias the v7 leg. CI runs all six
686
+ store/Prisma combinations. The SQLite legs delete `prisma/dev-adversarial.db` and push the schema
687
+ into a fresh file, so they need no `--force-reset` (which Prisma refuses to run under an AI agent).
688
+
689
+ No suite starts a PDP. The harness reads the golden files under `../conformance/golden/` for both
690
+ pinned PDPs and applies [`conformance-ledger.json`](conformance-ledger.json): a case with no entry
691
+ must return exactly the recorded allowed ids, and an `unsupported` case must throw
692
+ `UnsupportedQueryPlanError`. See "The harness contract" in
693
+ [conformance/README.md](../conformance/README.md). `npm test` reads its plans from the same golden
694
+ files but pins no corpus case's filter; it needs no database or generated client, and has no v6/v7
695
+ split.
546
696
 
547
- ### Community
697
+ ## Resources
548
698
 
549
- - [Cerbos Slack](https://community.cerbos.dev)
550
- - [Cerbos GitHub Discussions](https://github.com/cerbos/cerbos/discussions)
699
+ - [Cerbos documentation](https://docs.cerbos.dev) and the [query plan API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)
700
+ - [Prisma filtering guide](https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting)
701
+ - [Cerbos Slack](https://community.cerbos.dev) · [GitHub Discussions](https://github.com/cerbos/cerbos/discussions)
551
702
 
552
703
  ## License
553
704