@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.
- package/README.md +611 -460
- package/lib/collections.d.ts +30 -0
- package/lib/collections.d.ts.map +1 -0
- package/lib/collections.js +421 -0
- package/lib/collections.js.map +1 -0
- package/lib/comparison.d.ts +24 -0
- package/lib/comparison.d.ts.map +1 -0
- package/lib/comparison.js +731 -0
- package/lib/comparison.js.map +1 -0
- package/lib/errors.d.ts +13 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +17 -0
- package/lib/errors.js.map +1 -0
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.d.ts.map +1 -0
- package/lib/evaluate.js +118 -0
- package/lib/evaluate.js.map +1 -0
- package/lib/fields.d.ts +34 -0
- package/lib/fields.d.ts.map +1 -0
- package/lib/fields.js +253 -0
- package/lib/fields.js.map +1 -0
- package/lib/functions.d.ts +5 -0
- package/lib/functions.d.ts.map +1 -0
- package/lib/functions.js +408 -0
- package/lib/functions.js.map +1 -0
- package/lib/hierarchy.d.ts +6 -0
- package/lib/hierarchy.d.ts.map +1 -0
- package/lib/hierarchy.js +266 -0
- package/lib/hierarchy.js.map +1 -0
- package/lib/index.d.ts +91 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2301
- package/lib/index.js.map +1 -1
- package/lib/literals.d.ts +20 -0
- package/lib/literals.d.ts.map +1 -0
- package/lib/literals.js +290 -0
- package/lib/literals.js.map +1 -0
- package/lib/mapping.d.ts +95 -0
- package/lib/mapping.d.ts.map +1 -0
- package/lib/mapping.js +316 -0
- package/lib/mapping.js.map +1 -0
- package/lib/narrowing.d.ts +12 -0
- package/lib/narrowing.d.ts.map +1 -0
- package/lib/narrowing.js +32 -0
- package/lib/narrowing.js.map +1 -0
- package/lib/plan.d.ts +49 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +86 -0
- package/lib/plan.js.map +1 -0
- package/lib/presence.d.ts +25 -0
- package/lib/presence.d.ts.map +1 -0
- package/lib/presence.js +82 -0
- package/lib/presence.js.map +1 -0
- package/lib/regex.d.ts +33 -0
- package/lib/regex.d.ts.map +1 -0
- package/lib/regex.js +480 -0
- package/lib/regex.js.map +1 -0
- package/lib/relations.d.ts +58 -0
- package/lib/relations.d.ts.map +1 -0
- package/lib/relations.js +175 -0
- package/lib/relations.js.map +1 -0
- package/lib/rewrite.d.ts +36 -0
- package/lib/rewrite.d.ts.map +1 -0
- package/lib/rewrite.js +366 -0
- package/lib/rewrite.js.map +1 -0
- package/lib/solve.d.ts +20 -0
- package/lib/solve.d.ts.map +1 -0
- package/lib/solve.js +77 -0
- package/lib/solve.js.map +1 -0
- package/lib/strings.d.ts +12 -0
- package/lib/strings.d.ts.map +1 -0
- package/lib/strings.js +110 -0
- package/lib/strings.js.map +1 -0
- package/lib/ternary.d.ts +12 -0
- package/lib/ternary.d.ts.map +1 -0
- package/lib/ternary.js +192 -0
- package/lib/ternary.js.map +1 -0
- package/lib/timestamp.d.ts +45 -0
- package/lib/timestamp.d.ts.map +1 -0
- package/lib/timestamp.js +225 -0
- package/lib/timestamp.js.map +1 -0
- package/lib/translate.d.ts +20 -0
- package/lib/translate.d.ts.map +1 -0
- package/lib/translate.js +287 -0
- package/lib/translate.js.map +1 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +980 -0
- package/lib/types.js.map +1 -0
- package/lib/value.d.ts +8 -0
- package/lib/value.d.ts.map +1 -0
- package/lib/value.js +7 -0
- package/lib/value.js.map +1 -0
- package/package.json +31 -18
- package/src/adversarial.test.ts +488 -488
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +597 -0
- package/src/errors.ts +12 -0
- package/src/evaluate.ts +170 -0
- package/src/fields.ts +373 -0
- package/src/functions.ts +548 -0
- package/src/generated/prisma-adversarial/browser.ts +20 -0
- package/src/generated/prisma-adversarial/client.ts +20 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +44 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
- package/src/generated/prisma-adversarial/models.ts +4 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
- package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
- package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
- package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
- package/src/generated/prisma-adversarial-pg/client.ts +86 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
- package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
- package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
- package/src/generated/prisma-adversarial-pg/models.ts +20 -0
- package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +134 -3325
- package/src/literals.ts +363 -0
- package/src/mapping.ts +474 -0
- package/src/narrowing.ts +33 -0
- package/src/plan.ts +125 -0
- package/src/presence.ts +114 -0
- package/src/regex.ts +596 -0
- package/src/relations.ts +226 -0
- package/src/rewrite.ts +524 -0
- package/src/solve.ts +96 -0
- package/src/strings.ts +152 -0
- package/src/ternary.ts +359 -0
- package/src/test-setup.adversarial-url.ts +21 -0
- package/src/test-setup.adversarial.mysql.v6.ts +13 -0
- package/src/test-setup.adversarial.mysql.v7.ts +12 -0
- package/src/test-setup.adversarial.pg.v6.ts +13 -0
- package/src/test-setup.adversarial.pg.v7.ts +9 -0
- package/src/test-setup.adversarial.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +766 -0
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
- package/src/generated/prisma/browser.ts +0 -59
- package/src/generated/prisma/internal/class.ts +0 -274
- package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
- package/src/generated/prisma/models/Category.ts +0 -1412
- package/src/generated/prisma/models/Label.ts +0 -1276
- package/src/generated/prisma/models/NestedResource.ts +0 -1562
- package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
- package/src/generated/prisma/models/Resource.ts +0 -2196
- package/src/generated/prisma/models/SubCategory.ts +0 -1412
- package/src/generated/prisma/models/Tag.ts +0 -1276
- package/src/generated/prisma/models/User.ts +0 -1503
- package/src/generated/prisma/models.ts +0 -19
- package/src/generated/prisma-v6/index.d.ts +0 -13726
- package/src/index.test.ts +0 -8885
- package/src/test-setup.v6.ts +0 -5
- package/src/test-setup.v7.ts +0 -7
- /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Resolving plan references through the caller's mapper, and the per-translation state that
|
|
2
|
+
// resolution reads: the mapper in scope, the root model, the null convention and the lambda scopes.
|
|
3
|
+
|
|
4
|
+
import type { PlanExpressionOperand } from "@cerbos/core";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Mapper,
|
|
8
|
+
MapperConfig,
|
|
9
|
+
NullAttributeRepresentation,
|
|
10
|
+
PrismaFilter,
|
|
11
|
+
} from "./index";
|
|
12
|
+
import { isNamedOperand, isOperatorOperand, isValueOperand } from "./plan";
|
|
13
|
+
import { UnsupportedQueryPlanError } from "./errors";
|
|
14
|
+
import { isFunction } from "./narrowing";
|
|
15
|
+
|
|
16
|
+
// Each translation owns its lambda scopes, including when a function mapper re-enters the
|
|
17
|
+
// adapter. Scopes collect nullable element fields for three-valued-logic guards.
|
|
18
|
+
export type LambdaScope = {
|
|
19
|
+
variableName: string;
|
|
20
|
+
relationModel: string | undefined;
|
|
21
|
+
nullableFields: Set<string>;
|
|
22
|
+
unknownFilters: PrismaFilter[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type TranslationContext = {
|
|
26
|
+
readonly mapper: Mapper;
|
|
27
|
+
readonly rootModel: string | undefined;
|
|
28
|
+
readonly nullRepresentation: NullAttributeRepresentation;
|
|
29
|
+
readonly scopes: readonly LambdaScope[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The innermost lambda scope, if the translation is inside one. */
|
|
33
|
+
export function currentScope(context: TranslationContext): LambdaScope | undefined {
|
|
34
|
+
return context.scopes[context.scopes.length - 1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RelationConfig = {
|
|
38
|
+
name: string;
|
|
39
|
+
type: "one" | "many";
|
|
40
|
+
model?: string;
|
|
41
|
+
field?: string;
|
|
42
|
+
subqueryFilter?: PrismaFilter;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ResolvedFieldReference = {
|
|
46
|
+
path: string[];
|
|
47
|
+
nullable?: boolean;
|
|
48
|
+
timestampWrapped?: boolean;
|
|
49
|
+
relations?: RelationConfig[];
|
|
50
|
+
valueType?: MapperConfig["valueType"];
|
|
51
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ResolvedValue = {
|
|
55
|
+
value: any;
|
|
56
|
+
/**
|
|
57
|
+
* The value is a timestamp literal between two milliseconds, bound as the next millisecond: a
|
|
58
|
+
* comparison against it must go through roundSubMillisecond.
|
|
59
|
+
*/
|
|
60
|
+
subMillisecond?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ResolvedOperand = ResolvedFieldReference | ResolvedValue;
|
|
64
|
+
|
|
65
|
+
export function isResolvedFieldReference(
|
|
66
|
+
operand: ResolvedOperand
|
|
67
|
+
): operand is ResolvedFieldReference {
|
|
68
|
+
return "path" in operand;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isResolvedValue(
|
|
72
|
+
operand: ResolvedOperand
|
|
73
|
+
): operand is ResolvedValue {
|
|
74
|
+
return "value" in operand;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether this reference is one the caller sends as an explicit `null` when the column is NULL. */
|
|
78
|
+
export function isExplicitNullReference(
|
|
79
|
+
fieldRef: ResolvedFieldReference
|
|
80
|
+
): boolean {
|
|
81
|
+
return fieldRef.nullAttributeRepresentation === "explicit";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getLeafField(path: string[]): string {
|
|
85
|
+
const fieldName = path[path.length - 1];
|
|
86
|
+
|
|
87
|
+
if (!fieldName) {
|
|
88
|
+
throw new Error("Field path cannot be empty");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return fieldName;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The mapping the caller declared for `key`, through either mapper form. */
|
|
95
|
+
export function lookupMapping(
|
|
96
|
+
mapper: Mapper,
|
|
97
|
+
key: string
|
|
98
|
+
): MapperConfig | undefined {
|
|
99
|
+
return isFunction(mapper) ? mapper(key) : mapper[key];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The element field mappings of the collection at `collectionPath`. A chained collection
|
|
104
|
+
* (`R.attr.a.b`) has no mapping of its own, so it is found by descending from the longest mapped
|
|
105
|
+
* prefix through the nested `relation.fields`, the same walk resolveFieldReference performs.
|
|
106
|
+
*/
|
|
107
|
+
export function lookupElementFields(
|
|
108
|
+
mapper: Mapper,
|
|
109
|
+
collectionPath: string
|
|
110
|
+
): Record<string, MapperConfig> | undefined {
|
|
111
|
+
const direct = lookupMapping(mapper, collectionPath);
|
|
112
|
+
|
|
113
|
+
if (direct) {
|
|
114
|
+
return direct.relation?.fields;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parts = collectionPath.split(".");
|
|
118
|
+
|
|
119
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
120
|
+
let relation = lookupMapping(mapper, parts.slice(0, i).join("."))?.relation;
|
|
121
|
+
|
|
122
|
+
if (!relation) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const part of parts.slice(i)) {
|
|
127
|
+
relation = relation?.fields?.[part]?.relation;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return relation?.fields;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether `reference` names a to-one relation itself, rather than a column reached through it.
|
|
138
|
+
* CEL reads such a relation as a map whose keys are the attribute names present on the related
|
|
139
|
+
* row, and no Prisma filter enumerates those.
|
|
140
|
+
*/
|
|
141
|
+
export function namesToOneRelation(mapper: Mapper, reference: string): boolean {
|
|
142
|
+
const direct = lookupMapping(mapper, reference)?.relation;
|
|
143
|
+
|
|
144
|
+
if (direct) return direct.type === "one" && direct.field === undefined;
|
|
145
|
+
const parts = reference.split(".");
|
|
146
|
+
|
|
147
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
148
|
+
let relation = lookupMapping(mapper, parts.slice(0, i).join("."))?.relation;
|
|
149
|
+
|
|
150
|
+
if (!relation) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const part of parts.slice(i)) {
|
|
155
|
+
relation = relation?.fields?.[part]?.relation;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return relation?.type === "one" && relation.field === undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toRelationConfig(
|
|
165
|
+
relation: NonNullable<MapperConfig["relation"]>
|
|
166
|
+
): RelationConfig {
|
|
167
|
+
return {
|
|
168
|
+
name: relation.name,
|
|
169
|
+
type: relation.type,
|
|
170
|
+
model: relation.model,
|
|
171
|
+
field: relation.field,
|
|
172
|
+
subqueryFilter: relation.subqueryFilter,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolves a field reference considering relations and nested fields.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveFieldReference(
|
|
180
|
+
reference: string,
|
|
181
|
+
context: TranslationContext
|
|
182
|
+
): ResolvedFieldReference {
|
|
183
|
+
const parts = reference.split(".");
|
|
184
|
+
const config = lookupMapping(context.mapper, reference);
|
|
185
|
+
|
|
186
|
+
// Without a direct match, the longest dotted prefix that has a mapping applies.
|
|
187
|
+
let matchedPrefix = "";
|
|
188
|
+
let matchedConfig: MapperConfig | undefined;
|
|
189
|
+
|
|
190
|
+
if (!config) {
|
|
191
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
192
|
+
const prefix = parts.slice(0, i + 1).join(".");
|
|
193
|
+
const prefixConfig = lookupMapping(context.mapper, prefix);
|
|
194
|
+
|
|
195
|
+
if (prefixConfig) {
|
|
196
|
+
matchedPrefix = prefix;
|
|
197
|
+
matchedConfig = prefixConfig;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const activeConfig = config ?? matchedConfig;
|
|
204
|
+
|
|
205
|
+
// A scalar column has no keys: reading one beneath it would compare the column itself.
|
|
206
|
+
if (!config && matchedConfig && !matchedConfig.relation) {
|
|
207
|
+
throw new UnsupportedQueryPlanError(
|
|
208
|
+
`"${reference}" reads "${reference.slice(matchedPrefix.length + 1)}" beneath "${matchedPrefix}", which is mapped to a scalar field; map the full reference`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!activeConfig?.relation) {
|
|
213
|
+
return {
|
|
214
|
+
path: [activeConfig?.field || reference],
|
|
215
|
+
valueType: activeConfig?.valueType,
|
|
216
|
+
nullable: activeConfig?.nullable,
|
|
217
|
+
nullAttributeRepresentation: activeConfig?.nullAttributeRepresentation,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { fields } = activeConfig.relation;
|
|
222
|
+
|
|
223
|
+
const remainingParts = matchedPrefix
|
|
224
|
+
? parts.slice(matchedPrefix.split(".").length)
|
|
225
|
+
: [];
|
|
226
|
+
|
|
227
|
+
const lastPart = parts[parts.length - 1];
|
|
228
|
+
|
|
229
|
+
// A function mapper may return a relation config for the full leaf reference.
|
|
230
|
+
// Resolve that leaf here too, before any filter is constructed.
|
|
231
|
+
let field =
|
|
232
|
+
activeConfig.relation.field ??
|
|
233
|
+
(config && fields && lastPart
|
|
234
|
+
? (fields[lastPart]?.field ?? lastPart)
|
|
235
|
+
: undefined);
|
|
236
|
+
|
|
237
|
+
const relations = [toRelationConfig(activeConfig.relation)];
|
|
238
|
+
|
|
239
|
+
// Walk the remaining segments through nested relation mappings; the first segment that is
|
|
240
|
+
// not a relation names the leaf column.
|
|
241
|
+
let leafConfig: MapperConfig | undefined;
|
|
242
|
+
|
|
243
|
+
if (fields) {
|
|
244
|
+
let currentMapper: Record<string, MapperConfig> = fields;
|
|
245
|
+
let currentParts = remainingParts;
|
|
246
|
+
|
|
247
|
+
while (currentParts.length > 0) {
|
|
248
|
+
const currentPart = currentParts[0];
|
|
249
|
+
|
|
250
|
+
if (!currentPart) {
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const nextConfig: MapperConfig | undefined = currentMapper[currentPart];
|
|
255
|
+
|
|
256
|
+
if (!nextConfig?.relation) {
|
|
257
|
+
const leafPart = currentParts[currentParts.length - 1];
|
|
258
|
+
|
|
259
|
+
if (leafPart) {
|
|
260
|
+
field = nextConfig?.field || leafPart;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (currentParts.length === 1) leafConfig = nextConfig;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
relations.push(toRelationConfig(nextConfig.relation));
|
|
268
|
+
field = nextConfig.relation.field;
|
|
269
|
+
currentMapper = nextConfig.relation.fields || {};
|
|
270
|
+
currentParts = currentParts.slice(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
path: field ? [field] : remainingParts,
|
|
276
|
+
relations,
|
|
277
|
+
// A column reached through a relation is typed by its own entry in `relation.fields`.
|
|
278
|
+
valueType: leafConfig?.valueType ?? activeConfig.valueType,
|
|
279
|
+
nullable: leafConfig ? leafConfig.nullable : activeConfig.nullable,
|
|
280
|
+
nullAttributeRepresentation: activeConfig.nullAttributeRepresentation,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Records that the lambda body being built touches a nullable element column, so the
|
|
286
|
+
* enclosing collection operator can add its three-valued-logic guard.
|
|
287
|
+
*
|
|
288
|
+
* An element column is nullable unless its mapping says `nullable: false`. Omitting the guard is
|
|
289
|
+
* what over-grants (a negated `exists`, an `all` or a `hasIntersection` over `map` would admit
|
|
290
|
+
* rows holding a NULL element the PDP denies), so silence has to mean the safe reading. A
|
|
291
|
+
* nested relation is not a column and never gets a NULL guard.
|
|
292
|
+
*/
|
|
293
|
+
function recordNullableElementField(
|
|
294
|
+
context: TranslationContext,
|
|
295
|
+
config: MapperConfig,
|
|
296
|
+
defaultField: string
|
|
297
|
+
): void {
|
|
298
|
+
if (config.nullable === false || config.relation) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
currentScope(context)?.nullableFields.add(config.field || defaultField);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Records that `operand`, the bare variable of a projected scalar list (`tagNames.all(t, ...)`),
|
|
307
|
+
* is read by a function that raises an error on a null element (`t.startsWith(...)`,
|
|
308
|
+
* `t.upperAscii()`). A null element of such a list is a VALUE, so resolving the variable records
|
|
309
|
+
* nothing; it is the function that errors, and the enclosing macro needs the same guard as for a
|
|
310
|
+
* missing element column.
|
|
311
|
+
*/
|
|
312
|
+
export function recordErroringNullElement(
|
|
313
|
+
context: TranslationContext,
|
|
314
|
+
operand: PlanExpressionOperand,
|
|
315
|
+
resolved: ResolvedOperand
|
|
316
|
+
): void {
|
|
317
|
+
if (!isNamedOperand(operand) || !isResolvedFieldReference(resolved)) return;
|
|
318
|
+
|
|
319
|
+
if (resolved.nullable === false || (resolved.relations?.length ?? 0) > 0) return;
|
|
320
|
+
|
|
321
|
+
const scope = [...context.scopes]
|
|
322
|
+
.reverse()
|
|
323
|
+
.find((candidate) => candidate.variableName === operand.name);
|
|
324
|
+
|
|
325
|
+
scope?.nullableFields.add(getLeafField(resolved.path));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The context for a lambda body iterating `collectionPath`: `scope` is pushed, and references
|
|
330
|
+
* to the lambda variable resolve against the collection's element mapping.
|
|
331
|
+
*/
|
|
332
|
+
export function enterLambdaScope(
|
|
333
|
+
context: TranslationContext,
|
|
334
|
+
collectionPath: string,
|
|
335
|
+
scope: LambdaScope
|
|
336
|
+
): TranslationContext {
|
|
337
|
+
const scopedContext: TranslationContext = {
|
|
338
|
+
...context,
|
|
339
|
+
scopes: [...context.scopes, scope],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const { variableName } = scope;
|
|
343
|
+
const fullMapper = context.mapper;
|
|
344
|
+
|
|
345
|
+
const scopedMapper: Mapper = (key: string) => {
|
|
346
|
+
if (key === variableName) {
|
|
347
|
+
const { relations } = resolveFieldReference(collectionPath, scopedContext);
|
|
348
|
+
const projection = relations?.[relations.length - 1]?.field;
|
|
349
|
+
|
|
350
|
+
if (projection) {
|
|
351
|
+
return {
|
|
352
|
+
field: projection,
|
|
353
|
+
valueType:
|
|
354
|
+
lookupMapping(fullMapper, collectionPath)?.valueType ??
|
|
355
|
+
lookupElementFields(fullMapper, collectionPath)?.[projection]?.valueType,
|
|
356
|
+
// A projected list carries null values, unlike missing fields on object elements.
|
|
357
|
+
nullAttributeRepresentation: "explicit",
|
|
358
|
+
nullable: lookupElementFields(fullMapper, collectionPath)?.[projection]?.nullable,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A key that starts with the variable name accesses the collection element.
|
|
364
|
+
if (key.startsWith(variableName + ".")) {
|
|
365
|
+
const strippedKey = key.replace(variableName + ".", "");
|
|
366
|
+
const baseConfig = lookupElementFields(fullMapper, collectionPath);
|
|
367
|
+
|
|
368
|
+
if (!baseConfig) {
|
|
369
|
+
const fieldConfig = { field: strippedKey };
|
|
370
|
+
|
|
371
|
+
if (!strippedKey.includes(".")) {
|
|
372
|
+
recordNullableElementField(scopedContext, fieldConfig, strippedKey);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return fieldConfig;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// For nested paths, traverse the fields configuration.
|
|
379
|
+
const parts = strippedKey.split(".");
|
|
380
|
+
let currentConfig = baseConfig;
|
|
381
|
+
let field = parts[0] || strippedKey;
|
|
382
|
+
|
|
383
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
384
|
+
const part = parts[i];
|
|
385
|
+
const nextPart = parts[i + 1];
|
|
386
|
+
|
|
387
|
+
if (!part || !nextPart) {
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const nextFields = currentConfig[part]?.relation?.fields;
|
|
392
|
+
|
|
393
|
+
if (!nextFields) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
currentConfig = nextFields;
|
|
398
|
+
field = nextPart;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The field config if it exists, otherwise a default one.
|
|
402
|
+
const fieldConfig = currentConfig[field] || { field };
|
|
403
|
+
recordNullableElementField(scopedContext, fieldConfig, field);
|
|
404
|
+
|
|
405
|
+
return fieldConfig;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Keys not referencing the collection element resolve through the enclosing mapper.
|
|
409
|
+
if (isFunction(fullMapper)) {
|
|
410
|
+
return fullMapper(key);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return fullMapper[key] || { field: key };
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
return { ...scopedContext, mapper: scopedMapper };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Guards every site that would emit a NULL-selecting predicate out of a `null` comparison
|
|
421
|
+
* operand.
|
|
422
|
+
*
|
|
423
|
+
* Under the `"omitted"` representation a NULL column carries no attribute, so CEL raises a
|
|
424
|
+
* missing-attribute error and `check()` denies the row — `IS NULL` would return exactly the rows
|
|
425
|
+
* the PDP refuses. The rejection is deliberately wider than the over-granting shapes: `ne(x,
|
|
426
|
+
* null)` on its own is aligned, but Prisma applies negation by wrapping (`{ NOT: ... }`) rather
|
|
427
|
+
* than by pushing it into the leaf, so a leaf cannot tell whether an enclosing `not` will flip
|
|
428
|
+
* `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is correct
|
|
429
|
+
* under any nesting; narrowing it requires negation-parity tracking.
|
|
430
|
+
*/
|
|
431
|
+
export function assertNullOperandTranslatable(
|
|
432
|
+
translation: TranslationContext,
|
|
433
|
+
context: string,
|
|
434
|
+
declared?: NullAttributeRepresentation
|
|
435
|
+
): void {
|
|
436
|
+
if ((declared ?? translation.nullRepresentation) === "omitted") {
|
|
437
|
+
throw new UnsupportedQueryPlanError(
|
|
438
|
+
`Cannot translate ${context} under nullAttributeRepresentation "omitted": a NULL column ` +
|
|
439
|
+
"sends no attribute, so Cerbos evaluates the comparison as a missing-attribute error " +
|
|
440
|
+
"(deny) while a NULL-selecting filter would return those rows. Send NULL columns as " +
|
|
441
|
+
'explicit nulls and use "explicit", or keep this shape out of the policy.'
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Applies the null-operand guard to struct literals (`set-field` members) anywhere in the plan,
|
|
448
|
+
* under the convention of the attribute each literal is compared with.
|
|
449
|
+
*/
|
|
450
|
+
export function assertStructuralNulls(
|
|
451
|
+
operand: PlanExpressionOperand,
|
|
452
|
+
context: TranslationContext,
|
|
453
|
+
declared?: NullAttributeRepresentation
|
|
454
|
+
): void {
|
|
455
|
+
if (!isOperatorOperand(operand)) return;
|
|
456
|
+
const sibling = operand.operands.find(isNamedOperand);
|
|
457
|
+
const mapping = sibling && lookupMapping(context.mapper, sibling.name);
|
|
458
|
+
const convention = mapping?.nullAttributeRepresentation ?? declared;
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
operand.operator === "set-field" &&
|
|
462
|
+
operand.operands.some((part) => isValueOperand(part) && part.value === null)
|
|
463
|
+
) {
|
|
464
|
+
assertNullOperandTranslatable(
|
|
465
|
+
context,
|
|
466
|
+
"a null member in a struct literal",
|
|
467
|
+
convention
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
operand.operands.forEach((part) =>
|
|
472
|
+
assertStructuralNulls(part, context, convention)
|
|
473
|
+
);
|
|
474
|
+
}
|
package/src/narrowing.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Value } from "@cerbos/core";
|
|
2
|
+
|
|
3
|
+
export const isString = <T>(value: T): value is T & string => typeof value === "string";
|
|
4
|
+
|
|
5
|
+
export const isNumber = <T>(value: T): value is T & number => typeof value === "number";
|
|
6
|
+
|
|
7
|
+
export const isBoolean = <T>(value: T): value is T & boolean => typeof value === "boolean";
|
|
8
|
+
|
|
9
|
+
export const isFunction = <T>(value: T): value is Extract<T, Function> => typeof value === "function";
|
|
10
|
+
|
|
11
|
+
export const isObject = <T>(value: T): value is T & object => typeof value === "object" && value !== null;
|
|
12
|
+
|
|
13
|
+
export const typeofValue = (value: Value): "string" | "number" | "boolean" | "object" =>
|
|
14
|
+
isString(value) ? "string" : isNumber(value) ? "number" : isBoolean(value) ? "boolean" : "object";
|
|
15
|
+
|
|
16
|
+
const hasKey = <T extends object>(table: T, key: PropertyKey): key is keyof T => Object.hasOwn(table, key);
|
|
17
|
+
|
|
18
|
+
export const lookup = <T extends object>(table: T, key: PropertyKey): T[keyof T] | undefined =>
|
|
19
|
+
hasKey(table, key) ? table[key] : undefined;
|
|
20
|
+
|
|
21
|
+
const missingOperand = (): never => {
|
|
22
|
+
throw new Error("An expression is missing an operand");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const single = <T>([a]: readonly T[]): [T] => [a ?? missingOperand()];
|
|
26
|
+
|
|
27
|
+
export const pair = <T>([a, b]: readonly T[]): [T, T] => [a ?? missingOperand(), b ?? missingOperand()];
|
|
28
|
+
|
|
29
|
+
export const triple = <T>([a, b, c]: readonly T[]): [T, T, T] => [
|
|
30
|
+
a ?? missingOperand(),
|
|
31
|
+
b ?? missingOperand(),
|
|
32
|
+
c ?? missingOperand(),
|
|
33
|
+
];
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Reading the planner's expression tree: operand shapes, and the comparison-operator tables every
|
|
2
|
+
// handler shares.
|
|
3
|
+
|
|
4
|
+
import type { PlanExpressionOperand, Value } from "@cerbos/core";
|
|
5
|
+
import { UnsupportedQueryPlanError } from "./errors";
|
|
6
|
+
import { lookup } from "./narrowing";
|
|
7
|
+
|
|
8
|
+
export interface NamedOperand {
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ValueOperand {
|
|
13
|
+
value: Value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OperatorOperand {
|
|
17
|
+
operator: string;
|
|
18
|
+
operands: PlanExpressionOperand[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isNamedOperand(
|
|
22
|
+
operand: PlanExpressionOperand
|
|
23
|
+
): operand is NamedOperand {
|
|
24
|
+
return "name" in operand && typeof operand.name === "string";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isValueOperand(
|
|
28
|
+
operand: PlanExpressionOperand
|
|
29
|
+
): operand is ValueOperand {
|
|
30
|
+
return "value" in operand && operand.value !== undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isOperatorOperand(
|
|
34
|
+
operand: PlanExpressionOperand
|
|
35
|
+
): operand is OperatorOperand {
|
|
36
|
+
return (
|
|
37
|
+
"operator" in operand &&
|
|
38
|
+
typeof operand.operator === "string" &&
|
|
39
|
+
"operands" in operand &&
|
|
40
|
+
Array.isArray(operand.operands)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertDefined<T>(value: T | undefined, message: string): T {
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
throw new UnsupportedQueryPlanError(message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refuses an `and`/`or` with no operands. The planner never emits one, and neither empty form has
|
|
54
|
+
* a safe rendering: `{ AND: [] }` is true in Prisma and matches every row, and the constant folder
|
|
55
|
+
* would reduce an empty `and` to `true` (an unconditional filter) before the translator saw it. A
|
|
56
|
+
* malformed plan is a bug report, never a filter.
|
|
57
|
+
*/
|
|
58
|
+
export function assertLogicalOperands(
|
|
59
|
+
operator: string,
|
|
60
|
+
operands: PlanExpressionOperand[]
|
|
61
|
+
): void {
|
|
62
|
+
if ((operator === "and" || operator === "or") && operands.length === 0) {
|
|
63
|
+
throw new UnsupportedQueryPlanError(
|
|
64
|
+
`${operator} requires at least one operand: an empty ${operator} is not something the ` +
|
|
65
|
+
"planner emits, and translating it would produce an unconditional filter " +
|
|
66
|
+
"({ AND: [] } matches every row)"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The Prisma scalar filter each Cerbos comparison operator lowers to. */
|
|
72
|
+
export const CERBOS_TO_PRISMA_OPERATOR = {
|
|
73
|
+
eq: "equals",
|
|
74
|
+
ne: "not",
|
|
75
|
+
lt: "lt",
|
|
76
|
+
le: "lte",
|
|
77
|
+
gt: "gt",
|
|
78
|
+
ge: "gte",
|
|
79
|
+
} satisfies Record<string, string>;
|
|
80
|
+
|
|
81
|
+
/** The Cerbos comparison operators: exactly the keys of CERBOS_TO_PRISMA_OPERATOR. */
|
|
82
|
+
export const COMPARISON_OPERATORS: ReadonlySet<string> = new Set(
|
|
83
|
+
Object.keys(CERBOS_TO_PRISMA_OPERATOR)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Directional operators mirror when their operands swap sides; symmetric operators are unchanged.
|
|
87
|
+
const MIRRORED_OPERATOR = {
|
|
88
|
+
lt: "gt",
|
|
89
|
+
gt: "lt",
|
|
90
|
+
le: "ge",
|
|
91
|
+
ge: "le",
|
|
92
|
+
} satisfies Record<string, string>;
|
|
93
|
+
|
|
94
|
+
/** The operator that holds when the two operands of `operator` swap sides. */
|
|
95
|
+
export function mirrorOperator(operator: string): string {
|
|
96
|
+
return lookup(MIRRORED_OPERATOR, operator) ?? operator;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type BinaryComparison = { operator: string; operands: PlanExpressionOperand[] };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalize a binary comparison to field-side-first. The planner preserves policy source
|
|
103
|
+
* order, so a constant may precede the field it constrains (`1 < R.attr.x` arrives as
|
|
104
|
+
* `lt(value, variable)`) — swap the operands and mirror directional operators so downstream
|
|
105
|
+
* handlers can assume the field/expression side is first. Without this, value-first
|
|
106
|
+
* comparisons are silently inverted (#256).
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeBinaryOperands(
|
|
109
|
+
operator: string,
|
|
110
|
+
operands: PlanExpressionOperand[]
|
|
111
|
+
): BinaryComparison {
|
|
112
|
+
const [first, second] = operands;
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
operands.length === 2 &&
|
|
116
|
+
first !== undefined &&
|
|
117
|
+
second !== undefined &&
|
|
118
|
+
isValueOperand(first) &&
|
|
119
|
+
!isValueOperand(second)
|
|
120
|
+
) {
|
|
121
|
+
return { operator: mirrorOperator(operator), operands: [second, first] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { operator, operands };
|
|
125
|
+
}
|