UNPKG

@andrejs1979/document

Version:

MongoDB-compatible document database for NoSQL

527 lines 22.1 kB
/** * 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