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.
333 lines (332 loc) • 17.7 kB
JavaScript
import { logger } from "../../shared/logging.js";
import { isEmpty } from "es-toolkit/compat";
/**
* 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 {
config;
userMappingService;
// Map to track old to new ID mappings for each collection
oldIdToNewIdPerCollectionMap = new Map();
constructor(config, userMappingService) {
this.config = config;
this.userMappingService = userMappingService;
}
/**
* Helper method to generate a consistent key for collections.
* Preserves existing logic from DataLoader.
*/
getCollectionKey(name) {
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, oldId, newId) {
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, oldId) {
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, oldId) {
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
*/
getValueFromData(finalData, context, key) {
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, collections) {
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) => {
if (Array.isArray(currentData.finalData[fieldToSetKey])) {
return currentData.finalData[fieldToSetKey].filter((data) => !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) => 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) => 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) => 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)
*/
getMergedId(oldId, targetCollection) {
// 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) {
const errors = [];
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() {
const collectionsWithMappings = [];
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() {
this.oldIdToNewIdPerCollectionMap.clear();
}
/**
* Exports ID mappings for debugging or external use.
*
* @returns Serializable object containing all ID mappings
*/
exportMappings() {
const exported = {};
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) {
this.oldIdToNewIdPerCollectionMap.clear();
for (const [collectionKey, collectionMappings] of Object.entries(mappings)) {
const mappingMap = new Map();
for (const [oldId, newId] of Object.entries(collectionMappings)) {
mappingMap.set(oldId, newId);
}
this.oldIdToNewIdPerCollectionMap.set(collectionKey, mappingMap);
}
}
}