UNPKG

appwrite-utils-cli

Version:

Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.

461 lines (413 loc) 17.6 kB
import type { AttributeMappings, IdMappings, AppwriteConfig, CollectionCreate, } from "appwrite-utils"; import { logger } from "../../shared/logging.js"; import { isEmpty } from "es-toolkit/compat"; import type { UserMappingService } from "./UserMappingService.js"; export interface CollectionImportData { collection?: CollectionCreate; data: Array<{ rawData: any; finalData: any; context: any; importDef?: any; }>; } /** * Service responsible for resolving relationships and ID mappings during import. * Preserves all existing relationship resolution logic from DataLoader. * Extracted to provide focused, testable relationship management. */ export class RelationshipResolver { private config: AppwriteConfig; private userMappingService: UserMappingService; // Map to track old to new ID mappings for each collection private oldIdToNewIdPerCollectionMap = new Map<string, Map<string, string>>(); constructor(config: AppwriteConfig, userMappingService: UserMappingService) { this.config = config; this.userMappingService = userMappingService; } /** * Helper method to generate a consistent key for collections. * Preserves existing logic from DataLoader. */ getCollectionKey(name: string): string { return name.toLowerCase().replace(" ", ""); } /** * Stores ID mapping for a collection. * * @param collectionName - The collection name * @param oldId - The old ID * @param newId - The new ID */ setIdMapping(collectionName: string, oldId: string, newId: string): void { const collectionKey = this.getCollectionKey(collectionName); if (!this.oldIdToNewIdPerCollectionMap.has(collectionKey)) { this.oldIdToNewIdPerCollectionMap.set(collectionKey, new Map()); } const collectionMap = this.oldIdToNewIdPerCollectionMap.get(collectionKey)!; collectionMap.set(`${oldId}`, `${newId}`); } /** * Gets the new ID for an old ID in a specific collection. * * @param collectionName - The collection name * @param oldId - The old ID to look up * @returns The new ID if found, otherwise undefined */ getNewIdForOldId(collectionName: string, oldId: string): string | undefined { const collectionKey = this.getCollectionKey(collectionName); return this.oldIdToNewIdPerCollectionMap.get(collectionKey)?.get(`${oldId}`); } /** * Checks if an ID mapping exists for a collection. * * @param collectionName - The collection name * @param oldId - The old ID to check * @returns True if mapping exists */ hasIdMapping(collectionName: string, oldId: string): boolean { const collectionKey = this.getCollectionKey(collectionName); return this.oldIdToNewIdPerCollectionMap.get(collectionKey)?.has(`${oldId}`) || false; } /** * Gets the value to match for a given key in the final data or context. * Preserves existing logic from DataLoader. * * @param finalData - The final data object * @param context - The context object * @param key - The key to get the value for * @returns The value to match for from finalData or Context */ private getValueFromData(finalData: any, context: any, key: string): any { if ( context[key] !== undefined && context[key] !== null && context[key] !== "" ) { return context[key]; } return finalData[key]; } /** * Updates old references with new IDs based on ID mappings. * Preserves existing reference update logic from DataLoader. * * @param importMap - Map of collection data by collection key * @param collections - Array of collection configurations */ updateOldReferencesForNew( importMap: Map<string, CollectionImportData>, collections: CollectionCreate[] ): void { if (!collections) { return; } for (const collectionConfig of collections) { const collectionKey = this.getCollectionKey(collectionConfig.name); const collectionData = importMap.get(collectionKey); if (!collectionData || !collectionData.data) continue; logger.info(`Updating references for collection: ${collectionConfig.name}`); let needsUpdate = false; // Iterate over each data item in the current collection for (let i = 0; i < collectionData.data.length; i++) { if (collectionConfig.importDefs) { for (const importDef of collectionConfig.importDefs) { if (importDef.idMappings) { for (const idMapping of importDef.idMappings) { const targetCollectionKey = this.getCollectionKey( idMapping.targetCollection ); const fieldToSetKey = idMapping.fieldToSet || idMapping.sourceField; const targetFieldKey = idMapping.targetFieldToMatch || idMapping.targetField; const sourceValue = this.getValueFromData( collectionData.data[i].finalData, collectionData.data[i].context, idMapping.sourceField ); // Skip if value to match is missing or empty if ( !sourceValue || isEmpty(sourceValue) || sourceValue === null ) continue; const isFieldToSetArray = collectionConfig.attributes?.find( (attribute) => attribute.key === fieldToSetKey )?.array; const targetCollectionData = importMap.get(targetCollectionKey); if (!targetCollectionData || !targetCollectionData.data) continue; // Handle cases where sourceValue is an array const sourceValues = Array.isArray(sourceValue) ? sourceValue.map((sourceValue) => `${sourceValue}`) : [`${sourceValue}`]; let newData = []; for (const valueToMatch of sourceValues) { // Find matching data in the target collection const foundData = targetCollectionData.data.filter( ({ context, finalData }) => { const targetValue = this.getValueFromData( finalData, context, targetFieldKey ); const isMatch = `${targetValue}` === `${valueToMatch}`; // Ensure the targetValue is defined and not null return ( isMatch && targetValue !== undefined && targetValue !== null ); } ); if (foundData.length) { newData.push( ...foundData.map((data) => { const newValue = this.getValueFromData( data.finalData, data.context, idMapping.targetField ); return newValue; }) ); } else { logger.info( `No data found for collection: ${targetCollectionKey} with value: ${valueToMatch} for field: ${fieldToSetKey} -- idMapping: ${JSON.stringify( idMapping, null, 2 )}` ); } continue; } const getCurrentDataFiltered = (currentData: any) => { if (Array.isArray(currentData.finalData[fieldToSetKey])) { return currentData.finalData[fieldToSetKey].filter( (data: any) => !sourceValues.includes(`${data}`) ); } return currentData.finalData[fieldToSetKey]; }; // Get the current data to be updated const currentDataFiltered = getCurrentDataFiltered( collectionData.data[i] ); if (newData.length) { needsUpdate = true; // Handle cases where current data is an array if (isFieldToSetArray) { if (!currentDataFiltered) { // Set new data if current data is undefined collectionData.data[i].finalData[fieldToSetKey] = Array.isArray(newData) ? newData : [newData]; } else { if (Array.isArray(currentDataFiltered)) { // Convert current data to array and merge if new data is non-empty array collectionData.data[i].finalData[fieldToSetKey] = [ ...new Set( [...currentDataFiltered, ...newData].filter( (value: any) => value !== null && value !== undefined && value !== "" ) ), ]; } else { // Merge arrays if new data is non-empty array and filter for uniqueness collectionData.data[i].finalData[fieldToSetKey] = [ ...new Set( [ ...(Array.isArray(currentDataFiltered) ? currentDataFiltered : [currentDataFiltered]), ...newData, ].filter( (value: any) => value !== null && value !== undefined && value !== "" && !sourceValues.includes(`${value}`) ) ), ]; } } } else { if (!currentDataFiltered) { // Set new data if current data is undefined collectionData.data[i].finalData[fieldToSetKey] = Array.isArray(newData) ? newData[0] : newData; } else if (Array.isArray(newData) && newData.length > 0) { // Convert current data to array and merge if new data is non-empty array, then filter for uniqueness // and take the first value, because it's an array and the attribute is not an array collectionData.data[i].finalData[fieldToSetKey] = [ ...new Set( [currentDataFiltered, ...newData].filter( (value: any) => value !== null && value !== undefined && value !== "" && !sourceValues.includes(`${value}`) ) ), ].slice(0, 1)[0]; } else if ( !Array.isArray(newData) && newData !== undefined ) { // Simply update the field if new data is not an array and defined collectionData.data[i].finalData[fieldToSetKey] = newData; } } } } } } } } // Update the import map if any changes were made if (needsUpdate) { importMap.set(collectionKey, collectionData); } } } /** * Resolves relationship references using the merged user map. * Handles cases where users have been merged during deduplication. * * @param oldId - The old user ID to resolve * @param targetCollection - The target collection name * @returns The resolved ID (new user ID if merged, otherwise mapped ID) */ private getMergedId(oldId: string, targetCollection: string): string { // Check if this is a user collection and if the ID was merged if (this.userMappingService.isUsersCollection(targetCollection)) { const mergedId = this.userMappingService.findNewUserIdForOldId(oldId); if (mergedId !== oldId) { return mergedId; } } // Retrieve the old to new ID map for the related collection const oldToNewIdMap = this.oldIdToNewIdPerCollectionMap.get( this.getCollectionKey(targetCollection) ); // If there's a mapping for the old ID, return the new ID if (oldToNewIdMap && oldToNewIdMap.has(`${oldId}`)) { return oldToNewIdMap.get(`${oldId}`)!; } // If no mapping is found, return the old ID as a fallback return oldId; } /** * Validates relationship integrity before import. * Checks that all referenced collections and fields exist. * * @param collections - Array of collections to validate * @returns Array of validation errors */ validateRelationships(collections: CollectionCreate[]): string[] { const errors: string[] = []; const collectionNames = new Set(collections.map(c => c.name)); for (const collection of collections) { if (!collection.importDefs) continue; for (const importDef of collection.importDefs) { if (!importDef.idMappings) continue; for (const idMapping of importDef.idMappings) { // Check if target collection exists if (!collectionNames.has(idMapping.targetCollection)) { errors.push( `Collection '${collection.name}' references non-existent target collection '${idMapping.targetCollection}'` ); } // Check if source field exists in source collection attributes const sourceFieldExists = collection.attributes?.some( attr => attr.key === (idMapping.fieldToSet || idMapping.sourceField) ); if (!sourceFieldExists) { errors.push( `Collection '${collection.name}' ID mapping references non-existent source field '${idMapping.fieldToSet || idMapping.sourceField}'` ); } // Find target collection and check if target field exists const targetCollection = collections.find(c => c.name === idMapping.targetCollection); if (targetCollection) { const targetFieldExists = targetCollection.attributes?.some( attr => attr.key === idMapping.targetField ); if (!targetFieldExists) { errors.push( `Collection '${collection.name}' ID mapping references non-existent target field '${idMapping.targetField}' in collection '${idMapping.targetCollection}'` ); } } } } } return errors; } /** * Gets relationship statistics for reporting. * * @returns Statistics about relationships and ID mappings */ getStatistics(): { totalCollections: number; totalIdMappings: number; collectionsWithMappings: string[]; } { const collectionsWithMappings: string[] = []; let totalIdMappings = 0; for (const [collectionKey, mappings] of this.oldIdToNewIdPerCollectionMap.entries()) { collectionsWithMappings.push(collectionKey); totalIdMappings += mappings.size; } return { totalCollections: this.oldIdToNewIdPerCollectionMap.size, totalIdMappings, collectionsWithMappings, }; } /** * Clears all ID mappings (useful for testing or resetting state). */ clearAllMappings(): void { this.oldIdToNewIdPerCollectionMap.clear(); } /** * Exports ID mappings for debugging or external use. * * @returns Serializable object containing all ID mappings */ exportMappings(): { [collectionKey: string]: { [oldId: string]: string } } { const exported: { [collectionKey: string]: { [oldId: string]: string } } = {}; for (const [collectionKey, mappings] of this.oldIdToNewIdPerCollectionMap.entries()) { exported[collectionKey] = Object.fromEntries(mappings.entries()); } return exported; } /** * Imports ID mappings from an external source. * * @param mappings - The mappings to import */ importMappings(mappings: { [collectionKey: string]: { [oldId: string]: string } }): void { this.oldIdToNewIdPerCollectionMap.clear(); for (const [collectionKey, collectionMappings] of Object.entries(mappings)) { const mappingMap = new Map<string, string>(); for (const [oldId, newId] of Object.entries(collectionMappings)) { mappingMap.set(oldId, newId); } this.oldIdToNewIdPerCollectionMap.set(collectionKey, mappingMap); } } }