UNPKG

@cerbos/orm-prisma

Version:

Prisma adapter for Cerbos query plans

955 lines (810 loc) 25.3 kB
import { PlanResourcesResponse, PlanExpressionOperand, PlanKind, Value, } from "@cerbos/core"; export { PlanKind }; // Type Definitions export type PrismaFilter = Record<string, any>; export type MapperConfig = { field?: string; relation?: { name: string; type: "one" | "many"; field?: string; fields?: Record<string, MapperConfig>; }; }; export type Mapper = | Record<string, MapperConfig> | ((key: string) => MapperConfig); export interface QueryPlanToPrismaArgs { queryPlan: PlanResourcesResponse; mapper?: Mapper; } export type QueryPlanToPrismaResult = | { kind: PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED; } | { kind: PlanKind.CONDITIONAL; filters: PrismaFilter; }; // Type guards for operands interface NamedOperand { name: string; } interface ValueOperand { value: Value; } interface OperatorOperand { operator: string; operands: PlanExpressionOperand[]; } function isNamedOperand( operand: PlanExpressionOperand ): operand is NamedOperand { return "name" in operand && typeof operand.name === "string"; } function isValueOperand( operand: PlanExpressionOperand ): operand is ValueOperand { return "value" in operand && operand.value !== undefined; } function isOperatorOperand( operand: PlanExpressionOperand ): operand is OperatorOperand { return ( "operator" in operand && typeof operand.operator === "string" && "operands" in operand && Array.isArray(operand.operands) ); } function assertDefined<T>(value: T | undefined, message: string): T { if (value === undefined) { throw new Error(message); } return value; } function getLeafField(path: string[]): string { const fieldName = path[path.length - 1]; if (!fieldName) { throw new Error("Field path cannot be empty"); } return fieldName; } function getFilterEntry(filter: Record<string, unknown>): [string, unknown] { const entry = Object.entries(filter)[0]; if (!entry) { throw new Error("Filter must contain at least one entry"); } return entry; } // Field reference resolution types type RelationConfig = { name: string; type: "one" | "many"; field?: string; nestedMapper?: Record<string, MapperConfig>; }; type ResolvedFieldReference = { path: string[]; relations?: RelationConfig[]; }; type ResolvedValue = { value: any; }; type ResolvedOperand = ResolvedFieldReference | ResolvedValue; function isResolvedFieldReference( operand: ResolvedOperand ): operand is ResolvedFieldReference { return "path" in operand; } function isResolvedValue(operand: ResolvedOperand): operand is ResolvedValue { return "value" in operand; } /** * Converts a Cerbos query plan to a Prisma filter. */ export function queryPlanToPrisma({ queryPlan, mapper = {}, }: QueryPlanToPrismaArgs): QueryPlanToPrismaResult { switch (queryPlan.kind) { case PlanKind.ALWAYS_ALLOWED: return { kind: PlanKind.ALWAYS_ALLOWED }; case PlanKind.ALWAYS_DENIED: return { kind: PlanKind.ALWAYS_DENIED }; case PlanKind.CONDITIONAL: return { kind: 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: string, mapper: Mapper ): ResolvedFieldReference { const parts = reference.split("."); const config = typeof mapper === "function" ? mapper(reference) : mapper[reference]; let matchedPrefix = ""; let matchedConfig: MapperConfig | undefined; // 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: string | undefined; const relations: RelationConfig[] = [ { name, type, field: activeConfig.relation.field, nestedMapper: fields, }, ]; // Process nested relations if (fields && remainingParts.length > 0) { let currentMapper: Record<string, MapperConfig> | undefined = fields; let currentParts = remainingParts; while (currentParts.length > 0) { if (!currentMapper) { break; } const currentPart = currentParts[0]; if (!currentPart) { break; } const nextConfig: MapperConfig | undefined = 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: { name: string; type: "one" | "many"; field?: string; }): string { return relation.type === "one" ? "is" : "some"; } /** * Builds a nested relation filter for Prisma queries. */ function buildNestedRelationFilter( relations: RelationConfig[], fieldFilter: any ): any { 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: PlanExpressionOperand, mapper: Mapper ): ResolvedOperand { 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: string, variableName: string, fullMapper: Mapper ): Mapper { return (key: string) => { // 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: PlanExpressionOperand, mapper: Mapper ): PrismaFilter { // 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: string, operands: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: string, operands: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: PlanExpressionOperand[]): PrismaFilter { 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: string) => ({ field: key.replace(`${variable.name}.`, ""), })); } /** * Helper function to handle collection operators (exists, all, except, filter) */ function handleCollectionOperator( operator: string, operands: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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: PlanExpressionOperand[], mapper: Mapper ): PrismaFilter { 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 }, }, }); }