UNPKG

@cerbos/orm-prisma

Version:

Prisma adapter for Cerbos query plans

629 lines 24.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PlanKind = void 0; exports.queryPlanToPrisma = queryPlanToPrisma; const core_1 = require("@cerbos/core"); Object.defineProperty(exports, "PlanKind", { enumerable: true, get: function () { return core_1.PlanKind; } }); function isNamedOperand(operand) { return "name" in operand && typeof operand.name === "string"; } function isValueOperand(operand) { return "value" in operand && operand.value !== undefined; } function isOperatorOperand(operand) { return ("operator" in operand && typeof operand.operator === "string" && "operands" in operand && Array.isArray(operand.operands)); } function assertDefined(value, message) { if (value === undefined) { throw new Error(message); } return value; } function getLeafField(path) { const fieldName = path[path.length - 1]; if (!fieldName) { throw new Error("Field path cannot be empty"); } return fieldName; } function getFilterEntry(filter) { const entry = Object.entries(filter)[0]; if (!entry) { throw new Error("Filter must contain at least one entry"); } return entry; } function isResolvedFieldReference(operand) { return "path" in operand; } function isResolvedValue(operand) { return "value" in operand; } /** * Converts a Cerbos query plan to a Prisma filter. */ function queryPlanToPrisma({ queryPlan, mapper = {}, }) { switch (queryPlan.kind) { case core_1.PlanKind.ALWAYS_ALLOWED: return { kind: core_1.PlanKind.ALWAYS_ALLOWED }; case core_1.PlanKind.ALWAYS_DENIED: return { kind: core_1.PlanKind.ALWAYS_DENIED }; case core_1.PlanKind.CONDITIONAL: return { kind: core_1.PlanKind.CONDITIONAL, filters: buildPrismaFilterFromCerbosExpression(queryPlan.condition, mapper), }; default: throw Error(`Invalid query plan.`); } } /** * Resolves a field reference considering relations and nested fields. */ function resolveFieldReference(reference, mapper) { const parts = reference.split("."); const config = typeof mapper === "function" ? mapper(reference) : mapper[reference]; let matchedPrefix = ""; let matchedConfig; // If no direct match, look for partial matches if (!config) { for (let i = parts.length - 1; i >= 0; i--) { const prefix = parts.slice(0, i + 1).join("."); const prefixConfig = typeof mapper === "function" ? mapper(prefix) : mapper[prefix]; if (prefixConfig) { matchedPrefix = prefix; matchedConfig = prefixConfig; break; } } } const activeConfig = config ?? matchedConfig; // Handle relation mapping if (activeConfig?.relation) { const { name, type, fields } = activeConfig.relation; const matchedParts = matchedPrefix ? matchedPrefix.split(".") : []; const remainingParts = matchedPrefix ? parts.slice(matchedParts.length) : parts.slice(1); let field; const relations = [ { name, type, field: activeConfig.relation.field, nestedMapper: fields, }, ]; // Process nested relations if (fields && remainingParts.length > 0) { let currentMapper = fields; let currentParts = remainingParts; while (currentParts.length > 0) { if (!currentMapper) { break; } const currentPart = currentParts[0]; if (!currentPart) { break; } const nextConfig = currentMapper[currentPart]; if (nextConfig?.relation) { relations.push({ name: nextConfig.relation.name, type: nextConfig.relation.type, field: nextConfig.relation.field, nestedMapper: nextConfig.relation.fields, }); currentMapper = nextConfig.relation.fields || {}; currentParts = currentParts.slice(1); } else { const lastPart = currentParts[currentParts.length - 1]; if (!lastPart) { break; } field = nextConfig?.field || lastPart; break; } } } return { path: field ? [field] : remainingParts, relations }; } // Simple field mapping return { path: [activeConfig?.field || reference] }; } /** * Determines the appropriate Prisma operator based on relation type. */ function getPrismaRelationOperator(relation) { return relation.type === "one" ? "is" : "some"; } /** * Builds a nested relation filter for Prisma queries. */ function buildNestedRelationFilter(relations, fieldFilter) { if (relations.length === 0) return fieldFilter; let currentFilter = fieldFilter; // Build nested structure from inside out for (let i = relations.length - 1; i >= 0; i--) { const relation = relations[i]; if (!relation) { throw new Error("Relation mapping is missing"); } const relationOperator = getPrismaRelationOperator(relation); // Handle special case for the deepest relation if (relation.field && i === relations.length - 1) { const [key, filterValue] = getFilterEntry(currentFilter); if (key === "NOT") { currentFilter = { NOT: { [relation.field]: filterValue } }; } else { currentFilter = { [relation.field]: filterValue }; } } currentFilter = { [relation.name]: { [relationOperator]: currentFilter } }; } return currentFilter; } /** * Resolves a PlanExpressionOperand into a ResolvedOperand. */ function resolveOperand(operand, mapper) { if (isNamedOperand(operand)) { return resolveFieldReference(operand.name, mapper); } else if (isValueOperand(operand)) { return { value: operand.value }; } else if (isOperatorOperand(operand)) { const nestedResult = buildPrismaFilterFromCerbosExpression(operand, mapper); return { value: nestedResult }; } throw new Error("Operand must have name, value, or be an expression"); } /** * Creates a scoped mapper for collection operations */ function createScopedMapper(collectionPath, variableName, fullMapper) { return (key) => { // If the key starts with the variable name, it's accessing the collection item if (key.startsWith(variableName + ".")) { const strippedKey = key.replace(variableName + ".", ""); const parts = strippedKey.split("."); // Get the collection's relation config const collectionConfig = typeof fullMapper === "function" ? fullMapper(collectionPath) : fullMapper[collectionPath]; if (collectionConfig?.relation?.fields) { // For nested paths, traverse the fields configuration const baseConfig = collectionConfig.relation.fields; if (!baseConfig) { return { field: strippedKey }; } let currentConfig = baseConfig; let field = parts[0] || strippedKey; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const nextPart = parts[i + 1]; if (!part || !nextPart) { break; } const nextConfig = currentConfig[part]; if (nextConfig?.relation?.fields) { currentConfig = nextConfig.relation.fields; field = nextPart; } else { break; } } if (!field) { field = strippedKey; } // Return the field config if it exists, otherwise create a default one return currentConfig[field] || { field }; } return { field: strippedKey }; } // For keys not referencing the collection item, use the full mapper if (typeof fullMapper === "function") { return fullMapper(key); } return fullMapper[key] || { field: key }; }; } /** * Builds a Prisma filter from a Cerbos expression. */ function buildPrismaFilterFromCerbosExpression(expression, mapper) { // Validate expression structure if (!isOperatorOperand(expression)) { throw new Error("Invalid Cerbos expression structure"); } const { operator, operands } = expression; // Process different operator types switch (operator) { case "and": return { AND: operands.map((operand) => buildPrismaFilterFromCerbosExpression(operand, mapper)), }; case "or": return { OR: operands.map((operand) => buildPrismaFilterFromCerbosExpression(operand, mapper)), }; case "not": { const operand = operands[0]; if (!operand) { throw new Error("not operator requires an operand"); } return { NOT: buildPrismaFilterFromCerbosExpression(operand, mapper), }; } case "eq": case "ne": case "lt": case "le": case "gt": case "ge": { return handleRelationalOperator(operator, operands, mapper); } case "in": { return handleInOperator(operands, mapper); } case "contains": case "startsWith": case "endsWith": { return handleStringOperator(operator, operands, mapper); } case "isSet": { return handleIsSetOperator(operands, mapper); } case "hasIntersection": { return handleHasIntersectionOperator(operands, mapper); } case "lambda": { return handleLambdaOperator(operands); } case "exists": case "exists_one": case "all": case "except": case "filter": { return handleCollectionOperator(operator, operands, mapper); } case "map": { return handleMapOperator(operands, mapper); } default: throw new Error(`Unsupported operator: ${operator}`); } } /** * Helper function to process relational operators (eq, ne, lt, etc.) */ function handleRelationalOperator(operator, operands, mapper) { const prismaOperator = { eq: "equals", ne: "not", lt: "lt", le: "lte", gt: "gt", ge: "gte", }[operator]; if (!prismaOperator) { throw new Error(`Unsupported operator: ${operator}`); } const leftOperand = operands.find((o) => isNamedOperand(o) || isOperatorOperand(o)); if (!leftOperand) throw new Error("No valid left operand found"); const rightOperand = operands.find((o) => o !== leftOperand); if (!rightOperand) throw new Error("No valid right operand found"); const left = resolveOperand(leftOperand, mapper); const right = resolveOperand(rightOperand, mapper); if (isResolvedFieldReference(left)) { const { path, relations } = left; if (!isResolvedValue(right)) { throw new Error("Right operand must be a value"); } const filterValue = { [prismaOperator]: right.value }; const fieldName = getLeafField(path); const fieldFilter = { [fieldName]: filterValue }; if (relations && relations.length > 0) { return buildNestedRelationFilter(relations, fieldFilter); } return fieldFilter; } if (!isResolvedValue(right)) { throw new Error("Right operand must be a value"); } return { [prismaOperator]: right.value }; } /** * Helper function to handle "in" operator */ function handleInOperator(operands, mapper) { const nameOperand = operands.find(isNamedOperand); if (!nameOperand) throw new Error("Name operand is undefined"); const valueOperand = operands.find(isValueOperand); if (!valueOperand) throw new Error("Value operand is undefined"); const resolved = resolveOperand(nameOperand, mapper); if (!isResolvedFieldReference(resolved)) { throw new Error("Name operand must resolve to a field reference"); } const { path, relations } = resolved; const resolvedValue = resolveOperand(valueOperand, mapper); if (!isResolvedValue(resolvedValue)) { throw new Error("Value operand must resolve to a value"); } const { value } = resolvedValue; const values = Array.isArray(value) ? value : [value]; const fieldName = getLeafField(path); if (relations && relations.length > 0) { const fieldFilter = values.length === 1 ? { [fieldName]: values[0] } : { [fieldName]: { in: values } }; return buildNestedRelationFilter(relations, fieldFilter); } return values.length === 1 ? { [fieldName]: values[0] } : { [fieldName]: { in: values } }; } /** * Helper function to handle string operators (contains, startsWith, endsWith) */ function handleStringOperator(operator, operands, mapper) { const nameOperand = operands.find(isNamedOperand); if (!nameOperand) throw new Error("Name operand is undefined"); const resolved = resolveOperand(nameOperand, mapper); if (!isResolvedFieldReference(resolved)) { throw new Error("Name operand must resolve to a field reference"); } const { path, relations } = resolved; const valueOperand = operands.find(isValueOperand); if (!valueOperand) throw new Error("Value operand is undefined"); const resolvedValue = resolveOperand(valueOperand, mapper); if (!isResolvedValue(resolvedValue)) { throw new Error("Value operand must resolve to a value"); } const { value } = resolvedValue; if (typeof value !== "string") { throw new Error(`${operator} operator requires string value`); } const fieldName = getLeafField(path); const fieldFilter = { [fieldName]: { [operator]: value } }; if (relations && relations.length > 0) { return buildNestedRelationFilter(relations, fieldFilter); } return fieldFilter; } /** * Helper function to handle "isSet" operator */ function handleIsSetOperator(operands, mapper) { const nameOperand = operands.find(isNamedOperand); if (!nameOperand) throw new Error("Name operand is undefined"); const resolved = resolveOperand(nameOperand, mapper); if (!isResolvedFieldReference(resolved)) { throw new Error("Name operand must resolve to a field reference"); } const { path, relations } = resolved; const valueOperand = operands.find(isValueOperand); if (!valueOperand) throw new Error("Value operand is undefined"); const resolvedValue = resolveOperand(valueOperand, mapper); if (!isResolvedValue(resolvedValue)) { throw new Error("Value operand must resolve to a value"); } const fieldName = getLeafField(path); const fieldFilter = { [fieldName]: resolvedValue.value ? { not: null } : { equals: null }, }; if (relations && relations.length > 0) { return buildNestedRelationFilter(relations, fieldFilter); } return fieldFilter; } /** * Helper function to handle "hasIntersection" operator */ function handleHasIntersectionOperator(operands, mapper) { if (operands.length !== 2) { throw new Error("hasIntersection requires exactly two operands"); } const leftOperand = assertDefined(operands[0], "hasIntersection requires a left operand"); const rightOperand = assertDefined(operands[1], "hasIntersection requires a right operand"); // Check if left operand is a map operation if (isOperatorOperand(leftOperand) && leftOperand.operator === "map") { if (!isValueOperand(rightOperand)) { throw new Error("Second operand of hasIntersection must be a value"); } const collection = assertDefined(leftOperand.operands[0], "Map expression must include a collection reference"); const lambda = assertDefined(leftOperand.operands[1], "Map expression must include a lambda expression"); if (!isNamedOperand(collection)) { throw new Error("First operand of map must be a collection reference"); } // Get variable name from lambda if (!isOperatorOperand(lambda)) { throw new Error("Lambda expression must have operands"); } const variable = assertDefined(lambda.operands[1], "Lambda variable must have a name"); if (!isNamedOperand(variable)) { throw new Error("Lambda variable must have a name"); } // Create scoped mapper for the collection const scopedMapper = createScopedMapper(collection.name, variable.name, mapper); const { relations } = resolveFieldReference(collection.name, mapper); if (!relations || relations.length === 0) { throw new Error("Map operation requires relations"); } const projection = assertDefined(lambda.operands[0], "Invalid map lambda expression structure"); if (!isNamedOperand(projection)) { throw new Error("Invalid map lambda expression structure"); } // Use scoped mapper for resolving the projection const resolved = resolveFieldReference(projection.name, scopedMapper); const fieldName = getLeafField(resolved.path); return buildNestedRelationFilter(relations, { [fieldName]: { in: rightOperand.value }, }); } // Handle regular field reference if (!isNamedOperand(leftOperand)) { throw new Error("First operand of hasIntersection must be a field reference or map expression"); } if (!isValueOperand(rightOperand)) { throw new Error("Second operand of hasIntersection must be a value"); } const { path, relations } = resolveFieldReference(leftOperand.name, mapper); if (!Array.isArray(rightOperand.value)) { throw new Error("hasIntersection requires an array value"); } if (relations && relations.length > 0) { const fieldName = getLeafField(path); const fieldFilter = { [fieldName]: { in: rightOperand.value }, }; return buildNestedRelationFilter(relations, fieldFilter); } const fieldName = getLeafField(path); return { [fieldName]: { some: rightOperand.value } }; } /** * Helper function to handle "lambda" operator */ function handleLambdaOperator(operands) { const condition = assertDefined(operands[0], "Lambda requires a condition operand"); const variable = assertDefined(operands[1], "Lambda requires a variable operand"); if (!isNamedOperand(variable)) { throw new Error("Lambda variable must have a name"); } return buildPrismaFilterFromCerbosExpression(condition, (key) => ({ field: key.replace(`${variable.name}.`, ""), })); } /** * Helper function to handle collection operators (exists, all, except, filter) */ function handleCollectionOperator(operator, operands, mapper) { if (operands.length !== 2) { throw new Error(`${operator} requires exactly two operands`); } const collection = assertDefined(operands[0], `${operator} requires a collection operand`); const lambda = assertDefined(operands[1], `${operator} requires a lambda operand`); if (!isNamedOperand(collection)) { throw new Error(`First operand of ${operator} must be a collection reference`); } if (!isOperatorOperand(lambda)) { throw new Error(`Second operand of ${operator} must be a lambda expression`); } // Get variable name from lambda const variable = assertDefined(lambda.operands[1], "Lambda variable must have a name"); if (!isNamedOperand(variable)) { throw new Error("Lambda variable must have a name"); } // Create scoped mapper for the collection const scopedMapper = createScopedMapper(collection.name, variable.name, mapper); const { relations } = resolveFieldReference(collection.name, mapper); if (!relations || relations.length === 0) { throw new Error(`${operator} operator requires a relation mapping`); } const lambdaConditionOperand = assertDefined(lambda.operands[0], "Lambda expression must provide a condition"); const lambdaCondition = buildPrismaFilterFromCerbosExpression(lambdaConditionOperand, // Use the condition part of the lambda scopedMapper); const relation = assertDefined(relations[0], `${operator} operator requires a relation mapping`); let filterValue = lambdaCondition; // If the lambda condition already has a relation structure, merge it if (lambdaCondition["AND"] || lambdaCondition["OR"]) { filterValue = lambdaCondition; } else { const lambdaKeys = Object.keys(lambdaCondition); const defaultKey = lambdaKeys[0]; if (!defaultKey) { throw new Error("Lambda condition must have at least one field"); } const lambdaFieldValue = lambdaCondition[defaultKey]; if (lambdaFieldValue === undefined) { throw new Error("Lambda condition field value cannot be undefined"); } const filterField = relation.field || defaultKey; filterValue = { [filterField]: lambdaFieldValue, }; } switch (operator) { case "exists": case "filter": return { [relation.name]: { some: filterValue } }; case "except": return { [relation.name]: { some: { NOT: filterValue } } }; case "exists_one": return { [relation.name]: { some: filterValue, }, AND: [ { [relation.name]: { every: { OR: [filterValue, { NOT: filterValue }], }, }, }, ], }; case "all": return { [relation.name]: { every: filterValue } }; default: throw new Error(`Unexpected operator: ${operator}`); } } /** * Helper function to handle "map" operator */ function handleMapOperator(operands, mapper) { if (operands.length !== 2) { throw new Error("map requires exactly two operands"); } const collection = assertDefined(operands[0], "map requires a collection operand"); const lambda = assertDefined(operands[1], "map requires a lambda operand"); if (!isNamedOperand(collection)) { throw new Error("First operand of map must be a collection reference"); } if (!isOperatorOperand(lambda) || lambda.operator !== "lambda") { throw new Error("Second operand of map must be a lambda expression"); } // Get variable name from lambda const projection = assertDefined(lambda.operands[0], "Map lambda expression must provide a projection"); const variable = assertDefined(lambda.operands[1], "Map lambda expression must provide a variable"); if (!isNamedOperand(projection) || !isNamedOperand(variable)) { throw new Error("Invalid map lambda expression structure"); } // Create scoped mapper for the collection const scopedMapper = createScopedMapper(collection.name, variable.name, mapper); const { relations } = resolveFieldReference(collection.name, mapper); if (!relations || relations.length === 0) { throw new Error("map operator requires a relation mapping"); } // Use scoped mapper for resolving the projection const resolved = resolveFieldReference(projection.name, scopedMapper); const fieldName = getLeafField(resolved.path); const lastRelation = assertDefined(relations[relations.length - 1], "Relation mapping must contain at least one relation"); return buildNestedRelationFilter(relations, { [getPrismaRelationOperator(lastRelation)]: { select: { [fieldName]: true }, }, }); } //# sourceMappingURL=index.js.map