UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

534 lines (477 loc) 15.4 kB
import { FieldType } from "./core"; import { KrapiError } from "./core/krapi-error"; import { ExpectedSchema, TableDefinition, FieldDefinition, IndexDefinition, ConstraintDefinition, RelationDefinition, } from "./types"; /** * Schema Generator * * Automatically generates SQLite schema definitions from TypeScript interfaces. * This ensures the database schema always matches the current code structure. * * @class SchemaGenerator * @example * const generator = new SchemaGenerator(interfaces, { * defaultStringLength: 255, * generateIndexes: true * }); * const schema = generator.generateSchema(); */ export class SchemaGenerator { private typeMapping: Record<string, string> = { string: "VARCHAR(255)", number: "INTEGER", boolean: "BOOLEAN", Date: "TIMESTAMP", "string | undefined": "VARCHAR(255)", "number | undefined": "INTEGER", "boolean | undefined": "BOOLEAN", "Date | undefined": "TIMESTAMP", // SQLite uses JSON, not JSONB (JSONB is PostgreSQL-specific) "Record<string, unknown>": "JSON", "Record<string, any>": "JSON", "unknown[]": "JSON", "any[]": "JSON", }; constructor( private interfaces: Record<string, unknown> = {}, private options: SchemaGeneratorOptions = {} ) { this.options = { defaultStringLength: 255, defaultDecimalPrecision: 10, defaultDecimalScale: 2, generateIndexes: true, generateConstraints: true, ...options, }; } /** * Generate complete database schema from TypeScript interfaces */ generateSchema(): ExpectedSchema { const tables: TableDefinition[] = []; // Process each interface to generate table definitions for (const [interfaceName, interfaceDef] of Object.entries( this.interfaces )) { if (this.shouldGenerateTable(interfaceName, interfaceDef)) { const tableName = this.getTableName(interfaceName); const tableDef = this.generateTableDefinition( interfaceName, interfaceDef ); tables.push({ name: tableName, ...tableDef, }); } } // Generate relations between tables this.generateRelations(tables); return { tables, version: this.getSchemaVersion(), }; } /** * Generate table definition from a TypeScript interface */ private generateTableDefinition( interfaceName: string, interfaceDef: unknown ): Omit<TableDefinition, "name"> { const fields: FieldDefinition[] = []; const indexes: IndexDefinition[] = []; const constraints: ConstraintDefinition[] = []; // Process interface properties for (const [propertyName, propertyDef] of Object.entries( interfaceDef as Record<string, unknown> )) { if (this.shouldGenerateField(propertyName, propertyDef)) { const fieldName = this.getFieldName(propertyName); const fieldDef = this.generateFieldDefinition( propertyName, propertyDef ); fieldDef.name = fieldName; fields.push(fieldDef); // Generate indexes for certain field types if (this.options.generateIndexes) { const fieldIndexes = this.generateFieldIndexes( interfaceName, fieldName, propertyDef ); indexes.push(...fieldIndexes); } } } // Generate constraints if (this.options.generateConstraints) { const tableConstraints = this.generateTableConstraints( interfaceName, fields ); constraints.push(...tableConstraints); } return { fields, indexes, constraints, relations: [], }; } /** * Generate field definition from a TypeScript property */ private generateFieldDefinition( propertyName: string, propertyDef: unknown ): FieldDefinition { const type = this.mapTypeScriptType( (propertyDef as { type?: unknown }).type || propertyDef ); const isOptional = this.isOptionalProperty(propertyDef); const isPrimary = this.isPrimaryKey(propertyName); const isUnique = this.isUniqueField(propertyName, propertyDef); let fieldType = type; let length: number | undefined; let precision: number | undefined; let scale: number | undefined; let defaultValue: string | undefined; // Handle specific field types if (type === "VARCHAR" && this.options.defaultStringLength) { length = this.options.defaultStringLength; fieldType = `VARCHAR(${length})`; } else if (type === "DECIMAL") { precision = this.options.defaultDecimalPrecision; scale = this.options.defaultDecimalScale; fieldType = `DECIMAL(${precision}, ${scale})`; } else if (type === "TIMESTAMP" && propertyName === "created_at") { defaultValue = "CURRENT_TIMESTAMP"; } else if (type === "TIMESTAMP" && propertyName === "updated_at") { defaultValue = "CURRENT_TIMESTAMP"; } const fieldDef: FieldDefinition = { name: propertyName, type: this.mapFieldType(fieldType), required: !isOptional, nullable: isOptional, primary: isPrimary, unique: isUnique, }; if (defaultValue !== undefined) { fieldDef.default = defaultValue; } if (length !== undefined) { fieldDef.length = length; } if (precision !== undefined) { fieldDef.precision = precision; } if (scale !== undefined) { fieldDef.scale = scale; } return fieldDef; } /** * Generate indexes for a field */ private generateFieldIndexes( tableName: string, fieldName: string, propertyDef: unknown ): IndexDefinition[] { const indexes: IndexDefinition[] = []; // Primary key index if (this.isPrimaryKey(fieldName)) { indexes.push({ name: `${tableName}_pkey`, fields: [fieldName], unique: true, type: "btree", }); } // Unique field index if (this.isUniqueField(fieldName, propertyDef)) { indexes.push({ name: `idx_${tableName}_${fieldName}_unique`, fields: [fieldName], unique: true, type: "btree", }); } // Foreign key index if (this.isForeignKey(fieldName)) { indexes.push({ name: `idx_${tableName}_${fieldName}_fk`, fields: [fieldName], unique: false, type: "btree", }); } // Searchable field index if (this.isSearchableField(fieldName, propertyDef)) { indexes.push({ name: `idx_${tableName}_${fieldName}_search`, fields: [fieldName], unique: false, type: "btree", }); } return indexes; } /** * Generate table constraints */ private generateTableConstraints( tableName: string, fields: FieldDefinition[] ): ConstraintDefinition[] { const constraints: ConstraintDefinition[] = []; // Primary key constraint const primaryKeyFields = fields .filter((field) => field.primary) .map((field) => field.name); if (primaryKeyFields.length > 0) { constraints.push({ name: `${tableName}_pkey`, type: "primary_key", fields: primaryKeyFields, }); } // Unique constraints const uniqueFields = Object.entries(fields) .filter(([_, field]) => field.unique) .map(([name, _]) => name); for (const fieldName of uniqueFields) { constraints.push({ name: `${tableName}_${fieldName}_unique`, type: "unique", fields: [fieldName], }); } // Not null constraints const notNullFields = Object.entries(fields) .filter(([_, field]) => !field.nullable) .map(([name, _]) => name); for (const fieldName of notNullFields) { constraints.push({ name: `${tableName}_${fieldName}_not_null`, type: "not_null", fields: [fieldName], }); } return constraints; } /** * Generate relations between tables */ private generateRelations(tables: TableDefinition[]): void { for (const tableDef of tables) { const relations: RelationDefinition[] = []; for (const fieldDef of tableDef.fields) { if (this.isForeignKey(fieldDef.name)) { const targetTable = this.getTargetTable(fieldDef.name); if (targetTable && tables.find((t) => t.name === targetTable)) { relations.push({ name: `${tableDef.name}_${fieldDef.name}_fk`, type: "many-to-one", target_table: targetTable, source_field: fieldDef.name, target_field: "id", cascade_delete: false, }); } } } tableDef.relations = relations; } } // Helper methods private shouldGenerateTable( interfaceName: string, _interfaceDef: unknown ): boolean { // Skip internal interfaces and utility types const skipPatterns = [ "ApiResponse", "PaginatedResponse", "QueryOptions", "FilterCondition", ]; return !skipPatterns.some((pattern) => interfaceName.includes(pattern)); } private shouldGenerateField( propertyName: string, _propertyDef: unknown ): boolean { // Skip internal properties and methods const skipProperties = ["constructor", "prototype", "__proto__"]; return !skipProperties.includes(propertyName); } private getTableName(interfaceName: string): string { // Convert interface name to table name (e.g., AdminUser -> admin_users) return interfaceName .replace(/([A-Z])/g, "_$1") .toLowerCase() .replace(/^_/, "") .replace(/_+/g, "_"); } private getFieldName(propertyName: string): string { // Convert property name to field name (e.g., firstName -> first_name) return propertyName .replace(/([A-Z])/g, "_$1") .toLowerCase() .replace(/^_/, "") .replace(/_+/g, "_"); } private mapTypeScriptType(type: unknown): string { if (typeof type === "string") { return this.typeMapping[type] || "VARCHAR(255)"; } // Handle union types if (Array.isArray(type)) { const baseType = type[0]; return this.mapTypeScriptType(baseType); } // Handle object types if (typeof type === "object" && type !== null) { if ((type as { type?: unknown }).type) { return this.mapTypeScriptType((type as { type: unknown }).type); } } return "VARCHAR(255)"; } private mapFieldType(type: string): FieldType { // Map SQL types to FieldType enum values if (type.startsWith("VARCHAR")) return FieldType.varchar; if (type.startsWith("DECIMAL")) return FieldType.decimal; if (type === "TIMESTAMP") return FieldType.timestamp; if (type === "INTEGER") return FieldType.integer; if (type === "BOOLEAN") return FieldType.boolean; if (type === "TEXT") return FieldType.text; if (type === "UUID") return FieldType.uuid; if (type === "JSON") return FieldType.json; if (type === "ARRAY") return FieldType.array; if (type === "OBJECT") return FieldType.object; if (type === "FILE") return FieldType.file; if (type === "IMAGE") return FieldType.image; if (type === "VIDEO") return FieldType.video; if (type === "AUDIO") return FieldType.audio; if (type === "REFERENCE") return FieldType.reference; if (type === "RELATION") return FieldType.relation; if (type === "ENUM") return FieldType.enum; if (type === "PASSWORD") return FieldType.password; if (type === "ENCRYPTED") return FieldType.encrypted; if (type === "EMAIL") return FieldType.email; if (type === "URL") return FieldType.url; if (type === "PHONE") return FieldType.phone; if (type === "UNIQUEID") return FieldType.uniqueID; if (type === "DATE") return FieldType.date; if (type === "DATETIME") return FieldType.datetime; if (type === "TIME") return FieldType.time; if (type === "NUMBER") return FieldType.number; if (type === "FLOAT") return FieldType.float; if (type === "STRING") return FieldType.string; return FieldType.string; // Default fallback } private isOptionalProperty(propertyDef: unknown): boolean { if (typeof propertyDef === "object" && propertyDef !== null) { return ( (propertyDef as { optional?: boolean }).optional === true || (propertyDef as { required?: boolean }).required === false ); } return false; } private isPrimaryKey(propertyName: string): boolean { return propertyName === "id" || propertyName.endsWith("_id"); } private isUniqueField(propertyName: string, _propertyDef: unknown): boolean { const uniquePatterns = ["email", "username", "key", "token", "uuid"]; return uniquePatterns.some((pattern) => propertyName.includes(pattern)); } private isForeignKey(propertyName: string): boolean { return propertyName.endsWith("_id") && propertyName !== "id"; } private isSearchableField( propertyName: string, _propertyDef: unknown ): boolean { const searchablePatterns = [ "name", "title", "description", "content", "text", ]; return searchablePatterns.some((pattern) => propertyName.includes(pattern)); } private getTargetTable(fieldName: string): string | null { // Extract table name from foreign key field (e.g., user_id -> users) const tableName = fieldName.replace(/_id$/, ""); return `${tableName}s`; // Simple pluralization } private getSchemaVersion(): string { return "1.0.0"; } // Checksum generation available for schema validation // @ts-expect-error - Method reserved for future use private _generateChecksum(schema: Record<string, TableDefinition>): string { // Simple checksum generation for schema validation const schemaString = JSON.stringify(schema, Object.keys(schema).sort()); let hash = 0; for (let i = 0; i < schemaString.length; i++) { const char = schemaString.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(16); } /** * Load interfaces from a module or file */ static loadInterfacesFromModule( _modulePath: string ): Record<string, unknown> { // This would dynamically load interfaces from a module // For now, return an empty object return {}; } /** * Generate schema from a specific interface */ static generateFromInterface( interfaceName: string, interfaceDef: unknown ): TableDefinition { const generator = new SchemaGenerator({ [interfaceName]: interfaceDef }); const schema = generator.generateSchema(); const tableName = generator.getTableName(interfaceName); const table = schema.tables.find((t) => t.name === tableName); if (!table) { throw KrapiError.notFound(`Table '${tableName}' not found in schema`, { tableName, operation: "getTableDefinition", interfaceName }); } return table; } } // Configuration options for schema generation interface SchemaGeneratorOptions { defaultStringLength?: number; defaultDecimalPrecision?: number; defaultDecimalScale?: number; generateIndexes?: boolean; generateConstraints?: boolean; generateRelations?: boolean; }