@andrejs1979/document
Version:
MongoDB-compatible document database for NoSQL
527 lines • 22.1 kB
JavaScript
/**
* NoSQL - Relationship Manager
* Advanced relationship management for document collections
*/
import { RelationshipError } from '../types';
/**
* Manages relationships between documents across collections
*/
export class RelationshipManager {
storage;
config;
relationshipCache = new Map();
populateCache = new Map();
constructor(storage, config) {
this.storage = storage;
this.config = config;
}
/**
* Define a relationship between collections
*/
async defineRelationship(sourceCollection, targetCollection, relationship) {
if (!this.config.enableRelationships) {
throw new RelationshipError('Relationships are disabled in configuration');
}
try {
// Validate relationship configuration
this.validateRelationship(relationship);
// Store relationship metadata
const relationshipKey = this.getRelationshipKey(sourceCollection, targetCollection, relationship.localField);
this.relationshipCache.set(relationshipKey, relationship);
// Persist relationship metadata
await this.storeRelationshipMetadata(sourceCollection, targetCollection, relationship);
console.log(`Defined ${relationship.type} relationship: ${sourceCollection}.${relationship.localField} -> ${targetCollection}.${relationship.foreignField}`);
}
catch (error) {
throw new RelationshipError(`Failed to define relationship: ${error.message}`);
}
}
/**
* Remove a relationship definition
*/
async removeRelationship(sourceCollection, targetCollection, localField) {
try {
const relationshipKey = this.getRelationshipKey(sourceCollection, targetCollection, localField);
// Remove from cache
this.relationshipCache.delete(relationshipKey);
// Remove from persistent storage
await this.removeRelationshipMetadata(sourceCollection, targetCollection, localField);
console.log(`Removed relationship: ${sourceCollection}.${localField} -> ${targetCollection}`);
}
catch (error) {
throw new RelationshipError(`Failed to remove relationship: ${error.message}`);
}
}
/**
* Populate document with related data
*/
async populate(collection, document, populateOptions) {
if (!this.config.enableRelationships) {
return { document, populated: {} };
}
try {
const options = Array.isArray(populateOptions) ? populateOptions : [populateOptions];
const populated = {};
for (const option of options) {
const populatedData = await this.populateField(collection, document, option);
populated[option.path] = populatedData;
}
return { document, populated };
}
catch (error) {
throw new RelationshipError(`Population failed: ${error.message}`);
}
}
/**
* Populate multiple documents
*/
async populateMany(collection, documents, populateOptions) {
if (!this.config.enableRelationships || documents.length === 0) {
return documents.map(doc => ({ document: doc, populated: {} }));
}
try {
const options = Array.isArray(populateOptions) ? populateOptions : [populateOptions];
const results = [];
// Batch populate for efficiency
for (const document of documents) {
const populated = {};
for (const option of options) {
const populatedData = await this.populateField(collection, document, option);
populated[option.path] = populatedData;
}
results.push({ document, populated });
}
return results;
}
catch (error) {
throw new RelationshipError(`Bulk population failed: ${error.message}`);
}
}
/**
* Find documents with automatic population
*/
async findWithPopulate(collection, filter, populateOptions, findOptions = {}) {
try {
// First, find the documents
const documents = await this.storage.find(collection, filter, findOptions);
// Then populate them
return await this.populateMany(collection, documents, populateOptions);
}
catch (error) {
throw new RelationshipError(`Find with populate failed: ${error.message}`);
}
}
/**
* Create document with relationship validation
*/
async createWithRelationships(collection, document) {
try {
// Validate relationships before creating
await this.validateDocumentRelationships(collection, document);
// Create the document
const result = await this.storage.insertOne(collection, document);
// Update reverse relationships if needed
await this.updateReverseRelationships(collection, document, 'create');
return { ...document, _id: result.insertedId };
}
catch (error) {
throw new RelationshipError(`Create with relationships failed: ${error.message}`);
}
}
/**
* Update document with relationship validation
*/
async updateWithRelationships(collection, filter, update) {
try {
// Get the existing document
const existingDoc = await this.storage.findOne(collection, filter);
if (!existingDoc) {
throw new RelationshipError('Document not found for update');
}
// Apply the update to create the new document state
const updatedDoc = this.applyUpdateToDocument(existingDoc, update);
// Validate relationships
await this.validateDocumentRelationships(collection, updatedDoc);
// Perform the update
await this.storage.updateOne(collection, filter, update);
// Update reverse relationships
await this.updateReverseRelationships(collection, updatedDoc, 'update', existingDoc);
}
catch (error) {
throw new RelationshipError(`Update with relationships failed: ${error.message}`);
}
}
/**
* Delete document with relationship cleanup
*/
async deleteWithRelationships(collection, filter) {
try {
// Get the document to be deleted
const document = await this.storage.findOne(collection, filter);
if (!document) {
return; // Document doesn't exist
}
// Handle cascade delete and relationship cleanup
await this.handleCascadeDelete(collection, document);
// Delete the document
await this.storage.deleteOne(collection, filter);
// Update reverse relationships
await this.updateReverseRelationships(collection, document, 'delete');
}
catch (error) {
throw new RelationshipError(`Delete with relationships failed: ${error.message}`);
}
}
/**
* Get relationship statistics
*/
async getRelationshipStats(collection) {
try {
const outgoing = await this.getOutgoingRelationships(collection);
const incoming = await this.getIncomingRelationships(collection);
const relationshipTypes = {};
for (const rel of [...outgoing, ...incoming]) {
relationshipTypes[rel.type] = (relationshipTypes[rel.type] || 0) + 1;
}
return {
totalRelationships: outgoing.length + incoming.length,
outgoingRelationships: outgoing.length,
incomingRelationships: incoming.length,
relationshipTypes
};
}
catch (error) {
throw new RelationshipError(`Failed to get relationship stats: ${error.message}`);
}
}
// ===============================
// Private Methods
// ===============================
async populateField(collection, document, options) {
// Check cache first
const cacheKey = this.getPopulateCacheKey(collection, document._id, options);
if (this.populateCache.has(cacheKey)) {
return this.populateCache.get(cacheKey);
}
try {
// Get relationship definition
const relationship = await this.getRelationship(collection, options.path);
if (!relationship) {
throw new RelationshipError(`No relationship defined for ${collection}.${options.path}`);
}
// Get the foreign key value(s)
const localValue = this.getFieldValue(document, relationship.localField);
if (!localValue) {
return null;
}
// Build query for foreign collection
const foreignFilter = {};
if (Array.isArray(localValue)) {
// One-to-many or many-to-many
foreignFilter[relationship.foreignField] = { $in: localValue };
}
else {
// One-to-one or many-to-one
foreignFilter[relationship.foreignField] = localValue;
}
// Apply additional match criteria
if (options.match) {
Object.assign(foreignFilter, options.match);
}
// Build find options
const findOptions = {
...options.options,
projection: this.buildProjection(options.select)
};
// Query the foreign collection
const foreignCollection = relationship.foreignCollection;
const foreignDocuments = await this.storage.find(foreignCollection, foreignFilter, findOptions);
// Handle nested population
let result = null;
if (foreignDocuments.length === 0) {
result = relationship.type.includes('Many') ? [] : null;
}
else if (relationship.type === 'oneToOne' || relationship.type === 'manyToOne') {
result = foreignDocuments[0];
// Handle nested population
if (options.populate && result) {
const nestedPopulated = await this.populate(foreignCollection, result, options.populate);
result = nestedPopulated.document;
// Merge populated data
Object.assign(result, nestedPopulated.populated);
}
}
else {
result = foreignDocuments;
// Handle nested population for arrays
if (options.populate) {
const nestedResults = await this.populateMany(foreignCollection, foreignDocuments, options.populate);
result = nestedResults.map(r => {
const merged = { ...r.document };
Object.assign(merged, r.populated);
return merged;
});
}
}
// Cache the result
this.populateCache.set(cacheKey, result);
return result;
}
catch (error) {
throw new RelationshipError(`Field population failed for ${options.path}: ${error.message}`);
}
}
async getRelationship(collection, fieldPath) {
// Check cache first
for (const [key, relationship] of this.relationshipCache.entries()) {
if (key.includes(collection) && relationship.localField === fieldPath) {
return relationship;
}
}
// Load from database
return await this.loadRelationshipFromDatabase(collection, fieldPath);
}
async validateDocumentRelationships(collection, document) {
const relationships = await this.getOutgoingRelationships(collection);
for (const relationship of relationships) {
const localValue = this.getFieldValue(document, relationship.localField);
if (!localValue)
continue;
// Validate foreign key references exist
const foreignValues = Array.isArray(localValue) ? localValue : [localValue];
for (const value of foreignValues) {
const exists = await this.storage.findOne(relationship.foreignCollection, {
[relationship.foreignField]: value
});
if (!exists) {
throw new RelationshipError(`Foreign key constraint failed: ${relationship.foreignCollection}.${relationship.foreignField} = ${value} does not exist`);
}
}
}
}
async updateReverseRelationships(collection, document, operation, oldDocument) {
const incomingRelationships = await this.getIncomingRelationships(collection);
for (const relationship of incomingRelationships) {
if (relationship.type === 'oneToMany' || relationship.type === 'manyToMany') {
// Handle reverse updates for array relationships
await this.updateArrayRelationship(relationship, document, operation, oldDocument);
}
}
}
async updateArrayRelationship(relationship, document, operation, oldDocument) {
const foreignValue = this.getFieldValue(document, relationship.foreignField);
if (!foreignValue)
return;
const sourceCollection = relationship.foreignCollection; // Reverse relationship
const targetCollection = document._collection || '';
switch (operation) {
case 'create':
// Add document ID to the array in source documents
await this.storage.updateOne(sourceCollection, {
[relationship.localField]: foreignValue
}, {
$addToSet: { [relationship.foreignField]: document._id }
});
break;
case 'update':
// Handle changes in foreign key values
if (oldDocument) {
const oldForeignValue = this.getFieldValue(oldDocument, relationship.foreignField);
if (oldForeignValue !== foreignValue) {
// Remove from old parent
if (oldForeignValue) {
await this.storage.updateOne(sourceCollection, {
[relationship.localField]: oldForeignValue
}, {
$pull: { [relationship.foreignField]: document._id }
});
}
// Add to new parent
await this.storage.updateOne(sourceCollection, {
[relationship.localField]: foreignValue
}, {
$addToSet: { [relationship.foreignField]: document._id }
});
}
}
break;
case 'delete':
// Remove document ID from arrays in source documents
await this.storage.updateOne(sourceCollection, {
[relationship.localField]: foreignValue
}, {
$pull: { [relationship.foreignField]: document._id }
});
break;
}
}
async handleCascadeDelete(collection, document) {
const outgoingRelationships = await this.getOutgoingRelationships(collection);
for (const relationship of outgoingRelationships) {
// For now, we'll just handle reference cleanup
// In a full implementation, you might want configurable cascade behavior
if (relationship.type === 'oneToMany') {
// Set foreign keys to null or remove references
const localValue = this.getFieldValue(document, relationship.localField);
if (localValue) {
await this.storage.updateOne(relationship.foreignCollection, {
[relationship.foreignField]: localValue
}, {
$unset: { [relationship.foreignField]: true }
});
}
}
}
}
async getOutgoingRelationships(collection) {
// Get all relationships where this collection is the source
const relationships = [];
for (const [key, relationship] of this.relationshipCache.entries()) {
if (key.startsWith(collection)) {
relationships.push(relationship);
}
}
// Also load from database
const dbRelationships = await this.loadRelationshipsFromDatabase(collection, 'outgoing');
relationships.push(...dbRelationships);
return relationships;
}
async getIncomingRelationships(collection) {
// Get all relationships where this collection is the target
const relationships = [];
for (const [key, relationship] of this.relationshipCache.entries()) {
if (relationship.foreignCollection === collection) {
relationships.push(relationship);
}
}
// Also load from database
const dbRelationships = await this.loadRelationshipsFromDatabase(collection, 'incoming');
relationships.push(...dbRelationships);
return relationships;
}
validateRelationship(relationship) {
if (!relationship.localField || !relationship.foreignField || !relationship.foreignCollection) {
throw new RelationshipError('Relationship must specify localField, foreignField, and foreignCollection');
}
if (!['oneToOne', 'oneToMany', 'manyToOne', 'manyToMany'].includes(relationship.type)) {
throw new RelationshipError('Invalid relationship type');
}
}
async storeRelationshipMetadata(sourceCollection, targetCollection, relationship) {
try {
await this.storage.insertOne('_relationships', {
sourceCollection,
targetCollection,
relationship: relationship,
createdAt: new Date(),
active: true
});
}
catch (error) {
// If the _relationships collection doesn't exist, create it
console.warn('Could not store relationship metadata:', error.message);
}
}
async removeRelationshipMetadata(sourceCollection, targetCollection, localField) {
try {
await this.storage.deleteOne('_relationships', {
sourceCollection,
targetCollection,
'relationship.localField': localField
});
}
catch (error) {
console.warn('Could not remove relationship metadata:', error.message);
}
}
async loadRelationshipFromDatabase(collection, fieldPath) {
try {
const doc = await this.storage.findOne('_relationships', {
sourceCollection: collection,
'relationship.localField': fieldPath,
active: true
});
return doc?.relationship || null;
}
catch (error) {
return null;
}
}
async loadRelationshipsFromDatabase(collection, direction) {
try {
const filter = direction === 'outgoing'
? { sourceCollection: collection, active: true }
: { targetCollection: collection, active: true };
const docs = await this.storage.find('_relationships', filter);
return docs.map(doc => doc.relationship).filter(Boolean);
}
catch (error) {
return [];
}
}
getRelationshipKey(sourceCollection, targetCollection, localField) {
return `${sourceCollection}.${localField}->${targetCollection}`;
}
getPopulateCacheKey(collection, documentId, options) {
return `${collection}:${documentId}:${options.path}:${JSON.stringify(options.match || {})}`;
}
getFieldValue(document, fieldPath) {
const path = fieldPath.split('.');
let current = document;
for (const segment of path) {
if (current && typeof current === 'object') {
current = current[segment];
}
else {
return undefined;
}
}
return current;
}
buildProjection(select) {
if (!select)
return undefined;
const fields = Array.isArray(select) ? select : select.split(' ');
const projection = {};
for (const field of fields) {
if (field.trim()) {
projection[field.trim()] = 1;
}
}
return Object.keys(projection).length > 0 ? projection : undefined;
}
applyUpdateToDocument(document, update) {
const result = { ...document };
if (update.$set) {
Object.assign(result, update.$set);
}
if (update.$unset) {
for (const field of Object.keys(update.$unset)) {
delete result[field];
}
}
if (update.$inc) {
for (const [field, increment] of Object.entries(update.$inc)) {
result[field] = (result[field] || 0) + increment;
}
}
// Handle other update operators as needed
return result;
}
/**
* Clear populate cache
*/
clearPopulateCache() {
this.populateCache.clear();
}
/**
* Get populate cache statistics
*/
getPopulateCacheStats() {
return {
size: this.populateCache.size,
memoryUsage: JSON.stringify([...this.populateCache.entries()]).length
};
}
}
//# sourceMappingURL=relationship-manager.js.map