UNPKG

nosql-constraints

Version:

Helpers to manage constrants (i.e. cascade delete) in a NoSQL database

261 lines (260 loc) 12.5 kB
import stringify from 'safe-stable-stringify'; import { CyclesDFS, DiGraph, GraphPaths } from 'ya-digraph-js'; import { Constraints } from './constraints.js'; export class ConstraintsFactory { #containerSchemaAdapters = new Map(); #containerSchemaChunks = new Map(); #constraintsGraph = new DiGraph(); get constraintsGraph() { return this.#constraintsGraph; } addDocumentSchema(containerId, schema) { let adapters = this.#containerSchemaAdapters.get(containerId); if (!adapters) { adapters = []; this.#containerSchemaAdapters.set(containerId, adapters); } adapters.push(schema); let chunks = this.#containerSchemaChunks.get(containerId); if (!chunks) { chunks = []; this.#containerSchemaChunks.set(containerId, chunks); } const newChunks = schema.extractChunks(); chunks.push(...newChunks); } findDocumentSchemaChunks(docRef) { const { containerId, refDocType } = docRef; const chunks = this.#containerSchemaChunks.get(containerId); if (!chunks || chunks.length === 0) { throw new Error(`Missing schema for container ${containerId}`); } if (!refDocType || Object.keys(refDocType).length === 0) { return chunks; } const result = []; // Helper function to recursively match properties const matchProperties = (currentChunks, currentRefDocType) => { const isScalarMatch = (propertyChunks, refValue) => propertyChunks.some((propertyChunk) => { if (propertyChunk.type === 'literal') { return propertyChunk.value === refValue; } else if (propertyChunk.type === typeof refValue) { return true; } return false; }); const isPropertyMatch = (chunk, refProperty, refValue) => { const propertyChunks = chunk.properties?.[refProperty]; if (!propertyChunks) { return false; } if (typeof refValue === 'object' && refValue !== null) { // Drill down recursively for nested objects const nestedMatchedChunks = matchProperties(propertyChunks, refValue); return nestedMatchedChunks.length > 0; } // Check scalar values return isScalarMatch(propertyChunks, refValue); }; return currentChunks.filter((chunk) => Object.entries(currentRefDocType).every(([refProperty, refValue]) => isPropertyMatch(chunk, refProperty, refValue))); }; // Start matching from the top-level chunks result.push(...matchProperties(chunks, refDocType)); return result; } validateDocumentReference(docRef) { // Check that vertex has schema const chunks = this.findDocumentSchemaChunks(docRef); if (!chunks || chunks.length === 0) { throw new Error(`Missing schema for container ${docRef.containerId} and refDocType ${stringify(docRef.refDocType)}`); } } findPropertySchemaChunksForProperty(chunk, propertyPath) { // We need to drill down the property path // and find the chunks that match the whole path let currentChunks = [chunk]; const path = propertyPath.split('.'); for (const property of path) { const foundChunks = []; for (const currentChunk of currentChunks) { if (!currentChunk.properties) { continue; } const propertyChunks = currentChunk.properties[property]; if (!propertyChunks) { continue; } foundChunks.push(...propertyChunks); } if (foundChunks.length === 0) { return []; } currentChunks = foundChunks; } // If we reach here, we have found the property return currentChunks; } findDocumentSchemaChunksForProperty(chunks, propertyPath) { // The return value is are the chunks from the provided chunks // that have propertyPath as a property const foundChunks = chunks.filter((chunk) => this.findPropertySchemaChunksForProperty(chunk, propertyPath).length > 0); return foundChunks; } validateConstraint(referencing, constraint, referenced) { // Check that all refProperties are present in the referencing schema // At least one chunk should have all refProperties const referencingChunks = this.findDocumentSchemaChunks(referencing); const referencedChunks = this.findDocumentSchemaChunks(referenced); // Ref properties can be . separated paths for (const refProperty of Object.entries(constraint.refProperties)) { const [referencingProperty, referencedProperty] = refProperty; const foundReferencingChunks = this.findDocumentSchemaChunksForProperty(referencingChunks, referencingProperty); if (!foundReferencingChunks || foundReferencingChunks.length === 0) { throw new Error(`Failed to validate referencing constraint ${referencing.containerId}/${stringify(referencing.refDocType)}/${referencingProperty}: property not found`); } const foundReferencedChunks = this.findDocumentSchemaChunksForProperty(referencedChunks, referencedProperty); if (!foundReferencedChunks || foundReferencedChunks.length === 0) { throw new Error(`Failed to validate referenced constraint ${referenced.containerId}/${stringify(referenced.refDocType)}/${referencedProperty}: property not found`); } } } constructDocRefVertex(reference) { const id = `${reference.containerId}/${stringify(reference.refDocType)}`; return { id, vertex: reference }; } constructAndFilterVertices(...vertices) { return vertices.filter((v) => !this.#constraintsGraph.hasVertex(v.id)); } addConstraint(referencing, constraint, referenced) { // Validate first this.validateDocumentReference(referencing); this.validateDocumentReference(referenced); this.validateConstraint(referencing, constraint, referenced); // referenced = from, referencing = to const vfrom = this.constructDocRefVertex(referenced); const vto = this.constructDocRefVertex(referencing); const vertices = this.constructAndFilterVertices(vfrom, vto); this.#constraintsGraph.addVertices(...vertices); this.#constraintsGraph.addEdges({ from: vfrom.id, to: vto.id, edge: constraint }); } addCompoundConstraint(compound, constraint) { // Validate first this.validateDocumentReference(compound); this.validateConstraint(compound, constraint, compound); // referenced = from, referencing = to const vfrom = this.constructDocRefVertex(compound); const vto = { ...vfrom, id: `${vfrom.id}/compound` }; const vertices = this.constructAndFilterVertices(vfrom, vto); this.#constraintsGraph.addVertices(...vertices); this.#constraintsGraph.addEdges({ from: vfrom.id, to: vto.id, edge: constraint }); } validate() { // Check that there are no cycles in the graph const cycles = new CyclesDFS(this.#constraintsGraph); if (cycles.hasCycles()) { throw new Error('Validation failed: cycles detected in the constraints graph, only acyclic graph is supported at the moment'); } // Now we need to validate cascade delete // We need to check that for each edge, that has cascadeDelete = true, all further edges are also cascadeDelete = true // Otherwise its an error const edgeIds = this.#constraintsGraph.getEdgeIds(); const paths = new GraphPaths(this.#constraintsGraph); for (const edgeId of edgeIds) { const edge = this.#constraintsGraph.getEdge(edgeId); if (edge?.cascadeDelete !== true) { // Nothing to check continue; } // Check that all edges from this edge are also cascadeDelete = true const to = edgeId.to; const allPaths = [...paths.getPathsFrom(to)].filter((path) => path.length > 1); if (allPaths.length === 0) { // No paths from edge.to, nothing to check continue; } // Check that all paths have cascadeDelete = true down to the leaves const cascadeDeletePathsEdges = allPaths.map((path) => { const pathEdgeIds = path .map((vertexId, index) => { if (index === 0) { return undefined; } return { from: path[index - 1], to: vertexId }; }) .filter((e) => e !== undefined); const cascadeDeleteEdges = pathEdgeIds.map((edgeId) => this.#constraintsGraph.getEdge(edgeId)?.cascadeDelete ?? false); return cascadeDeleteEdges; }); const cascadeDeletePaths = cascadeDeletePathsEdges.map((edges) => edges.every((e) => e === true)); // Check that all edges in the path have cascadeDelete = true const allCascadeDelete = cascadeDeletePaths.every((e) => e === true); if (!allCascadeDelete) { // Find paths that are not cascadeDelete const invalidPaths = allPaths .map((path, index) => [path, index]) .filter((_, index) => cascadeDeletePaths[index] !== true) .map(([path, pathIndex]) => [ [edgeId.from, edge.cascadeDelete ?? false], ...path.map((v, vertexIndex) => [ v, cascadeDeletePathsEdges[pathIndex].length > vertexIndex ? cascadeDeletePathsEdges[pathIndex][vertexIndex] : undefined ]) ]); const pathStrings = invalidPaths.map((path) => path .map((e) => { if (e[1] === undefined) { return `${e[0]}`; } return `${e[0]}: delete=${e[1]}`; }) .join(' -> ')); throw new Error(`Validation failed: cascadeDelete = true is not set for all edges in the path(s): ${pathStrings.join(', ')}. All edges in the path(s) must have cascadeDelete = true.`); } } } build() { this.validate(); // Prepare data for fast access to the constraints // Map<ConstraintVertex, ConstraintEdge[][]>: Map all vertices to all paths from this vertex const constraintsMap = new Map(); const paths = new GraphPaths(this.#constraintsGraph); for (const vertexId of this.#constraintsGraph.getVertexIds()) { const vertex = this.#constraintsGraph.getVertex(vertexId); const allPaths = [...paths.getPathsFrom(vertexId)].filter((path) => path.length > 1); const allConstraints = allPaths.map((path) => { // Get all the edges in the path const pathEdgeIds = path .map((vertexId, index) => { if (index === 0) { return undefined; } return { from: path[index - 1], to: vertexId }; }) .filter((e) => e !== undefined); return pathEdgeIds.map((edgeId) => ({ fromId: edgeId.from, toId: edgeId.to, from: this.#constraintsGraph.getVertex(edgeId.from), to: this.#constraintsGraph.getVertex(edgeId.to), constraint: this.#constraintsGraph.getEdge(edgeId) })); }); constraintsMap.set({ id: vertexId, vertex }, allConstraints); } return new Constraints(this.#constraintsGraph, constraintsMap); } }