nosql-constraints
Version:
Helpers to manage constrants (i.e. cascade delete) in a NoSQL database
261 lines (260 loc) • 12.5 kB
JavaScript
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);
}
}