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.

479 lines (472 loc) 21.6 kB
import { toCamelCase, toPascalCase } from "../utils/index.js"; import { z } from "zod"; import fs from "fs"; import path from "path"; import { dump } from "js-yaml"; import { getDatabaseFromConfig } from "./afterImportActions.js"; import { ulid } from "ulidx"; export class SchemaGenerator { relationshipMap = new Map(); config; appwriteFolderPath; constructor(config, appwriteFolderPath) { this.config = config; this.appwriteFolderPath = appwriteFolderPath; this.extractRelationships(); } updateTsSchemas() { const collections = this.config.collections; const functions = this.config.functions || []; delete this.config.collections; delete this.config.functions; const configPath = path.join(this.appwriteFolderPath, "appwriteConfig.ts"); const configContent = `import { type AppwriteConfig } from "appwrite-utils"; const appwriteConfig: AppwriteConfig = { appwriteEndpoint: "${this.config.appwriteEndpoint}", appwriteProject: "${this.config.appwriteProject}", appwriteKey: "${this.config.appwriteKey}", enableBackups: ${this.config.enableBackups}, backupInterval: ${this.config.backupInterval}, backupRetention: ${this.config.backupRetention}, enableBackupCleanup: ${this.config.enableBackupCleanup}, enableMockData: ${this.config.enableMockData}, documentBucketId: "${this.config.documentBucketId}", usersCollectionName: "${this.config.usersCollectionName}", databases: ${JSON.stringify(this.config.databases, null, 4)}, buckets: ${JSON.stringify(this.config.buckets, null, 4)}, functions: ${JSON.stringify(functions.map((func) => ({ functionId: func.$id || ulid(), name: func.name, runtime: func.runtime, path: func.dirPath || `functions/${func.name}`, entrypoint: func.entrypoint || "src/index.ts", execute: func.execute, events: func.events || [], schedule: func.schedule || "", timeout: func.timeout || 15, enabled: func.enabled !== false, logging: func.logging !== false, commands: func.commands || "npm install", scopes: func.scopes || [], installationId: func.installationId, providerRepositoryId: func.providerRepositoryId, providerBranch: func.providerBranch, providerSilentMode: func.providerSilentMode, providerRootDirectory: func.providerRootDirectory, specification: func.specification, ...(func.predeployCommands ? { predeployCommands: func.predeployCommands } : {}), ...(func.deployDir ? { deployDir: func.deployDir } : {}), })), null, 4)} }; export default appwriteConfig; `; fs.writeFileSync(configPath, configContent, { encoding: "utf-8" }); const collectionsFolderPath = path.join(this.appwriteFolderPath, "collections"); if (!fs.existsSync(collectionsFolderPath)) { fs.mkdirSync(collectionsFolderPath, { recursive: true }); } collections?.forEach((collection) => { const { databaseId, ...collectionWithoutDbId } = collection; // Destructure to exclude databaseId const collectionFilePath = path.join(collectionsFolderPath, `${collection.name}.ts`); const collectionContent = `import { type CollectionCreate } from "appwrite-utils"; const ${collection.name}Config: Partial<CollectionCreate> = { name: "${collection.name}", $id: "${collection.$id}", enabled: ${collection.enabled}, documentSecurity: ${collection.documentSecurity}, $permissions: [ ${collection.$permissions .map((permission) => `{ permission: "${permission.permission}", target: "${permission.target}" }`) .join(",\n ")} ], attributes: [ ${collection.attributes .map((attr) => { return `{ ${Object.entries(attr) .map(([key, value]) => { // Check the type of the value and format it accordingly if (typeof value === "string") { // If the value is a string, wrap it in quotes return `${key}: "${value.replace(/"/g, '\\"')}"`; // Escape existing quotes in the string } else if (Array.isArray(value)) { // If the value is an array, join it with commas if (value.length > 0) { return `${key}: [${value .map((item) => `"${item}"`) .join(", ")}]`; } else { return `${key}: []`; } } else { // If the value is not a string (e.g., boolean or number), output it directly return `${key}: ${value}`; } }) .join(", ")} }`; }) .join(",\n ")} ], indexes: [ ${(collection.indexes?.map((index) => { // Map each attribute to ensure it is properly quoted const formattedAttributes = index.attributes.map((attr) => `"${attr}"`).join(", ") ?? ""; return `{ key: "${index.key}", type: "${index.type}", attributes: [${formattedAttributes}], orders: [${index.orders ?.filter((order) => order !== null) .map((order) => `"${order}"`) .join(", ") ?? ""}] }`; }) ?? []).join(",\n ")} ] }; export default ${collection.name}Config; `; fs.writeFileSync(collectionFilePath, collectionContent, { encoding: "utf-8", }); console.log(`Collection schema written to ${collectionFilePath}`); }); } updateConfig(config) { const configPath = path.join(this.appwriteFolderPath, "appwriteConfig.ts"); const configContent = `import { type AppwriteConfig } from "appwrite-utils"; const appwriteConfig: AppwriteConfig = { appwriteEndpoint: "${config.appwriteEndpoint}", appwriteProject: "${config.appwriteProject}", appwriteKey: "${config.appwriteKey}", enableBackups: ${config.enableBackups}, backupInterval: ${config.backupInterval}, backupRetention: ${config.backupRetention}, enableBackupCleanup: ${config.enableBackupCleanup}, enableMockData: ${config.enableMockData}, documentBucketId: "${config.documentBucketId}", usersCollectionName: "${config.usersCollectionName}", databases: ${JSON.stringify(config.databases, null, 4)}, buckets: ${JSON.stringify(config.buckets, null, 4)}, functions: ${JSON.stringify(config.functions?.map((func) => ({ $id: func.$id || ulid(), name: func.name, runtime: func.runtime, dirPath: func.dirPath || `functions/${func.name}`, entrypoint: func.entrypoint || "src/index.ts", execute: func.execute || [], events: func.events || [], schedule: func.schedule || "", timeout: func.timeout || 15, enabled: func.enabled !== false, logging: func.logging !== false, commands: func.commands || "npm install", scopes: func.scopes || [], installationId: func.installationId, providerRepositoryId: func.providerRepositoryId, providerBranch: func.providerBranch, providerSilentMode: func.providerSilentMode, providerRootDirectory: func.providerRootDirectory, specification: func.specification, })), null, 4)} }; export default appwriteConfig; `; fs.writeFileSync(configPath, configContent, { encoding: "utf-8" }); } extractRelationships() { if (!this.config.collections) { return; } this.config.collections.forEach((collection) => { if (!collection.attributes) { return; } collection.attributes.forEach((attr) => { if (attr.type === "relationship" && attr.twoWay && attr.twoWayKey) { const relationshipAttr = attr; let isArrayParent = false; let isArrayChild = false; switch (relationshipAttr.relationType) { case "oneToMany": isArrayParent = true; isArrayChild = false; break; case "manyToMany": isArrayParent = true; isArrayChild = true; break; case "oneToOne": isArrayParent = false; isArrayChild = false; break; case "manyToOne": isArrayParent = false; isArrayChild = true; break; default: break; } this.addRelationship(collection.name, relationshipAttr.relatedCollection, attr.key, relationshipAttr.twoWayKey, isArrayParent, isArrayChild); console.log(`Extracted relationship: ${attr.key}\n\t${collection.name} -> ${relationshipAttr.relatedCollection}, databaseId: ${collection.databaseId}`); } }); }); } addRelationship(parentCollection, childCollection, parentKey, childKey, isArrayParent, isArrayChild) { const relationshipsChild = this.relationshipMap.get(childCollection) || []; const relationshipsParent = this.relationshipMap.get(parentCollection) || []; relationshipsParent.push({ parentCollection, childCollection, parentKey, childKey, isArray: isArrayParent, isChild: false, }); relationshipsChild.push({ parentCollection, childCollection, parentKey, childKey, isArray: isArrayChild, isChild: true, }); this.relationshipMap.set(childCollection, relationshipsChild); this.relationshipMap.set(parentCollection, relationshipsParent); } generateSchemas() { if (!this.config.collections) { return; } this.config.collections.forEach((collection) => { const schemaString = this.createSchemaString(collection.name, collection.attributes || []); const camelCaseName = toCamelCase(collection.name); const schemaPath = path.join(this.appwriteFolderPath, "schemas", `${camelCaseName}.ts`); fs.writeFileSync(schemaPath, schemaString, { encoding: "utf-8" }); console.log(`Schema written to ${schemaPath}`); }); } createSchemaString = (name, attributes) => { const pascalName = toPascalCase(name); let imports = `import { z } from "zod";\n`; const hasDescription = attributes.some((attr) => attr.description); if (hasDescription) { imports += `import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";\n`; imports += `extendZodWithOpenApi(z);\n`; } // Use the relationshipMap to find related collections const relationshipDetails = this.relationshipMap.get(name) || []; const relatedCollections = relationshipDetails .filter((detail, index, self) => { const uniqueKey = `${detail.parentCollection}-${detail.childCollection}-${detail.parentKey}-${detail.childKey}`; return (index === self.findIndex((obj) => `${obj.parentCollection}-${obj.childCollection}-${obj.parentKey}-${obj.childKey}` === uniqueKey)); }) .map((detail) => { const relatedCollectionName = detail.isChild ? detail.parentCollection : detail.childCollection; const key = detail.isChild ? detail.childKey : detail.parentKey; const isArray = detail.isArray ? "array" : ""; return [relatedCollectionName, key, isArray]; }); let relatedTypes = ""; let relatedTypesLazy = ""; let curNum = 0; let maxNum = relatedCollections.length; relatedCollections.forEach((relatedCollection) => { console.log(relatedCollection); let relatedPascalName = toPascalCase(relatedCollection[0]); let relatedCamelName = toCamelCase(relatedCollection[0]); curNum++; let endNameTypes = relatedPascalName; let endNameLazy = `${relatedPascalName}Schema`; if (relatedCollection[2] === "array") { endNameTypes += "[]"; endNameLazy += ".array().default([])"; } else if (!(relatedCollection[2] === "array")) { endNameTypes += " | null"; endNameLazy += ".nullish()"; } imports += `import { ${relatedPascalName}Schema, type ${relatedPascalName} } from "./${relatedCamelName}";\n`; relatedTypes += `${relatedCollection[1]}?: ${endNameTypes};\n`; if (relatedTypes.length > 0 && curNum !== maxNum) { relatedTypes += " "; } relatedTypesLazy += `${relatedCollection[1]}: z.lazy(() => ${endNameLazy}),\n`; if (relatedTypesLazy.length > 0 && curNum !== maxNum) { relatedTypesLazy += " "; } }); let schemaString = `${imports}\n\n`; schemaString += `export const ${pascalName}SchemaBase = z.object({\n`; schemaString += ` $id: z.string().optional(),\n`; schemaString += ` $createdAt: z.string().optional(),\n`; schemaString += ` $updatedAt: z.string().optional(),\n`; for (const attribute of attributes) { if (attribute.type === "relationship") { continue; } schemaString += ` ${attribute.key}: ${this.typeToZod(attribute)},\n`; } schemaString += `});\n\n`; schemaString += `export type ${pascalName}Base = z.infer<typeof ${pascalName}SchemaBase>`; if (relatedTypes.length > 0) { schemaString += ` & {\n ${relatedTypes}};\n\n`; } else { schemaString += `;\n\n`; } schemaString += `export const ${pascalName}Schema: z.ZodType<${pascalName}Base> = ${pascalName}SchemaBase`; if (relatedTypes.length > 0) { schemaString += `.extend({\n ${relatedTypesLazy}});\n\n`; } else { schemaString += `;\n`; } schemaString += `export type ${pascalName} = z.infer<typeof ${pascalName}Schema>;\n\n`; return schemaString; }; typeToZod = (attribute) => { let baseSchemaCode = ""; const finalAttribute = (attribute.type === "string" && attribute.format && attribute.format === "enum" && attribute.type === "string" ? { ...attribute, type: attribute.format } : attribute); switch (finalAttribute.type) { case "string": baseSchemaCode = "z.string()"; if (finalAttribute.size) { baseSchemaCode += `.max(${finalAttribute.size}, "Maximum length of ${finalAttribute.size} characters exceeded")`; } if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default("${finalAttribute.xdefault}")`; } if (!attribute.required && !attribute.array) { baseSchemaCode += ".nullish()"; } break; case "integer": baseSchemaCode = "z.number().int()"; if (finalAttribute.min !== undefined) { if (BigInt(finalAttribute.min) === BigInt(-9223372036854776000)) { delete finalAttribute.min; } else { baseSchemaCode += `.min(${finalAttribute.min}, "Minimum value of ${finalAttribute.min} not met")`; } } if (finalAttribute.max !== undefined) { if (BigInt(finalAttribute.max) === BigInt(9223372036854776000)) { delete finalAttribute.max; } else { baseSchemaCode += `.max(${finalAttribute.max}, "Maximum value of ${finalAttribute.max} exceeded")`; } } if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default(${finalAttribute.xdefault})`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "float": baseSchemaCode = "z.number()"; if (finalAttribute.min !== undefined) { baseSchemaCode += `.min(${finalAttribute.min}, "Minimum value of ${finalAttribute.min} not met")`; } if (finalAttribute.max !== undefined) { baseSchemaCode += `.max(${finalAttribute.max}, "Maximum value of ${finalAttribute.max} exceeded")`; } if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default(${finalAttribute.xdefault})`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "boolean": baseSchemaCode = "z.boolean()"; if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default(${finalAttribute.xdefault})`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "datetime": baseSchemaCode = "z.date()"; if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default(new Date("${finalAttribute.xdefault}"))`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "email": baseSchemaCode = "z.string().email()"; if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default("${finalAttribute.xdefault}")`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "ip": baseSchemaCode = "z.string()"; // Add custom validation as needed if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default("${finalAttribute.xdefault}")`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "url": baseSchemaCode = "z.string().url()"; if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default("${finalAttribute.xdefault}")`; } if (!finalAttribute.required && !finalAttribute.array) { baseSchemaCode += ".nullish()"; } break; case "enum": baseSchemaCode = `z.enum([${finalAttribute.elements .map((element) => `"${element}"`) .join(", ")}])`; if (finalAttribute.xdefault !== undefined) { baseSchemaCode += `.default("${finalAttribute.xdefault}")`; } if (!attribute.required && !attribute.array) { baseSchemaCode += ".nullish()"; } break; case "relationship": break; default: baseSchemaCode = "z.any()"; } // Handle arrays if (attribute.array) { baseSchemaCode = `z.array(${baseSchemaCode})`; } if (attribute.array && !attribute.required) { baseSchemaCode += ".nullish()"; } if (attribute.description) { if (typeof attribute.description === "string") { baseSchemaCode += `.openapi({ description: "${attribute.description}" })`; } else { baseSchemaCode += `.openapi(${Object.entries(attribute.description) .map(([key, value]) => `"${key}": ${value}`) .join(", ")})`; } } return baseSchemaCode; }; }