diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 162eb5d29b..b88f7e0bcd 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -348,8 +348,33 @@ public function getFiles(): array ], [ 'scope' => 'copy', - 'destination' => 'lib/commands/db.ts', - 'template' => 'cli/lib/commands/db.ts', + 'destination' => 'lib/commands/config-validations.ts', + 'template' => 'cli/lib/commands/config-validations.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generate.ts', + 'template' => 'cli/lib/commands/generate.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/base.ts', + 'template' => 'cli/lib/commands/generators/base.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/index.ts', + 'template' => 'cli/lib/commands/generators/index.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/language-detector.ts', + 'template' => 'cli/lib/commands/generators/language-detector.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/typescript/databases.ts', + 'template' => 'cli/lib/commands/generators/typescript/databases.ts', ], [ 'scope' => 'copy', diff --git a/templates/cli/cli.ts.twig b/templates/cli/cli.ts.twig index 204224fea1..4592d41917 100644 --- a/templates/cli/cli.ts.twig +++ b/templates/cli/cli.ts.twig @@ -23,6 +23,7 @@ import { pull } from './lib/commands/pull.js'; import { run } from './lib/commands/run.js'; import { push, deploy } from './lib/commands/push.js'; import { update } from './lib/commands/update.js'; +import { generate } from './lib/commands/generate.js'; {% else %} import { migrate } from './lib/commands/generic.js'; {% endif %} @@ -122,6 +123,7 @@ if (process.argv.includes('-v') || process.argv.includes('--version')) { .addCommand(deploy) .addCommand(run) .addCommand(update) + .addCommand(generate) .addCommand(logout) {% endif %} {% for service in spec.services %} diff --git a/templates/cli/lib/commands/config-validations.ts b/templates/cli/lib/commands/config-validations.ts new file mode 100644 index 0000000000..98fc06c4aa --- /dev/null +++ b/templates/cli/lib/commands/config-validations.ts @@ -0,0 +1,199 @@ +import { z } from "zod"; + +// ============================================================================ +// Attribute Validations +// ============================================================================ + +/** + * Validates that when 'required' is true, 'default' must be null + */ +export const validateRequiredDefault = (data: { + required?: boolean; + default?: any; +}) => { + if (data.required === true && data.default !== null) { + return false; + } + return true; +}; + +/** + * Validates that string type attributes must have a size defined + */ +export const validateStringSize = (data: { + type: string; + size?: number | null; +}) => { + if ( + data.type === "string" && + (data.size === undefined || data.size === null) + ) { + return false; + } + return true; +}; + +// ============================================================================ +// Collection & Table Validations +// ============================================================================ + +interface AttributeOrColumn { + key: string; +} + +interface Index { + key: string; +} + +interface CollectionOrTableData { + attributes?: AttributeOrColumn[]; + columns?: AttributeOrColumn[]; + indexes?: Index[]; +} + +/** + * Validates duplicate keys in attributes/columns and indexes + */ +export const validateContainerDuplicates = ( + data: CollectionOrTableData, + ctx: z.RefinementCtx, +) => { + const items = data.attributes || data.columns || []; + const itemType = data.attributes ? "Attribute" : "Column"; + const itemPath = data.attributes ? "attributes" : "columns"; + + // Validate duplicate item keys + if (items.length > 0) { + const seenKeys = new Set(); + + items.forEach((item, index) => { + if (seenKeys.has(item.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${itemType} with the key '${item.key}' already exists. ${itemType} keys must be unique, try again with a different key.`, + path: [itemPath, index, "key"], + }); + } else { + seenKeys.add(item.key); + } + }); + } + + // Validate duplicate index keys + if (data.indexes && data.indexes.length > 0) { + const seenKeys = new Set(); + + data.indexes.forEach((index, indexPos) => { + if (seenKeys.has(index.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, + path: ["indexes", indexPos, "key"], + }); + } else { + seenKeys.add(index.key); + } + }); + } +}; + +// ============================================================================ +// Config Validations +// ============================================================================ + +interface RelationshipItem { + key: string; + type: string; + relatedTable?: string; + relatedCollection?: string; +} + +interface ContainerWithDatabase { + $id: string; + name: string; + databaseId: string; + columns?: RelationshipItem[]; + attributes?: RelationshipItem[]; +} + +interface ConfigData { + tables?: ContainerWithDatabase[]; + collections?: ContainerWithDatabase[]; +} + +/** + * Validates cross-database relationships + */ +export const validateCrossDatabase = ( + data: ConfigData, + ctx: z.RefinementCtx, +) => { + // Validate cross-database relationships for tables + if (data.tables && data.tables.length > 0) { + // Build a map of table IDs to their database IDs + const tableDatabaseMap = new Map(); + for (const table of data.tables) { + tableDatabaseMap.set(table.$id, table.databaseId); + } + + // Check each table's relationship columns + data.tables.forEach((table, tableIndex) => { + const columns = table.columns || []; + columns.forEach((column, columnIndex) => { + if (column.type === "relationship" && column.relatedTable) { + const relatedDatabaseId = tableDatabaseMap.get(column.relatedTable); + if (relatedDatabaseId && relatedDatabaseId !== table.databaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid cross-database relationship: Table '${table.name}' (database: '${table.databaseId}') has relationship '${column.key}' pointing to table '${column.relatedTable}' which is in database '${relatedDatabaseId}'. Appwrite only supports relationships within the same database.`, + path: [ + "tables", + tableIndex, + "columns", + columnIndex, + "relatedTable", + ], + }); + } + } + }); + }); + } + + // Validate cross-database relationships for collections (legacy) + if (data.collections && data.collections.length > 0) { + // Build a map of collection IDs to their database IDs + const collectionDatabaseMap = new Map(); + for (const collection of data.collections) { + collectionDatabaseMap.set(collection.$id, collection.databaseId); + } + + // Check each collection's relationship attributes + data.collections.forEach((collection, collectionIndex) => { + const attributes = collection.attributes || []; + attributes.forEach((attribute, attributeIndex) => { + if (attribute.type === "relationship" && attribute.relatedCollection) { + const relatedDatabaseId = collectionDatabaseMap.get( + attribute.relatedCollection, + ); + if ( + relatedDatabaseId && + relatedDatabaseId !== collection.databaseId + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid cross-database relationship: Collection '${collection.name}' (database: '${collection.databaseId}') has relationship '${attribute.key}' pointing to collection '${attribute.relatedCollection}' which is in database '${relatedDatabaseId}'. Appwrite only supports relationships within the same database.`, + path: [ + "collections", + collectionIndex, + "attributes", + attributeIndex, + "relatedCollection", + ], + }); + } + } + }); + }); + } +}; diff --git a/templates/cli/lib/commands/config.ts b/templates/cli/lib/commands/config.ts index 466d6325b0..33da6446f4 100644 --- a/templates/cli/lib/commands/config.ts +++ b/templates/cli/lib/commands/config.ts @@ -1,7 +1,13 @@ import { z } from "zod"; +import { + validateRequiredDefault, + validateStringSize, + validateContainerDuplicates, + validateCrossDatabase, +} from "./config-validations.js"; // ============================================================================ -// Internal Helpers (not exported) +// Internal Helpers // ============================================================================ const INT64_MIN = BigInt("-9223372036854775808"); @@ -65,29 +71,6 @@ const MockNumberSchema = z }) .strict(); -// ============================================================================ -// Config Schema -// ============================================================================ - -const ConfigSchema = z - .object({ - projectId: z.string(), - projectName: z.string().optional(), - endpoint: z.string().optional(), - settings: z.lazy(() => SettingsSchema).optional(), - functions: z.array(z.lazy(() => FunctionSchema)).optional(), - sites: z.array(z.lazy(() => SiteSchema)).optional(), - databases: z.array(z.lazy(() => DatabaseSchema)).optional(), - collections: z.array(z.lazy(() => CollectionSchema)).optional(), - tablesDB: z.array(z.lazy(() => DatabaseSchema)).optional(), - tables: z.array(z.lazy(() => TablesDBSchema)).optional(), - topics: z.array(z.lazy(() => TopicSchema)).optional(), - teams: z.array(z.lazy(() => TeamSchema)).optional(), - buckets: z.array(z.lazy(() => BucketSchema)).optional(), - messages: z.array(z.lazy(() => MessageSchema)).optional(), - }) - .strict(); - // ============================================================================ // Project Settings // ============================================================================ @@ -206,7 +189,7 @@ const DatabaseSchema = z // Collections (legacy) // ============================================================================ -const AttributeSchemaBase = z +const AttributeSchema = z .object({ key: z.string(), type: z.enum([ @@ -234,6 +217,7 @@ const AttributeSchemaBase = z .optional(), elements: z.array(z.string()).optional(), relatedCollection: z.string().optional(), + relatedTable: z.string().optional(), relationType: z.string().optional(), twoWay: z.boolean().optional(), twoWayKey: z.string().optional(), @@ -243,34 +227,15 @@ const AttributeSchemaBase = z orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), }) - .strict(); - -const AttributeSchema = AttributeSchemaBase.refine( - (data) => { - if (data.required === true && data.default !== null) { - return false; - } - return true; - }, - { + .strict() + .refine(validateRequiredDefault, { message: "When 'required' is true, 'default' must be null", path: ["default"], - }, -).refine( - (data) => { - if ( - data.type === "string" && - (data.size === undefined || data.size === null) - ) { - return false; - } - return true; - }, - { + }) + .refine(validateStringSize, { message: "When 'type' is 'string', 'size' must be defined", path: ["size"], - }, -); + }); const IndexSchema = z .object({ @@ -294,45 +259,13 @@ const CollectionSchema = z indexes: z.array(IndexSchema).optional(), }) .strict() - .superRefine((data, ctx) => { - if (data.attributes && data.attributes.length > 0) { - const seenKeys = new Set(); - - data.attributes.forEach((attr, index) => { - if (seenKeys.has(attr.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Attribute with the key '${attr.key}' already exists. Attribute keys must be unique, try again with a different key.`, - path: ["attributes", index, "key"], - }); - } else { - seenKeys.add(attr.key); - } - }); - } - - if (data.indexes && data.indexes.length > 0) { - const seenKeys = new Set(); - - data.indexes.forEach((index, indexPos) => { - if (seenKeys.has(index.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, - path: ["indexes", indexPos, "key"], - }); - } else { - seenKeys.add(index.key); - } - }); - } - }); + .superRefine(validateContainerDuplicates); // ============================================================================ // Tables // ============================================================================ -const ColumnSchemaBase = z +const ColumnSchema = z .object({ key: z.string(), type: z.enum([ @@ -371,34 +304,15 @@ const ColumnSchemaBase = z orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), }) - .strict(); - -const ColumnSchema = ColumnSchemaBase.refine( - (data) => { - if (data.required === true && data.default !== null) { - return false; - } - return true; - }, - { + .strict() + .refine(validateRequiredDefault, { message: "When 'required' is true, 'default' must be null", path: ["default"], - }, -).refine( - (data) => { - if ( - data.type === "string" && - (data.size === undefined || data.size === null) - ) { - return false; - } - return true; - }, - { + }) + .refine(validateStringSize, { message: "When 'type' is 'string', 'size' must be defined", path: ["size"], - }, -); + }); const IndexTableSchema = z .object({ @@ -410,7 +324,7 @@ const IndexTableSchema = z }) .strict(); -const TablesDBSchemaBase = z +const TableSchema = z .object({ $id: z.string(), $permissions: z.array(z.string()).optional(), @@ -421,41 +335,8 @@ const TablesDBSchemaBase = z columns: z.array(ColumnSchema).optional(), indexes: z.array(IndexTableSchema).optional(), }) - .strict(); - -const TablesDBSchema = TablesDBSchemaBase.superRefine((data, ctx) => { - if (data.columns && data.columns.length > 0) { - const seenKeys = new Set(); - - data.columns.forEach((col, index) => { - if (seenKeys.has(col.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Column with the key '${col.key}' already exists. Column keys must be unique, try again with a different key.`, - path: ["columns", index, "key"], - }); - } else { - seenKeys.add(col.key); - } - }); - } - - if (data.indexes && data.indexes.length > 0) { - const seenKeys = new Set(); - - data.indexes.forEach((index, indexPos) => { - if (seenKeys.has(index.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, - path: ["indexes", indexPos, "key"], - }); - } else { - seenKeys.add(index.key); - } - }); - } -}); + .strict() + .superRefine(validateContainerDuplicates); // ============================================================================ // Topics @@ -515,7 +396,31 @@ const BucketSchema = z .strict(); // ============================================================================ -// Type Exports (inferred from Zod schemas - single source of truth) +// Config Schema +// ============================================================================ + +const ConfigSchema = z + .object({ + projectId: z.string(), + projectName: z.string().optional(), + endpoint: z.string().optional(), + settings: z.lazy(() => SettingsSchema).optional(), + functions: z.array(z.lazy(() => FunctionSchema)).optional(), + sites: z.array(z.lazy(() => SiteSchema)).optional(), + databases: z.array(z.lazy(() => DatabaseSchema)).optional(), + collections: z.array(z.lazy(() => CollectionSchema)).optional(), + tablesDB: z.array(z.lazy(() => DatabaseSchema)).optional(), + tables: z.array(z.lazy(() => TableSchema)).optional(), + topics: z.array(z.lazy(() => TopicSchema)).optional(), + teams: z.array(z.lazy(() => TeamSchema)).optional(), + buckets: z.array(z.lazy(() => BucketSchema)).optional(), + messages: z.array(z.lazy(() => MessageSchema)).optional(), + }) + .strict() + .superRefine(validateCrossDatabase); + +// ============================================================================ +// Type Exports // ============================================================================ export type ConfigType = z.infer; @@ -526,7 +431,7 @@ export type DatabaseType = z.infer; export type CollectionType = z.infer; export type AttributeType = z.infer; export type IndexType = z.infer; -export type TableType = z.infer; +export type TableType = z.infer; export type ColumnType = z.infer; export type TableIndexType = z.infer; export type TopicType = z.infer; @@ -539,6 +444,7 @@ export type BucketType = z.infer; // ============================================================================ export { + /** Config */ ConfigSchema, /** Project Settings */ @@ -557,10 +463,8 @@ export { IndexSchema, /** Tables */ - TablesDBSchema, - TablesDBSchemaBase, + TableSchema, ColumnSchema, - ColumnSchemaBase, IndexTableSchema, /** Topics */ diff --git a/templates/cli/lib/commands/db.ts b/templates/cli/lib/commands/db.ts deleted file mode 100644 index 1149bee400..0000000000 --- a/templates/cli/lib/commands/db.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { ConfigType, AttributeSchema } from "./config.js"; -import * as fs from "fs"; -import * as path from "path"; -import { z } from "zod"; - -export interface GenerateOptions { - strict?: boolean; -} - -export interface GenerateResult { - dbContent: string; - typesContent: string; -} - -export class Db { - private getType( - attribute: z.infer, - collections: NonNullable, - ): string { - let type = ""; - - switch (attribute.type) { - case "string": - case "datetime": - type = "string"; - if (attribute.format === "enum") { - type = this.toPascalCase(attribute.key); - } - break; - case "integer": - type = "number"; - break; - case "double": - type = "number"; - break; - case "boolean": - type = "boolean"; - break; - case "relationship": - const relatedCollection = collections.find( - (c) => c.$id === attribute.relatedCollection, - ); - if (!relatedCollection) { - throw new Error( - `Related collection with ID '${attribute.relatedCollection}' not found.`, - ); - } - type = this.toPascalCase(relatedCollection.name); - if ( - (attribute.relationType === "oneToMany" && - attribute.side === "parent") || - (attribute.relationType === "manyToOne" && - attribute.side === "child") || - attribute.relationType === "manyToMany" - ) { - type = `${type}[]`; - } - break; - default: - throw new Error(`Unknown attribute type: ${attribute.type}`); - } - - if (attribute.array) { - type += "[]"; - } - - if (!attribute.required && attribute.default === null) { - type += " | null"; - } - - return type; - } - - private toPascalCase(str: string): string { - return str - .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) - .replace(/^(.)/, (char) => char.toUpperCase()); - } - - private toCamelCase(str: string): string { - return str - .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) - .replace(/^(.)/, (char) => char.toLowerCase()); - } - - private toUpperSnakeCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/[-\s]+/g, "_") - .toUpperCase(); - } - - private generateCollectionType( - collection: NonNullable[number], - collections: NonNullable, - options: GenerateOptions = {}, - ): string { - if (!collection.attributes) { - return ""; - } - - const { strict = false } = options; - const typeName = this.toPascalCase(collection.name); - const attributes = collection.attributes - .map((attr: z.infer) => { - const key = strict ? this.toCamelCase(attr.key) : attr.key; - return ` ${key}: ${this.getType(attr, collections)};`; - }) - .join("\n"); - - return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; - } - - private generateEnums( - collections: NonNullable, - ): string { - const enumTypes: string[] = []; - - for (const collection of collections) { - if (!collection.attributes) continue; - - for (const attribute of collection.attributes) { - if (attribute.format === "enum" && attribute.elements) { - const enumName = this.toPascalCase(attribute.key); - const enumValues = attribute.elements - .map((element, index) => { - const key = this.toUpperSnakeCase(element); - const isLast = index === attribute.elements!.length - 1; - return ` ${key} = "${element}"${isLast ? "" : ","}`; - }) - .join("\n"); - - enumTypes.push(`export enum ${enumName} {\n${enumValues}\n}`); - } - } - } - - return enumTypes.join("\n\n"); - } - - private generateTypesFile( - config: ConfigType, - options: GenerateOptions = {}, - ): string { - if (!config.collections || config.collections.length === 0) { - return "// No collections found in configuration\n"; - } - - const appwriteDep = this.getAppwriteDependency(); - const enums = this.generateEnums(config.collections); - const types = config.collections - .map((collection) => - this.generateCollectionType(collection, config.collections!, options), - ) - .join("\n\n"); - - const parts = [`import { type Models } from '${appwriteDep}';`, ""]; - - if (enums) { - parts.push(enums); - parts.push(""); - } - - parts.push(types); - parts.push(""); - - return parts.join("\n"); - } - - private getAppwriteDependency(): string { - const cwd = process.cwd(); - - if (fs.existsSync(path.resolve(cwd, "package.json"))) { - try { - const packageJsonRaw = fs.readFileSync( - path.resolve(cwd, "package.json"), - "utf-8", - ); - const packageJson = JSON.parse(packageJsonRaw); - return packageJson.dependencies?.["appwrite"] - ? "appwrite" - : "node-appwrite"; - } catch { - // Fallback if package.json is invalid - } - } - - if (fs.existsSync(path.resolve(cwd, "deno.json"))) { - return "https://deno.land/x/appwrite/mod.ts"; - } - - return "appwrite"; - } - - private generateDbFile( - config: ConfigType, - options: GenerateOptions = {}, - ): string { - const { strict = false } = options; - const typesFileName = "appwrite.types.ts"; - - if (!config.collections || config.collections.length === 0) { - return "// No collections found in configuration\n"; - } - - const typeNames = config.collections.map((c) => this.toPascalCase(c.name)); - const importPath = typesFileName - .replace(/\.d\.ts$/, "") - .replace(/\.ts$/, ""); - const appwriteDep = this.getAppwriteDependency(); - - const collectionsCode = config.collections - .map((collection) => { - const collectionName = strict - ? this.toCamelCase(collection.name) - : collection.name; - const typeName = this.toPascalCase(collection.name); - - return ` ${collectionName}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: string[] }) => - tablesDB.createRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: options?.rowId ?? ID.unique(), - data, - permissions: [ - Permission.write(Role.user(data.createdBy)), - Permission.read(Role.user(data.createdBy)), - Permission.update(Role.user(data.createdBy)), - Permission.delete(Role.user(data.createdBy)) - ] - }), - get: (id: string) => - tablesDB.getRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - }), - update: (id: string, data: Partial>, options?: { permissions?: string[] }) => - tablesDB.updateRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - data, - ...(options?.permissions ? { permissions: options.permissions } : {}), - }), - delete: (id: string) => - tablesDB.deleteRow({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - }), - list: (queries?: string[]) => - tablesDB.listRows<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - queries, - }), - }`; - }) - .join(",\n"); - - return `import { Client, TablesDB, ID, type Models, Permission, Role } from '${appwriteDep}'; -import type { ${typeNames.join(", ")} } from './${importPath}'; - -const client = new Client() - .setEndpoint(process.env.APPWRITE_ENDPOINT!) - .setProject(process.env.APPWRITE_PROJECT_ID!) - .setKey(process.env.APPWRITE_API_KEY!); - -const tablesDB = new TablesDB(client); - - -export const db = { -${collectionsCode} -}; -`; - } - - /** - * Generates TypeScript code for Appwrite database collections and types based on the provided configuration. - * - * This method returns generated content as strings: - * 1. A types string containing TypeScript interfaces for each collection. - * 2. A database client string with helper methods for CRUD operations on each collection. - * - * @param config - The Appwrite project configuration, including collections and project details. - * @param options - Optional settings for code generation: - * - strict: Whether to use strict naming conventions for collections (default: false). - * @returns A Promise that resolves with an object containing dbContent and typesContent strings. - * @throws If the configuration is missing a projectId or contains no collections. - */ - public async generate( - config: ConfigType, - options: GenerateOptions = {}, - ): Promise { - const { strict = false } = options; - - if (!config.projectId) { - throw new Error("Project ID is required in configuration"); - } - - if (!config.collections || config.collections.length === 0) { - console.log( - "No collections found in configuration. Skipping database generation.", - ); - return { - dbContent: "// No collections found in configuration\n", - typesContent: "// No collections found in configuration\n", - }; - } - - // Generate types content - const typesContent = this.generateTypesFile(config, { strict }); - - // Generate database client content - const dbContent = this.generateDbFile(config, { strict }); - - return { - dbContent, - typesContent, - }; - } -} diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts new file mode 100644 index 0000000000..dd66e92e12 --- /dev/null +++ b/templates/cli/lib/commands/generate.ts @@ -0,0 +1,142 @@ +import * as path from "path"; +import { Command } from "commander"; +import { ConfigType } from "./config.js"; +import { localConfig } from "../config.js"; +import { success, error, log, warn, actionRunner } from "../parser.js"; +import { + createGeneratorFromDetection, + createGenerator, + getSupportedLanguages, + LanguageDetector, + SupportedLanguage, +} from "./generators/index.js"; +import { + SDK_TITLE, + SDK_TITLE_LOWER, + EXECUTABLE_NAME, + NPM_PACKAGE_NAME, +} from "../constants.js"; + +export interface GenerateCommandOptions { + output: string; + language?: string; +} + +const generateAction = async ( + options: GenerateCommandOptions, +): Promise => { + const project = localConfig.getProject(); + + if (!project.projectId) { + error( + `No project found. Please run '${EXECUTABLE_NAME} init project' first.`, + ); + process.exit(1); + } + + // Determine the generator to use + let generator; + let detectedLanguage: string; + + if (options.language) { + // User explicitly specified a language + if (!LanguageDetector.isSupported(options.language)) { + const supported = getSupportedLanguages().join(", "); + error( + `Unsupported language: ${options.language}. Supported languages: ${supported}`, + ); + process.exit(1); + } + generator = createGenerator(options.language as SupportedLanguage); + detectedLanguage = options.language; + log(`Using specified language: ${detectedLanguage}`); + } else { + // Auto-detect language + try { + const { generator: detectedGenerator, detection } = + createGeneratorFromDetection(); + generator = detectedGenerator; + detectedLanguage = detection.language; + + if (detection.confidence === "low") { + warn( + `Detected language '${detectedLanguage}' with low confidence (${detection.reason}). ` + + `Use --language to specify explicitly.`, + ); + } else { + log(`Detected language: ${detectedLanguage} (${detection.reason})`); + } + } catch (err: any) { + const supported = getSupportedLanguages().join(", "); + error( + `${err.message}\nUse --language to specify the target language. Supported: ${supported}`, + ); + process.exit(1); + } + } + + const config: ConfigType = { + projectId: project.projectId, + projectName: project.projectName, + tablesDB: localConfig.getTablesDBs(), + tables: localConfig.getTables(), + databases: localConfig.getDatabases(), + collections: localConfig.getCollections(), + }; + + const outputDir = options.output; + const absoluteOutputDir = path.isAbsolute(outputDir) + ? outputDir + : path.join(process.cwd(), outputDir); + + log( + `Generating type-safe ${detectedLanguage} SDK to ${absoluteOutputDir}...`, + ); + + try { + const result = await generator.generate(config); + await generator.writeFiles(absoluteOutputDir, result); + + const generatedFiles = generator.getGeneratedFilePaths(result); + success(`Generated files:`); + for (const file of generatedFiles) { + console.log(` - ${path.join(outputDir, file)}`); + } + + // Show language-specific usage instructions + if (detectedLanguage === "typescript") { + console.log(""); + log(`Import the generated SDK in your project:`); + console.log( + ` import { createDatabases } from "./${outputDir}/${SDK_TITLE_LOWER}/index.js";`, + ); + console.log(""); + log(`Usage:`); + console.log(` import { Client } from '${NPM_PACKAGE_NAME}';`); + console.log( + ` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`, + ); + console.log(` const databases = createDatabases(client);`); + console.log(` const db = databases.from('your-database-id');`); + console.log(` await db.tableName.create({ ... });`); + } + } catch (err: any) { + error(`Failed to generate SDK: ${err.message}`); + process.exit(1); + } +}; + +export const generate = new Command("generate") + .description( + `Generate a type-safe SDK from your ${SDK_TITLE} project configuration`, + ) + .option( + "-o, --output ", + "Output directory for generated files (default: generated)", + "generated", + ) + .option( + "-l, --language ", + `Target language for SDK generation (supported: ${getSupportedLanguages().join(", ")})`, + ) + .action(actionRunner(generateAction)); diff --git a/templates/cli/lib/commands/generators/base.ts b/templates/cli/lib/commands/generators/base.ts new file mode 100644 index 0000000000..2d51c32828 --- /dev/null +++ b/templates/cli/lib/commands/generators/base.ts @@ -0,0 +1,91 @@ +import * as fs from "fs"; +import * as path from "path"; +import { ConfigType } from "../config.js"; +import { SDK_TITLE_LOWER } from "../../constants.js"; + +/** + * Supported programming languages for SDK generation. + * Add new languages here as they are implemented. + */ +export type SupportedLanguage = "typescript"; + +/** + * Result of the generation process. + * Each language generator should return files as a map of filename to content. + */ +export interface GenerateResult { + /** Map of relative file paths to their content */ + files: Map; +} + +/** + * Base interface for all language-specific database generators. + * Implement this interface to add support for new languages. + */ +export interface IDatabasesGenerator { + /** + * The language this generator produces code for. + */ + readonly language: SupportedLanguage; + + /** + * File extension for the generated files (e.g., "ts", "py", "go"). + */ + readonly fileExtension: string; + + /** + * Generate the SDK files from the configuration. + * @param config - The project configuration containing tables/collections + * @returns Promise resolving to the generated files + */ + generate(config: ConfigType): Promise; + + /** + * Write the generated files to disk. + * @param outputDir - The base output directory + * @param result - The generation result containing files to write + */ + writeFiles(outputDir: string, result: GenerateResult): Promise; + + /** + * Get the list of generated file paths (relative to output directory). + * Used for displaying success messages. + * @param result - The generation result + */ + getGeneratedFilePaths(result: GenerateResult): string[]; +} + +/** + * Abstract base class providing common functionality for database generators. + * Extend this class to implement language-specific generators. + */ +export abstract class BaseDatabasesGenerator implements IDatabasesGenerator { + abstract readonly language: SupportedLanguage; + abstract readonly fileExtension: string; + + abstract generate(config: ConfigType): Promise; + + async writeFiles(outputDir: string, result: GenerateResult): Promise { + const sdkDir = path.join(outputDir, SDK_TITLE_LOWER); + if (!fs.existsSync(sdkDir)) { + fs.mkdirSync(sdkDir, { recursive: true }); + } + + for (const [relativePath, content] of result.files) { + const filePath = path.join(sdkDir, relativePath); + const fileDir = path.dirname(filePath); + + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }); + } + + fs.writeFileSync(filePath, content, "utf-8"); + } + } + + getGeneratedFilePaths(result: GenerateResult): string[] { + return Array.from(result.files.keys()).map((relativePath) => + path.join(SDK_TITLE_LOWER, relativePath), + ); + } +} diff --git a/templates/cli/lib/commands/generators/index.ts b/templates/cli/lib/commands/generators/index.ts new file mode 100644 index 0000000000..8415b5db8a --- /dev/null +++ b/templates/cli/lib/commands/generators/index.ts @@ -0,0 +1,87 @@ +import { IDatabasesGenerator, SupportedLanguage } from "./base.js"; +import { + LanguageDetector, + LanguageDetectionResult, +} from "./language-detector.js"; +import { TypeScriptDatabasesGenerator } from "./typescript/databases.js"; + +export { + IDatabasesGenerator, + SupportedLanguage, + GenerateResult, +} from "./base.js"; +export { + LanguageDetector, + LanguageDetectionResult, +} from "./language-detector.js"; + +/** + * Factory function type for creating database generators. + */ +type GeneratorFactory = () => IDatabasesGenerator; + +/** + * Registry of generator factories by language. + * Add new language generators here as they are implemented. + */ +const generatorRegistry: Record = { + typescript: () => new TypeScriptDatabasesGenerator(), + // Future languages: + // python: () => new PythonDatabasesGenerator(), + // go: () => new GoDatabasesGenerator(), + // dart: () => new DartDatabasesGenerator(), +}; + +/** + * Create a database generator for the specified language. + * @param language - The target language + * @returns The appropriate generator instance + * @throws Error if the language is not supported + */ +export function createGenerator( + language: SupportedLanguage, +): IDatabasesGenerator { + const factory = generatorRegistry[language]; + if (!factory) { + throw new Error( + `No generator available for language: ${language}. ` + + `Supported languages: ${Object.keys(generatorRegistry).join(", ")}`, + ); + } + return factory(); +} + +/** + * Create a database generator by auto-detecting the project language. + * @param cwd - The working directory to detect language from (defaults to process.cwd()) + * @returns Object containing the generator and detection result + * @throws Error if no supported language is detected + */ +export function createGeneratorFromDetection(cwd?: string): { + generator: IDatabasesGenerator; + detection: LanguageDetectionResult; +} { + const detector = new LanguageDetector(cwd); + const detection = detector.detect(); + + if (!detection) { + const supported = LanguageDetector.getSupportedLanguages().join(", "); + throw new Error( + `Could not detect project language. ` + + `Supported languages: ${supported}. ` + + `Please ensure your project has the appropriate configuration files (e.g., package.json for TypeScript).`, + ); + } + + return { + generator: createGenerator(detection.language), + detection, + }; +} + +/** + * Get all supported languages. + */ +export function getSupportedLanguages(): SupportedLanguage[] { + return Object.keys(generatorRegistry) as SupportedLanguage[]; +} diff --git a/templates/cli/lib/commands/generators/language-detector.ts b/templates/cli/lib/commands/generators/language-detector.ts new file mode 100644 index 0000000000..d2562ca4c6 --- /dev/null +++ b/templates/cli/lib/commands/generators/language-detector.ts @@ -0,0 +1,163 @@ +import * as fs from "fs"; +import * as path from "path"; +import { SupportedLanguage } from "./base.js"; + +/** + * Language detection result with confidence level. + */ +export interface LanguageDetectionResult { + language: SupportedLanguage; + confidence: "high" | "medium" | "low"; + reason: string; +} + +/** + * Configuration for detecting a specific language. + */ +interface LanguageConfig { + language: SupportedLanguage; + /** Files that indicate this language with high confidence */ + primaryIndicators: string[]; + /** Files that indicate this language with medium confidence */ + secondaryIndicators: string[]; + /** File extensions to scan for */ + fileExtensions: string[]; +} + +/** + * Language detection configurations. + * Add new languages here as they are supported. + */ +const languageConfigs: LanguageConfig[] = [ + { + language: "typescript", + primaryIndicators: ["tsconfig.json", "package.json", "deno.json"], + secondaryIndicators: [ + ".nvmrc", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + ], + fileExtensions: [".ts", ".tsx", ".js", ".jsx"], + }, + // Future languages can be added here: + // { + // language: "python", + // primaryIndicators: ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"], + // secondaryIndicators: [".python-version", "poetry.lock"], + // fileExtensions: [".py"], + // }, + // { + // language: "go", + // primaryIndicators: ["go.mod"], + // secondaryIndicators: ["go.sum"], + // fileExtensions: [".go"], + // }, + // { + // language: "dart", + // primaryIndicators: ["pubspec.yaml"], + // secondaryIndicators: ["pubspec.lock"], + // fileExtensions: [".dart"], + // }, +]; + +/** + * Detects the programming language of a project based on its directory contents. + */ +export class LanguageDetector { + private cwd: string; + + constructor(cwd: string = process.cwd()) { + this.cwd = cwd; + } + + /** + * Detect the primary language of the project. + * @returns The detected language and confidence level, or null if no supported language detected + */ + detect(): LanguageDetectionResult | null { + for (const config of languageConfigs) { + const result = this.checkLanguage(config); + if (result) { + return result; + } + } + + return null; + } + + /** + * Check if a specific language is detected in the project. + */ + private checkLanguage( + config: LanguageConfig, + ): LanguageDetectionResult | null { + // Check primary indicators first + for (const indicator of config.primaryIndicators) { + if (this.fileExists(indicator)) { + return { + language: config.language, + confidence: "high", + reason: `Found ${indicator}`, + }; + } + } + + // Check secondary indicators + for (const indicator of config.secondaryIndicators) { + if (this.fileExists(indicator)) { + return { + language: config.language, + confidence: "medium", + reason: `Found ${indicator}`, + }; + } + } + + // Check for file extensions in the current directory + const hasMatchingFiles = this.hasFilesWithExtensions(config.fileExtensions); + if (hasMatchingFiles) { + return { + language: config.language, + confidence: "low", + reason: `Found files with extensions: ${config.fileExtensions.join(", ")}`, + }; + } + + return null; + } + + /** + * Check if a file exists in the project directory. + */ + private fileExists(filename: string): boolean { + return fs.existsSync(path.resolve(this.cwd, filename)); + } + + /** + * Check if files with specific extensions exist in the current directory. + */ + private hasFilesWithExtensions(extensions: string[]): boolean { + try { + const files = fs.readdirSync(this.cwd); + return files.some((file) => extensions.some((ext) => file.endsWith(ext))); + } catch { + return false; + } + } + + /** + * Get all supported languages. + */ + static getSupportedLanguages(): SupportedLanguage[] { + return languageConfigs.map((c) => c.language); + } + + /** + * Check if a language is supported. + */ + static isSupported(language: string): language is SupportedLanguage { + return languageConfigs.some((c) => c.language === language); + } +} diff --git a/templates/cli/lib/commands/generators/typescript/databases.ts b/templates/cli/lib/commands/generators/typescript/databases.ts new file mode 100644 index 0000000000..408524800f --- /dev/null +++ b/templates/cli/lib/commands/generators/typescript/databases.ts @@ -0,0 +1,590 @@ +import * as fs from "fs"; +import * as path from "path"; +import { z } from "zod"; +import { ConfigType, AttributeSchema } from "../../config.js"; +import { toPascalCase, sanitizeEnumKey } from "../../../utils.js"; +import { SDK_TITLE, EXECUTABLE_NAME } from "../../../constants.js"; +import { + BaseDatabasesGenerator, + GenerateResult, + SupportedLanguage, +} from "../base.js"; + +type Entity = + | NonNullable[number] + | NonNullable[number]; +type Entities = + | NonNullable + | NonNullable; + +/** + * TypeScript-specific database generator. + * Generates type-safe SDK files for TypeScript/JavaScript projects. + */ +export class TypeScriptDatabasesGenerator extends BaseDatabasesGenerator { + readonly language: SupportedLanguage = "typescript"; + readonly fileExtension = "ts"; + + private getType( + attribute: z.infer, + collections: NonNullable, + entityName: string, + ): string { + let type = ""; + + switch (attribute.type) { + case "string": + case "datetime": + type = "string"; + if (attribute.format === "enum") { + type = toPascalCase(entityName) + toPascalCase(attribute.key); + } + break; + case "integer": + case "double": + type = "number"; + break; + case "boolean": + type = "boolean"; + break; + case "relationship": { + // Handle both collections (relatedCollection) and tables (relatedTable) + const relatedId = attribute.relatedCollection ?? attribute.relatedTable; + const relatedEntity = collections.find( + (c) => c.$id === relatedId || c.name === relatedId, + ); + if (!relatedEntity) { + throw new Error(`Related entity with ID '${relatedId}' not found.`); + } + type = toPascalCase(relatedEntity.name); + if ( + (attribute.relationType === "oneToMany" && + attribute.side === "parent") || + (attribute.relationType === "manyToOne" && + attribute.side === "child") || + attribute.relationType === "manyToMany" + ) { + type = `${type}[]`; + } + break; + } + default: + throw new Error(`Unknown attribute type: ${attribute.type}`); + } + + if (attribute.array) { + type += "[]"; + } + + if (!attribute.required && attribute.default === null) { + type += " | null"; + } + + return type; + } + + private getFields( + entity: Entity, + ): z.infer[] | undefined { + return "columns" in entity + ? (entity as NonNullable[number]).columns + : (entity as NonNullable[number]).attributes; + } + + /** + * Checks if an entity has relationship columns. + * Used to disable bulk methods for tables with relationships. + * + * TODO: Remove this restriction when bulk operations support relationships. + * To enable bulk methods for all tables, simply return false here: + * return false; + */ + private hasRelationshipColumns(entity: Entity): boolean { + const fields = this.getFields(entity); + if (!fields) return false; + return fields.some((field) => field.type === "relationship"); + } + + private generateTableType(entity: Entity, entities: Entities): string { + const fields = this.getFields(entity); + if (!fields) return ""; + + const typeName = toPascalCase(entity.name); + const attributes = fields + .map( + (attr) => + ` ${JSON.stringify(attr.key)}${attr.required ? "" : "?"}: ${this.getType(attr, entities as any, entity.name)};`, + ) + .join("\n"); + + return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; + } + + private generateEnums(entities: Entities): string { + const enumTypes: string[] = []; + + for (const entity of entities) { + const fields = this.getFields(entity); + if (!fields) continue; + + for (const field of fields) { + if (field.format === "enum" && field.elements) { + const enumName = toPascalCase(entity.name) + toPascalCase(field.key); + const usedKeys = new Set(); + const enumValues = field.elements + .map((element: string, index: number) => { + let key = sanitizeEnumKey(element); + if (usedKeys.has(key)) { + let disambiguator = 1; + while (usedKeys.has(`${key}_${disambiguator}`)) { + disambiguator++; + } + key = `${key}_${disambiguator}`; + } + usedKeys.add(key); + const isLast = index === field.elements!.length - 1; + return ` ${key} = ${JSON.stringify(element)}${isLast ? "" : ","}`; + }) + .join("\n"); + + enumTypes.push(`export enum ${enumName} {\n${enumValues}\n}`); + } + } + } + + return enumTypes.join("\n\n"); + } + + private groupEntitiesByDb(entities: Entities): Map { + const entitiesByDb = new Map(); + for (const entity of entities) { + const dbId = entity.databaseId; + if (!entitiesByDb.has(dbId)) { + entitiesByDb.set(dbId, []); + } + entitiesByDb.get(dbId)!.push(entity); + } + return entitiesByDb; + } + + private getAppwriteDependency(): string { + const cwd = process.cwd(); + + if (fs.existsSync(path.resolve(cwd, "package.json"))) { + try { + const packageJsonRaw = fs.readFileSync( + path.resolve(cwd, "package.json"), + "utf-8", + ); + const packageJson = JSON.parse(packageJsonRaw); + const deps = packageJson.dependencies ?? {}; + + if (deps["@appwrite.io/console"]) { + return "@appwrite.io/console"; + } + if (deps["react-native-appwrite"]) { + return "react-native-appwrite"; + } + if (deps["appwrite"]) { + return "appwrite"; + } + if (deps["node-appwrite"]) { + return "node-appwrite"; + } + } catch { + // Fallback if package.json is invalid + } + } + + if (fs.existsSync(path.resolve(cwd, "deno.json"))) { + return "npm:node-appwrite"; + } + + return "appwrite"; + } + + private supportsBulkMethods(appwriteDep: string): boolean { + return ( + appwriteDep === "node-appwrite" || + appwriteDep === "npm:node-appwrite" || + appwriteDep === "@appwrite.io/console" + ); + } + + private generateQueryBuilderType(): string { + return `export type QueryValue = string | number | boolean; + +export type ExtractQueryValue = T extends (infer U)[] + ? U extends QueryValue ? U : never + : T extends QueryValue | null ? NonNullable : never; + +export type QueryableKeys = { + [K in keyof T]: ExtractQueryValue extends never ? never : K; +}[keyof T]; + +export type QueryBuilder = { + equal: >(field: K, value: ExtractQueryValue) => string; + notEqual: >(field: K, value: ExtractQueryValue) => string; + lessThan: >(field: K, value: ExtractQueryValue) => string; + lessThanEqual: >(field: K, value: ExtractQueryValue) => string; + greaterThan: >(field: K, value: ExtractQueryValue) => string; + greaterThanEqual: >(field: K, value: ExtractQueryValue) => string; + contains: >(field: K, value: ExtractQueryValue) => string; + search: >(field: K, value: string) => string; + isNull: >(field: K) => string; + isNotNull: >(field: K) => string; + startsWith: >(field: K, value: string) => string; + endsWith: >(field: K, value: string) => string; + between: >(field: K, start: ExtractQueryValue, end: ExtractQueryValue) => string; + select: (fields: K[]) => string; + orderAsc: (field: K) => string; + orderDesc: (field: K) => string; + limit: (value: number) => string; + offset: (value: number) => string; + cursorAfter: (documentId: string) => string; + cursorBefore: (documentId: string) => string; + or: (...queries: string[]) => string; + and: (...queries: string[]) => string; +}`; + } + + private generateDatabaseTablesType( + entitiesByDb: Map, + appwriteDep: string, + ): string { + const supportsBulk = this.supportsBulkMethods(appwriteDep); + const dbReturnTypes = Array.from(entitiesByDb.entries()) + .map(([dbId, dbEntities]) => { + const tableTypes = dbEntities + .map((entity) => { + const typeName = toPascalCase(entity.name); + const baseMethods = ` create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; + get: (id: string) => Promise<${typeName}>; + update: (id: string, data: Partial>, options?: { permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; + delete: (id: string, options?: { transactionId?: string }) => Promise; + list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; + + // Bulk methods not supported for tables with relationship columns (see hasRelationshipColumns) + const canUseBulkMethods = + supportsBulk && !this.hasRelationshipColumns(entity); + const bulkMethods = canUseBulkMethods + ? ` + createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + updateMany: (data: Partial>, options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + deleteMany: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>;` + : ""; + + return ` '${entity.name}': {\n${baseMethods}${bulkMethods}\n }`; + }) + .join(";\n"); + return ` '${dbId}': {\n${tableTypes}\n }`; + }) + .join(";\n"); + + return `export type DatabaseTables = {\n${dbReturnTypes}\n}`; + } + + private generateTypesFile(config: ConfigType): string { + const entities = config.tables?.length ? config.tables : config.collections; + + if (!entities || entities.length === 0) { + return "// No tables or collections found in configuration\n"; + } + + const appwriteDep = this.getAppwriteDependency(); + const enums = this.generateEnums(entities); + const types = entities + .map((entity: Entity) => this.generateTableType(entity, entities)) + .join("\n\n"); + const entitiesByDb = this.groupEntitiesByDb(entities); + const dbIds = Array.from(entitiesByDb.keys()); + const dbIdType = dbIds.map((id) => `'${id}'`).join(" | "); + + const parts = [ + `import { type Models, Permission } from '${appwriteDep}';`, + "", + ]; + + if (enums) { + parts.push(enums); + parts.push(""); + } + + parts.push(types); + parts.push(""); + parts.push(this.generateQueryBuilderType()); + parts.push(""); + parts.push(`export type DatabaseId = ${dbIdType};`); + parts.push(""); + parts.push(this.generateDatabaseTablesType(entitiesByDb, appwriteDep)); + parts.push(""); + + return parts.join("\n"); + } + + private generateQueryBuilder(): string { + return `const createQueryBuilder = (): QueryBuilder => ({ + equal: (field, value) => Query.equal(String(field), value as any), + notEqual: (field, value) => Query.notEqual(String(field), value as any), + lessThan: (field, value) => Query.lessThan(String(field), value as any), + lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), + greaterThan: (field, value) => Query.greaterThan(String(field), value as any), + greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), + contains: (field, value) => Query.contains(String(field), value as any), + search: (field, value) => Query.search(String(field), value), + isNull: (field) => Query.isNull(String(field)), + isNotNull: (field) => Query.isNotNull(String(field)), + startsWith: (field, value) => Query.startsWith(String(field), value), + endsWith: (field, value) => Query.endsWith(String(field), value), + between: (field, start, end) => Query.between(String(field), start as any, end as any), + select: (fields) => Query.select(fields.map(String)), + orderAsc: (field) => Query.orderAsc(String(field)), + orderDesc: (field) => Query.orderDesc(String(field)), + limit: (value) => Query.limit(value), + offset: (value) => Query.offset(value), + cursorAfter: (documentId) => Query.cursorAfter(documentId), + cursorBefore: (documentId) => Query.cursorBefore(documentId), + or: (...queries) => Query.or(queries), + and: (...queries) => Query.and(queries), +})`; + } + + private generateTableIdMap(entitiesByDb: Map): string { + const lines: string[] = [ + "const tableIdMap: Record> = Object.create(null);", + ]; + + for (const [dbId, dbEntities] of entitiesByDb.entries()) { + lines.push(`tableIdMap[${JSON.stringify(dbId)}] = Object.create(null);`); + for (const entity of dbEntities) { + lines.push( + `tableIdMap[${JSON.stringify(dbId)}][${JSON.stringify(entity.name)}] = ${JSON.stringify(entity.$id)};`, + ); + } + } + + return lines.join("\n"); + } + + private generateTablesWithRelationships( + entitiesByDb: Map, + ): string { + const tablesWithRelationships: string[] = []; + + for (const [dbId, dbEntities] of entitiesByDb.entries()) { + for (const entity of dbEntities) { + if (this.hasRelationshipColumns(entity)) { + tablesWithRelationships.push(`'${dbId}:${entity.name}'`); + } + } + } + + if (tablesWithRelationships.length === 0) { + return `const tablesWithRelationships = new Set()`; + } + + return `const tablesWithRelationships = new Set([${tablesWithRelationships.join(", ")}])`; + } + + private generateDatabasesFile(config: ConfigType): string { + const entities = config.tables?.length ? config.tables : config.collections; + + if (!entities || entities.length === 0) { + return "// No tables or collections found in configuration\n"; + } + + const entitiesByDb = this.groupEntitiesByDb(entities); + const appwriteDep = this.getAppwriteDependency(); + const supportsBulk = this.supportsBulkMethods(appwriteDep); + + const bulkMethodsCode = supportsBulk + ? ` + createMany: (rows: any[], options?: { transactionId?: string }) => + tablesDB.createRows({ + databaseId, + tableId, + rows, + transactionId: options?.transactionId, + }), + updateMany: (data: any, options?: { queries?: (q: any) => string[]; transactionId?: string }) => + tablesDB.updateRows({ + databaseId, + tableId, + data, + queries: options?.queries?.(createQueryBuilder()), + transactionId: options?.transactionId, + }), + deleteMany: (options?: { queries?: (q: any) => string[]; transactionId?: string }) => + tablesDB.deleteRows({ + databaseId, + tableId, + queries: options?.queries?.(createQueryBuilder()), + transactionId: options?.transactionId, + }),` + : ""; + + const hasBulkCheck = supportsBulk + ? `const hasBulkMethods = (dbId: string, tableName: string) => !tablesWithRelationships.has(\`\${dbId}:\${tableName}\`);` + : ""; + + return `import { Client, TablesDB, ID, Query, type Models, Permission } from '${appwriteDep}'; +import type { DatabaseId, DatabaseTables, QueryBuilder } from './types.js'; + +${this.generateQueryBuilder()}; + +${this.generateTableIdMap(entitiesByDb)}; + +${this.generateTablesWithRelationships(entitiesByDb)}; + +function createTableApi( + tablesDB: TablesDB, + databaseId: string, + tableId: string, +) { + return { + create: (data: any, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => + tablesDB.createRow({ + databaseId, + tableId, + rowId: options?.rowId ?? ID.unique(), + data, + permissions: options?.permissions?.map((p) => p.toString()), + transactionId: options?.transactionId, + }), + get: (id: string) => + tablesDB.getRow({ + databaseId, + tableId, + rowId: id, + }), + update: (id: string, data: any, options?: { permissions?: Permission[]; transactionId?: string }) => + tablesDB.updateRow({ + databaseId, + tableId, + rowId: id, + data, + ...(options?.permissions ? { permissions: options.permissions.map((p) => p.toString()) } : {}), + transactionId: options?.transactionId, + }), + delete: async (id: string, options?: { transactionId?: string }) => { + await tablesDB.deleteRow({ + databaseId, + tableId, + rowId: id, + transactionId: options?.transactionId, + }); + }, + list: (options?: { queries?: (q: any) => string[] }) => + tablesDB.listRows({ + databaseId, + tableId, + queries: options?.queries?.(createQueryBuilder()), + }),${bulkMethodsCode} + }; +} + +${hasBulkCheck} + +const hasOwn = (obj: unknown, key: string): boolean => + obj != null && Object.prototype.hasOwnProperty.call(obj, key); + +function createDatabaseProxy( + tablesDB: TablesDB, + databaseId: D, +): DatabaseTables[D] { + const tableApiCache = new Map>(); + const dbMap = tableIdMap[databaseId]; + + return new Proxy({} as DatabaseTables[D], { + get(_target, tableName: string) { + if (typeof tableName === 'symbol') return undefined; + + if (!tableApiCache.has(tableName)) { + if (!hasOwn(dbMap, tableName)) return undefined; + const tableId = dbMap[tableName]; + + const api = createTableApi(tablesDB, databaseId, tableId); + ${ + supportsBulk + ? ` + // Remove bulk methods for tables with relationships + if (!hasBulkMethods(databaseId, tableName)) { + delete (api as any).createMany; + delete (api as any).updateMany; + delete (api as any).deleteMany; + }` + : "" + } + tableApiCache.set(tableName, api); + } + return tableApiCache.get(tableName); + }, + has(_target, tableName: string) { + return typeof tableName === 'string' && hasOwn(dbMap, tableName); + }, + }); +} + +export const createDatabases = (client: Client) => { + const tablesDB = new TablesDB(client); + const dbCache = new Map(); + + return { + from: (databaseId: T): DatabaseTables[T] => { + if (!dbCache.has(databaseId)) { + dbCache.set(databaseId, createDatabaseProxy(tablesDB, databaseId)); + } + return dbCache.get(databaseId) as DatabaseTables[T]; + }, + }; +}; +`; + } + + private generateIndexFile(): string { + return `/** + * ${SDK_TITLE} Generated SDK + * + * This file is auto-generated. Do not edit manually. + * Re-run \`${EXECUTABLE_NAME} generate\` to regenerate. + */ + +export { createDatabases } from "./databases.js"; +export * from "./types.js"; +`; + } + + async generate(config: ConfigType): Promise { + if (!config.projectId) { + throw new Error("Project ID is required in configuration"); + } + + const files = new Map(); + + const hasEntities = + (config.tables && config.tables.length > 0) || + (config.collections && config.collections.length > 0); + + if (!hasEntities) { + console.log( + "No tables or collections found in configuration. Skipping database generation.", + ); + files.set( + "databases.ts", + "// No tables or collections found in configuration\n", + ); + files.set( + "types.ts", + "// No tables or collections found in configuration\n", + ); + files.set("index.ts", this.generateIndexFile()); + return { files }; + } + + files.set("types.ts", this.generateTypesFile(config)); + files.set("databases.ts", this.generateDatabasesFile(config)); + files.set("index.ts", this.generateIndexFile()); + + return { files }; + } +} diff --git a/templates/cli/lib/commands/pull.ts b/templates/cli/lib/commands/pull.ts index 55128694bb..4240f25b54 100644 --- a/templates/cli/lib/commands/pull.ts +++ b/templates/cli/lib/commands/pull.ts @@ -39,8 +39,8 @@ import { import type { ConfigType } from "./config.js"; import { DatabaseSchema, - TablesDBSchemaBase, - ColumnSchemaBase, + TableSchema, + ColumnSchema, BucketSchema, TopicSchema, } from "./config.js"; @@ -542,11 +542,11 @@ export class Pull { for (const table of tables) { // Filter columns to only include schema-defined fields const filteredColumns = table.columns?.map((col: any) => - filterBySchema(col, ColumnSchemaBase), + filterBySchema(col, ColumnSchema), ); allTables.push({ - ...filterBySchema(table, TablesDBSchemaBase), + ...filterBySchema(table, TableSchema), columns: filteredColumns || [], }); } diff --git a/templates/cli/lib/commands/schema.ts b/templates/cli/lib/commands/schema.ts index a76436df4d..10d4d7029e 100644 --- a/templates/cli/lib/commands/schema.ts +++ b/templates/cli/lib/commands/schema.ts @@ -7,7 +7,7 @@ import { parseWithBetterErrors } from "./utils/error-formatter.js"; import JSONbig from "json-bigint"; import * as fs from "fs"; import * as path from "path"; -import { Db } from "./db.js"; +import { TypeScriptDatabasesGenerator } from "./generators/typescript/databases.js"; const JSONBig = JSONbig({ useNativeBigInt: true }); @@ -17,7 +17,7 @@ export class Schema { private pullCommandSilent: Pull; - public db: Db; + public db: TypeScriptDatabasesGenerator; constructor({ projectClient, @@ -31,7 +31,7 @@ export class Schema { this.pullCommandSilent = new Pull(projectClient, consoleClient, true); - this.db = new Db(); + this.db = new TypeScriptDatabasesGenerator(); } /** diff --git a/templates/cli/lib/commands/utils/attributes.ts b/templates/cli/lib/commands/utils/attributes.ts index f83fb4bf52..91d2f1df26 100644 --- a/templates/cli/lib/commands/utils/attributes.ts +++ b/templates/cli/lib/commands/utils/attributes.ts @@ -480,6 +480,13 @@ export class Attributes { ); }; + /** + * Check if attribute is a child-side relationship + * Child-side relationships are auto-generated by Appwrite and should be skipped + */ + private isChildSideRelationship = (attribute: any): boolean => + attribute.type === "relationship" && attribute.side === "child"; + /** * Filter deleted and recreated attributes, * return list of attributes to create and whether any changes were made @@ -490,30 +497,42 @@ export class Attributes { collection: Collection, isIndex: boolean = false, ): Promise<{ attributes: any[]; hasChanges: boolean }> => { - const deleting = remoteAttributes + // Filter out child-side relationships from both local and remote attributes for comparison + // Child-side relationships are auto-generated by Appwrite when creating two-way relationships + // from the parent side, so we should not compare or try to create them directly + const filteredLocalAttributes = localAttributes.filter( + (attr) => !this.isChildSideRelationship(attr), + ); + let filteredRemoteAttributes = remoteAttributes.filter( + (attr) => !this.isChildSideRelationship(attr), + ); + + const deleting = filteredRemoteAttributes .filter( - (attribute) => !this.attributesContains(attribute, localAttributes), + (attribute) => + !this.attributesContains(attribute, filteredLocalAttributes), ) .map((attr) => this.generateChangesObject(attr, collection, false)); - const adding = localAttributes + const adding = filteredLocalAttributes .filter( - (attribute) => !this.attributesContains(attribute, remoteAttributes), + (attribute) => + !this.attributesContains(attribute, filteredRemoteAttributes), ) .map((attr) => this.generateChangesObject(attr, collection, true)); - const conflicts = remoteAttributes + const conflicts = filteredRemoteAttributes .map((attribute) => this.checkAttributeChanges( attribute, - this.attributesContains(attribute, localAttributes), + this.attributesContains(attribute, filteredLocalAttributes), collection, ), ) .filter((attribute) => attribute !== undefined) as AttributeChange[]; - const changes = remoteAttributes + const changes = filteredRemoteAttributes .map((attribute) => this.checkAttributeChanges( attribute, - this.attributesContains(attribute, localAttributes), + this.attributesContains(attribute, filteredLocalAttributes), collection, false, ), @@ -584,7 +603,7 @@ export class Attributes { this.deleteAttribute(collection, changed, isIndex), ), ); - remoteAttributes = remoteAttributes.filter( + filteredRemoteAttributes = filteredRemoteAttributes.filter( (attribute) => !this.attributesContains(attribute, changedAttributes), ); } @@ -631,8 +650,9 @@ export class Attributes { } } - const newAttributes = localAttributes.filter( - (attribute) => !this.attributesContains(attribute, remoteAttributes), + const newAttributes = filteredLocalAttributes.filter( + (attribute) => + !this.attributesContains(attribute, filteredRemoteAttributes), ); return { attributes: newAttributes, hasChanges: true }; }; diff --git a/templates/cli/lib/config.ts b/templates/cli/lib/config.ts index 9d303ff900..ae273a85d8 100644 --- a/templates/cli/lib/config.ts +++ b/templates/cli/lib/config.ts @@ -22,8 +22,8 @@ import { CollectionSchema, AttributeSchema, IndexSchema, - TablesDBSchemaBase, - ColumnSchemaBase, + TableSchema, + ColumnSchema, IndexTableSchema, TopicSchema, TeamSchema, @@ -60,12 +60,12 @@ const KeysSite = getSchemaKeys(SiteSchema); const KeysFunction = getSchemaKeys(FunctionSchema); const KeysDatabase = getSchemaKeys(DatabaseSchema); const KeysCollection = getSchemaKeys(CollectionSchema); -const KeysTable = getSchemaKeys(TablesDBSchemaBase); +const KeysTable = getSchemaKeys(TableSchema); const KeysStorage = getSchemaKeys(BucketSchema); const KeysTopics = getSchemaKeys(TopicSchema); const KeysTeams = getSchemaKeys(TeamSchema); const KeysAttributes = getSchemaKeys(AttributeSchema); -const KeysColumns = getSchemaKeys(ColumnSchemaBase); +const KeysColumns = getSchemaKeys(ColumnSchema); const KeyIndexes = getSchemaKeys(IndexSchema); const KeyIndexesColumns = getSchemaKeys(IndexTableSchema); diff --git a/templates/cli/lib/utils.ts b/templates/cli/lib/utils.ts index 77ed773b6f..86351abb66 100644 --- a/templates/cli/lib/utils.ts +++ b/templates/cli/lib/utils.ts @@ -184,3 +184,35 @@ export function filterBySchema>( return result as z.infer; } + +export function toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) + .replace(/^(.)/, (char) => char.toUpperCase()); +} + +export function toUpperSnakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toUpperCase(); +} + +export function sanitizeEnumKey(key: string): string { + let sanitized = toUpperSnakeCase(key) + .replace(/[^A-Z0-9_]/gi, "_") // Replace non-alphanumeric with underscores + .replace(/_+/g, "_") // Collapse consecutive underscores + .replace(/^_+|_+$/g, ""); // Trim leading/trailing underscores + + // Prefix with underscore if starts with a digit + if (/^[0-9]/.test(sanitized)) { + sanitized = "_" + sanitized; + } + + // Fallback if empty after sanitization + if (!sanitized) { + sanitized = "_VALUE"; + } + + return sanitized; +}