@cerbos/orm-prisma 1.1.1 → 2.0.0-alpha.1

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/lib/index.js CHANGED
@@ -1,165 +1,341 @@
1
- import { PlanKind as PK, } from "@cerbos/core";
2
- export const PlanKind = PK;
3
- export function queryPlanToPrisma({ queryPlan, fieldNameMapper, relationMapper = {}, }) {
4
- const toFieldName = (key) => {
5
- if (typeof fieldNameMapper === "function") {
6
- return fieldNameMapper(key);
7
- }
8
- else if (fieldNameMapper[key]) {
9
- return fieldNameMapper[key];
10
- }
11
- else {
12
- return key;
13
- }
14
- };
15
- const toRelationName = (key) => {
16
- if (typeof relationMapper === "function") {
17
- return relationMapper(key);
18
- }
19
- else if (relationMapper[key]) {
20
- return relationMapper[key];
21
- }
22
- return null;
23
- };
1
+ import { PlanKind, } from "@cerbos/core";
2
+ export { PlanKind };
3
+ /**
4
+ * Converts a Cerbos query plan to a Prisma filter.
5
+ * @param {QueryPlanToPrismaArgs} args - The arguments containing the query plan and mapper.
6
+ * @returns {QueryPlanToPrismaResult} The result containing the kind and filters.
7
+ */
8
+ export function queryPlanToPrisma({ queryPlan, mapper = {}, }) {
9
+ // Choose filter conversion based on plan kind
24
10
  switch (queryPlan.kind) {
25
11
  case PlanKind.ALWAYS_ALLOWED:
26
12
  return {
27
13
  kind: PlanKind.ALWAYS_ALLOWED,
28
- filters: {},
29
14
  };
30
15
  case PlanKind.ALWAYS_DENIED:
31
- return {
32
- kind: PlanKind.ALWAYS_DENIED,
33
- };
16
+ return { kind: PlanKind.ALWAYS_DENIED };
34
17
  case PlanKind.CONDITIONAL:
35
18
  return {
36
19
  kind: PlanKind.CONDITIONAL,
37
- filters: mapOperand(queryPlan.condition, toFieldName, toRelationName),
20
+ filters: buildPrismaFilterFromCerbosExpression(queryPlan.condition, mapper),
38
21
  };
39
22
  default:
40
23
  throw Error(`Invalid query plan.`);
41
24
  }
42
25
  }
43
- function isExpression(e) {
44
- return e.operator !== undefined;
45
- }
46
- function isValue(e) {
47
- return e.value !== undefined;
48
- }
49
- function isVariable(e) {
50
- return e.variable !== undefined;
51
- }
52
- function getOperandVariable(operands) {
53
- const op = operands.find((o) => o.hasOwnProperty("name"));
54
- if (!op)
55
- return;
56
- return op.name;
57
- }
58
- function getOperandValue(operands) {
59
- const op = operands.find((o) => isValue(o));
60
- if (!op)
61
- return;
62
- return op.value;
63
- }
64
- const OPERATORS = {
65
- eq: {
66
- relationalCondition: "is",
67
- fieldCondition: "equals",
68
- },
69
- ne: {
70
- relationalCondition: "isNot",
71
- fieldCondition: "not",
72
- },
73
- in: {
74
- relationalCondition: "some",
75
- fieldCondition: "in",
76
- },
77
- lt: {
78
- fieldCondition: "lt",
79
- },
80
- gt: {
81
- fieldCondition: "gt",
82
- },
83
- le: {
84
- fieldCondition: "lte",
85
- },
86
- ge: {
87
- fieldCondition: "gte",
88
- },
89
- };
90
- function mapOperand(operand, getFieldName, getRelationName, output = {}) {
91
- if (!isExpression(operand))
92
- throw Error(`Query plan did not contain an expression for operand ${operand}`);
93
- const { operator, operands } = operand;
94
- // HANDLE NESTING OPERATIONS: AND/OR/NOT
95
- if (operator == "and") {
96
- if (operands.length < 2)
97
- throw Error("Expected atleast 2 operands");
98
- output.AND = operands.map((o) => mapOperand(o, getFieldName, getRelationName, {}));
99
- return output;
100
- }
101
- if (operator == "or") {
102
- if (operands.length < 2)
103
- throw Error("Expected atleast 2 operands");
104
- output.OR = operands.map((o) => mapOperand(o, getFieldName, getRelationName, {}));
105
- return output;
106
- }
107
- if (operator == "not") {
108
- if (operands.length > 1)
109
- throw Error("Expected only one operand");
110
- output.NOT = operands.map((o) => mapOperand(o, getFieldName, getRelationName, {}))[0];
111
- return output;
112
- }
113
- // get the operation parameters
114
- const operation = OPERATORS[operator];
115
- if (!operation)
116
- throw Error(`Unsupported operator ${operator}`);
117
- const opVariable = getOperandVariable(operands);
118
- if (!opVariable)
119
- throw Error(`Unexpected variable ${operands}`);
120
- const opValue = getOperandValue(operands);
121
- const relation = getRelationName(opVariable);
122
- const fieldName = getFieldName(opVariable);
123
- // There is a relational mapper for this variable
124
- if (relation && operation.relationalCondition) {
125
- output[relation.relation] = {
126
- [operation.relationalCondition]: {
127
- [relation.field]: opValue,
128
- },
129
- };
130
- return output;
26
+ /**
27
+ * Resolves a field reference considering relations.
28
+ * @param {string} reference - The field reference.
29
+ * @param {Mapper} mapper - The mapper configuration.
30
+ * @returns {object} The resolved field reference with path and relation.
31
+ */
32
+ const resolveFieldReference = (reference, mapper) => {
33
+ const parts = reference.split(".");
34
+ const lastPart = parts[parts.length - 1];
35
+ // First try exact match
36
+ const config = typeof mapper === "function" ? mapper(reference) : mapper[reference];
37
+ if (config) {
38
+ if (config.relation) {
39
+ const { name, type, field, fields } = config.relation;
40
+ return {
41
+ path: [name, field].filter(Boolean),
42
+ relation: { name, type, field, nestedMapper: fields },
43
+ };
44
+ }
45
+ return { path: [config.field || reference] };
131
46
  }
132
- else if (fieldName && operation.fieldCondition) {
133
- // There is a field mapper for this variable
134
- const fieldPath = fieldName.split(".");
135
- if (fieldPath.length > 1) {
136
- output[fieldPath[0]] = convertPathToJSON(fieldPath.splice(1), {
137
- [operation.fieldCondition]: opValue,
138
- });
139
- }
140
- else {
141
- output[fieldPath[0]] = {
142
- [operation.fieldCondition]: opValue,
47
+ // Check for parent relation with multiple parts
48
+ if (parts.length > 1) {
49
+ const parentPath = parts.slice(0, -1).join(".");
50
+ const parentConfig = typeof mapper === "function" ? mapper(parentPath) : mapper[parentPath];
51
+ if (parentConfig === null || parentConfig === void 0 ? void 0 : parentConfig.relation) {
52
+ const { name, type, fields } = parentConfig.relation;
53
+ const fieldConfig = fields === null || fields === void 0 ? void 0 : fields[lastPart];
54
+ const fieldName = (fieldConfig === null || fieldConfig === void 0 ? void 0 : fieldConfig.field) || lastPart;
55
+ return {
56
+ path: [name],
57
+ relation: { name, type, field: fieldName, nestedMapper: fields },
143
58
  };
144
59
  }
145
- return output;
146
60
  }
147
- else {
148
- throw Error("Failed to map");
61
+ // Fallback to raw reference if no mapper fits
62
+ return { path: [reference] };
63
+ };
64
+ /**
65
+ * Determines the appropriate Prisma operator based on relation type.
66
+ * @param {object} relation - The relation configuration.
67
+ * @returns {string} The Prisma relation operator.
68
+ */
69
+ const getPrismaRelationOperator = (relation) => (relation.type === "one" ? "is" : "some");
70
+ /**
71
+ * Builds Prisma WHERE conditions from a Cerbos expression.
72
+ * @param {PlanExpressionOperand} expression - The Cerbos expression.
73
+ * @param {Mapper} mapper - The mapper configuration.
74
+ * @returns {PrismaFilter} The Prisma filter.
75
+ */
76
+ const buildPrismaFilterFromCerbosExpression = (expression, mapper) => {
77
+ if (!("operator" in expression) || !("operands" in expression)) {
78
+ throw new Error("Invalid Cerbos expression structure");
149
79
  }
150
- }
151
- function convertPathToJSON(segments, value) {
152
- const result = {};
153
- let current = result;
154
- for (let i = 0; i < segments.length; i++) {
155
- const segment = segments[i];
156
- if (i === segments.length - 1) {
157
- current[segment] = value;
158
- }
159
- else {
160
- current[segment] = {};
161
- current = current[segment];
80
+ const { operator, operands } = expression;
81
+ /**
82
+ * Helper function to resolve an operand.
83
+ * @param {PlanExpressionOperand} operand - The operand from expression.
84
+ * @returns {any} An object with a field reference or value.
85
+ */
86
+ const resolveOperand = (operand) => {
87
+ if ("name" in operand && operand.name) {
88
+ return resolveFieldReference(operand.name, mapper);
89
+ }
90
+ else if ("value" in operand && operand.value !== undefined) {
91
+ return { value: operand.value };
162
92
  }
93
+ else if ("operator" in operand) {
94
+ // Nested expression resolution
95
+ const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper);
96
+ return { value: nestedResult };
97
+ }
98
+ throw new Error("Operand must have name, value, or be an expression");
99
+ };
100
+ // Process the operator type
101
+ switch (operator) {
102
+ case "and": {
103
+ // Combine operands with logical AND
104
+ return {
105
+ AND: operands.map((operand) => buildPrismaFilterFromCerbosExpression(operand, mapper)),
106
+ };
107
+ }
108
+ case "or": {
109
+ // Combine operands with logical OR
110
+ return {
111
+ OR: operands.map((operand) => buildPrismaFilterFromCerbosExpression(operand, mapper)),
112
+ };
113
+ }
114
+ case "not": {
115
+ // Negate the operand filter
116
+ return {
117
+ NOT: buildPrismaFilterFromCerbosExpression(operands[0], mapper),
118
+ };
119
+ }
120
+ case "eq":
121
+ case "ne":
122
+ case "lt":
123
+ case "le":
124
+ case "gt":
125
+ case "ge": {
126
+ // Relational operators: find left and right operands
127
+ const leftOperand = operands.find((o) => "name" in o || "operator" in o);
128
+ if (!leftOperand)
129
+ throw new Error("No valid left operand found");
130
+ const rightOperand = operands.find((o) => o !== leftOperand);
131
+ if (!rightOperand)
132
+ throw new Error("No valid right operand found");
133
+ const left = resolveOperand(leftOperand);
134
+ const right = resolveOperand(rightOperand);
135
+ const prismaOperator = {
136
+ eq: "equals",
137
+ ne: "not",
138
+ lt: "lt",
139
+ le: "lte",
140
+ gt: "gt",
141
+ ge: "gte",
142
+ }[operator];
143
+ if ("path" in left) {
144
+ const { path, relation } = left;
145
+ if (relation) {
146
+ const relationOperator = getPrismaRelationOperator(relation);
147
+ if (relation.field) {
148
+ return {
149
+ [relation.name]: {
150
+ [relationOperator]: {
151
+ [relation.field]: { [prismaOperator]: right.value },
152
+ },
153
+ },
154
+ };
155
+ }
156
+ return {
157
+ [relation.name]: {
158
+ [relationOperator]: { [prismaOperator]: right.value },
159
+ },
160
+ };
161
+ }
162
+ // Create nested filter for field path
163
+ return path.reduceRight((acc, key, index) => index === path.length - 1
164
+ ? { [key]: { [prismaOperator]: right.value } }
165
+ : { [key]: acc }, {});
166
+ }
167
+ return { [prismaOperator]: right.value };
168
+ }
169
+ case "in": {
170
+ // Inclusion operator for lists
171
+ const { path, relation } = resolveOperand(operands.find((o) => "name" in o));
172
+ const { value } = resolveOperand(operands.find((o) => "value" in o));
173
+ if (relation) {
174
+ const relationOperator = getPrismaRelationOperator(relation);
175
+ return {
176
+ [relation.name]: { [relationOperator]: { [relation.field]: value } },
177
+ };
178
+ }
179
+ return path.reduceRight((acc, key, index) => index === path.length - 1 ? { [key]: { in: value } } : { [key]: acc }, {});
180
+ }
181
+ case "contains":
182
+ case "startsWith":
183
+ case "endsWith": {
184
+ // String operators; ensure value is a string
185
+ const leftOperand = operands.find((o) => "name" in o);
186
+ if (!leftOperand)
187
+ throw new Error("No operand with 'name' found");
188
+ const rightOperand = operands.find((o) => "value" in o);
189
+ if (!rightOperand)
190
+ throw new Error("No operand with 'value' found");
191
+ const { path, relation } = resolveOperand(leftOperand);
192
+ const { value } = resolveOperand(rightOperand);
193
+ if (typeof value !== "string")
194
+ throw new Error(`${operator} operator requires string value`);
195
+ if (relation) {
196
+ const relationOperator = getPrismaRelationOperator(relation);
197
+ return {
198
+ [relation.name]: {
199
+ [relationOperator]: { [relation.field]: { [operator]: value } },
200
+ },
201
+ };
202
+ }
203
+ return path.reduceRight((acc, key, index) => index === path.length - 1
204
+ ? { [key]: { [operator]: value } }
205
+ : { [key]: acc }, {});
206
+ }
207
+ case "isSet": {
208
+ // Check if a field is set (not null) or unset (null)
209
+ const leftOperand = operands.find((o) => "name" in o);
210
+ if (!leftOperand)
211
+ throw new Error("No operand with 'name' found");
212
+ const rightOperand = operands.find((o) => "value" in o);
213
+ if (!rightOperand)
214
+ throw new Error("No operand with 'value' found");
215
+ const { path, relation } = resolveOperand(leftOperand);
216
+ const { value } = resolveOperand(rightOperand);
217
+ if (relation) {
218
+ const relationOperator = getPrismaRelationOperator(relation);
219
+ return {
220
+ [relation.name]: {
221
+ [relationOperator]: {
222
+ [relation.field]: value ? { not: null } : { equals: null },
223
+ },
224
+ },
225
+ };
226
+ }
227
+ return path.reduceRight((acc, key, index) => index === path.length - 1
228
+ ? { [key]: value ? { not: null } : { equals: null } }
229
+ : { [key]: acc }, {});
230
+ }
231
+ case "hasIntersection": {
232
+ // Ensure exactly two operands for intersection check
233
+ if (operands.length !== 2)
234
+ throw new Error("hasIntersection requires exactly two operands");
235
+ const [leftOperand, rightOperand] = operands;
236
+ // Handle map expressions as first operand
237
+ if ("operator" in leftOperand && leftOperand.operator === "map") {
238
+ const mapResult = buildPrismaFilterFromCerbosExpression(leftOperand, mapper);
239
+ const relationKey = Object.keys(mapResult)[0];
240
+ const relation = mapResult[relationKey];
241
+ const selectKey = Object.keys(relation[Object.keys(relation)[0]].select)[0];
242
+ if (!("value" in rightOperand))
243
+ throw new Error("Second operand of hasIntersection must be a value");
244
+ return {
245
+ [relationKey]: { some: { [selectKey]: { in: rightOperand.value } } },
246
+ };
247
+ }
248
+ // Handle direct field reference
249
+ if (!("name" in leftOperand))
250
+ throw new Error("First operand of hasIntersection must be a field reference or map expression");
251
+ if (!("value" in rightOperand))
252
+ throw new Error("Second operand of hasIntersection must be a value");
253
+ const { path, relation } = resolveFieldReference(leftOperand.name, mapper);
254
+ if (!Array.isArray(rightOperand.value))
255
+ throw new Error("hasIntersection requires an array value");
256
+ if (relation) {
257
+ return {
258
+ [relation.name]: {
259
+ some: { [relation.field]: { in: rightOperand.value } },
260
+ },
261
+ };
262
+ }
263
+ return path.reduceRight((acc, key, index) => index === path.length - 1
264
+ ? { [key]: { hasSome: rightOperand.value } }
265
+ : { [key]: acc }, {});
266
+ }
267
+ case "lambda": {
268
+ // Handle lambda expressions by replacing variable prefix on keys
269
+ const [condition, variable] = operands;
270
+ if (!("name" in variable))
271
+ throw new Error("Lambda variable must have a name");
272
+ return buildPrismaFilterFromCerbosExpression(condition, (key) => ({ field: key.replace(`${variable.name}.`, "") }));
273
+ }
274
+ case "exists":
275
+ case "exists_one":
276
+ case "all":
277
+ case "except":
278
+ case "filter": {
279
+ // Operators applying conditions on collections via lambda expressions
280
+ if (operands.length !== 2)
281
+ throw new Error(`${operator} requires exactly two operands`);
282
+ const [collection, lambda] = operands;
283
+ if (!("name" in collection))
284
+ throw new Error("First operand of exists/all/except must be a collection reference");
285
+ if (!("operator" in lambda))
286
+ throw new Error("Second operand of exists/all/except must be a lambda expression");
287
+ const { relation } = resolveFieldReference(collection.name, mapper);
288
+ if (!relation)
289
+ throw new Error(`${operator} operator requires a relation mapping`);
290
+ const lambdaCondition = buildPrismaFilterFromCerbosExpression(lambda, mapper);
291
+ switch (operator) {
292
+ case "exists":
293
+ return { [relation.name]: { some: lambdaCondition } };
294
+ case "exists_one":
295
+ return {
296
+ [relation.name]: { some: lambdaCondition },
297
+ AND: [
298
+ {
299
+ [relation.name]: {
300
+ every: { OR: [lambdaCondition, { NOT: lambdaCondition }] },
301
+ },
302
+ },
303
+ ],
304
+ };
305
+ case "all":
306
+ return { [relation.name]: { every: lambdaCondition } };
307
+ case "filter":
308
+ return { [relation.name]: { some: lambdaCondition } };
309
+ default:
310
+ throw new Error(`Unexpected operator: ${operator}`);
311
+ }
312
+ }
313
+ case "map": {
314
+ // Process map operator to project field values
315
+ if (operands.length !== 2)
316
+ throw new Error("map requires exactly two operands");
317
+ const [collection, lambda] = operands;
318
+ if (!("name" in collection))
319
+ throw new Error("First operand of map must be a collection reference");
320
+ if (!("operator" in lambda) || lambda.operator !== "lambda")
321
+ throw new Error("Second operand of map must be a lambda expression");
322
+ const { relation } = resolveFieldReference(collection.name, mapper);
323
+ if (!relation)
324
+ throw new Error("map operator requires a relation mapping");
325
+ const [projection, variable] = lambda.operands;
326
+ if (!("name" in projection) || !("name" in variable))
327
+ throw new Error("Invalid map lambda expression structure");
328
+ return {
329
+ [relation.name]: {
330
+ [getPrismaRelationOperator(relation)]: {
331
+ select: {
332
+ [projection.name.replace(`${variable.name}.`, "")]: true,
333
+ },
334
+ },
335
+ },
336
+ };
337
+ }
338
+ default:
339
+ throw new Error(`Unsupported operator: ${operator}`);
163
340
  }
164
- return result;
165
- }
341
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerbos/orm-prisma",
3
- "version": "1.1.1",
3
+ "version": "2.0.0-alpha.1",
4
4
  "homepage": "https://cerbos.dev",
5
5
  "description": "",
6
6
  "private": false,
@@ -21,16 +21,16 @@
21
21
  "author": "Cerbos",
22
22
  "license": "Apache-2.0",
23
23
  "devDependencies": {
24
- "@prisma/client": "^4.3.1",
24
+ "@prisma/client": "^6.0.0",
25
25
  "@types/jest": "^29.0.3",
26
- "@types/node": "^18.7.21",
27
- "prisma": "^4.3.1",
26
+ "@types/node": "^22.0.0",
27
+ "prisma": "^6.0.0",
28
28
  "ts-jest": "^29.0.0",
29
29
  "ts-node": "^10.4.0",
30
30
  "typescript": "^5.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@cerbos/core": "^0.12.0",
34
- "@cerbos/grpc": "^0.12.0"
33
+ "@cerbos/core": "^0.21.0",
34
+ "@cerbos/grpc": "^0.20.0"
35
35
  }
36
36
  }