UNPKG

@wearesage/schema

Version:

A flexible schema definition and validation system for TypeScript with multi-database support

563 lines (480 loc) 16.6 kB
import { Type } from "../core/types"; import { DatabaseAdapter } from "./interface"; import { MetadataRegistry } from "../core/MetadataRegistry"; import { SchemaReflector } from "../core/SchemaReflector"; /** * SQLite-specific table decorator */ export function Table(name: string) { return function (target: any) { Reflect.defineMetadata("sqlite:table", name, target); }; } /** * SQLite-specific primary key decorator */ export function PrimaryKey() { return function (target: any, propertyKey: string) { Reflect.defineMetadata( "sqlite:primaryKey", propertyKey, target.constructor ); }; } /** * SQLite-specific index decorator */ export function Index(name?: string, unique = false) { return function (target: any, propertyKey: string) { const indexes = Reflect.getMetadata("sqlite:indexes", target.constructor) || []; indexes.push({ name: name || `idx_${propertyKey}`, column: propertyKey, unique, }); Reflect.defineMetadata("sqlite:indexes", indexes, target.constructor); }; } /** * SQLite-specific column decorator */ export function Column(options: { name?: string; type?: "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL"; nullable?: boolean; defaultValue?: any; }) { return function (target: any, propertyKey: string) { const columns = Reflect.getMetadata("sqlite:columns", target.constructor) || {}; columns[propertyKey] = options; Reflect.defineMetadata("sqlite:columns", columns, target.constructor); }; } /** * SQLite-specific foreign key decorator */ export function ForeignKey(options: { references: string; column: string; onDelete?: "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION"; onUpdate?: "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION"; }) { return function (target: any, propertyKey: string) { const foreignKeys = Reflect.getMetadata("sqlite:foreignKeys", target.constructor) || []; foreignKeys.push({ column: propertyKey, ...options, }); Reflect.defineMetadata( "sqlite:foreignKeys", foreignKeys, target.constructor ); }; } /** * Shorthand decorator for SQLite entities */ export function SQLite<T extends { new (...args: any[]): any }>(target: T): T { return DatabaseAdapter("SQLite")(target); } /** * Implementation of DatabaseAdapter for SQLite */ export class SQLiteAdapter implements DatabaseAdapter { readonly type = "SQLite"; private registry: MetadataRegistry; private reflector = new SchemaReflector(); /** * Constructor for SQLite adapter * @param registry The metadata registry to use for schema information * @param databasePath Path to the SQLite database file */ constructor(registry: MetadataRegistry, private databasePath: string) { this.registry = registry; } /** * Get the table name for an entity type */ private getTableName<T>(entityType: Type<T>): string { // Get custom table name if defined, otherwise use entity name const tableName = Reflect.getMetadata("sqlite:table", entityType); if (tableName) { return tableName; } // Use entity name in snake_case return this.toSnakeCase(entityType.name); } /** * Get primary key column for an entity type */ private getPrimaryKey<T>(entityType: Type<T>): string { const primaryKey = Reflect.getMetadata("sqlite:primaryKey", entityType); if (primaryKey) { return primaryKey; } // Default to 'id' if not specified return "id"; } /** * Get column configurations for an entity type */ private getColumns<T>(entityType: Type<T>): Record<string, any> { return Reflect.getMetadata("sqlite:columns", entityType) || {}; } /** * Get index configurations for an entity type */ private getIndexes<T>(entityType: Type<T>): any[] { return Reflect.getMetadata("sqlite:indexes", entityType) || []; } /** * Get foreign key configurations for an entity type */ private getForeignKeys<T>(entityType: Type<T>): any[] { return Reflect.getMetadata("sqlite:foreignKeys", entityType) || []; } /** * Convert entity to SQLite row */ private entityToRow<T>(entity: T): Record<string, any> { const entityType = entity.constructor as Type<T>; const entitySchema = this.reflector.getEntitySchema(entityType); const columns = this.getColumns(entityType); const row: Record<string, any> = {}; // Convert each property to column value for (const [key, meta] of Object.entries(entitySchema.properties)) { if ((entity as any)[key] !== undefined) { const columnConfig = columns[key]; const columnName = columnConfig?.name || this.toSnakeCase(key); let value = (entity as any)[key]; // Handle date objects if (value instanceof Date) { value = value.toISOString(); } // Handle objects (convert to JSON string) if (typeof value === "object" && value !== null) { value = JSON.stringify(value); } row[columnName] = value; } } return row; } /** * Convert SQLite row to entity instance */ private rowToEntity<T>(entityType: Type<T>, row: Record<string, any>): T { const entity = new entityType(); const entitySchema = this.reflector.getEntitySchema(entityType); const columns = this.getColumns(entityType); // Create a map of column names to property names const columnNameMap: Record<string, string> = {}; for (const [key, config] of Object.entries(columns)) { if (config.name) { columnNameMap[config.name] = key; } else { columnNameMap[this.toSnakeCase(key)] = key; } } // Convert each column to entity property for (const [colName, value] of Object.entries(row)) { // Find the property name for this column const propName = columnNameMap[colName] || this.toCamelCase(colName); // Only set if the property exists in the schema if (entitySchema.properties[propName]) { let propValue = value; // Handle string to object conversion const propType = entitySchema.properties[propName].type; if (propType === "object" && typeof value === "string") { try { propValue = JSON.parse(value); } catch (e) { // If parsing fails, use the original value } } // Handle string to date conversion if (propType === "date" && typeof value === "string") { propValue = new Date(value); } (entity as any)[propName] = propValue; } } return entity; } /** * Convert string to snake_case */ private toSnakeCase(str: string): string { return str .replace(/([A-Z])/g, "_$1") .toLowerCase() .replace(/^_/, ""); } /** * Convert string to camelCase */ private toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } /** * Build a SQL WHERE clause from criteria */ private buildWhereClause(criteria: Record<string, any>): { clause: string; params: any[]; } { const conditions: string[] = []; const params: any[] = []; for (const [key, value] of Object.entries(criteria)) { if (value === null) { conditions.push(`${this.toSnakeCase(key)} IS NULL`); } else { conditions.push(`${this.toSnakeCase(key)} = ?`); params.push(value); } } return { clause: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", params, }; } /** * Query for a single entity by criteria */ async query<T>(entityType: Type<T>, criteria: object): Promise<T | null> { const tableName = this.getTableName(entityType); console.log( `[SQLiteAdapter] Querying ${tableName} with criteria:`, criteria ); const { clause, params } = this.buildWhereClause( criteria as Record<string, any> ); const sql = `SELECT * FROM ${tableName} ${clause} LIMIT 1`; console.log(`[SQLiteAdapter] SQL query: ${sql}`, params); // In a real implementation, this would execute a SQL query // const db = new SQLite3(this.databasePath); // const row = await db.get(sql, ...params); // return row ? this.rowToEntity(entityType, row) : null; // Mock implementation const result = await this.mockQueryExecution<T>(entityType, criteria); return result; } /** * Query for multiple entities by criteria */ async queryMany<T>(entityType: Type<T>, criteria: object): Promise<T[]> { const tableName = this.getTableName(entityType); console.log( `[SQLiteAdapter] Querying for multiple rows in ${tableName} with criteria:`, criteria ); const { clause, params } = this.buildWhereClause( criteria as Record<string, any> ); const sql = `SELECT * FROM ${tableName} ${clause}`; console.log(`[SQLiteAdapter] SQL query: ${sql}`, params); // In a real implementation, this would execute a SQL query // const db = new SQLite3(this.databasePath); // const rows = await db.all(sql, ...params); // return rows.map(row => this.rowToEntity(entityType, row)); // Mock implementation return []; } /** * Save an entity to SQLite */ async save<T extends object>(entity: T): Promise<void> { const entityType = entity.constructor as Type<T>; const tableName = this.getTableName(entityType); // Validate the entity before saving const validation = this.reflector.validateEntity(entity); if (!validation.valid) { throw new Error(`Invalid entity: ${validation.errors.join(", ")}`); } // Convert entity to row const row = this.entityToRow(entity); console.log(`[SQLiteAdapter] Saving row to table ${tableName}:`, row); // Get primary key const primaryKey = this.getPrimaryKey(entityType); const primaryKeyCol = this.toSnakeCase(primaryKey); // Determine if this is an insert or update let sql: string; let params: any[]; if (row[primaryKeyCol] === undefined) { // Insert - exclude primary key if it's undefined (autoincrement) const cols = Object.keys(row).filter( (col) => col !== primaryKeyCol || row[primaryKeyCol] !== undefined ); const placeholders = cols.map(() => "?").join(", "); sql = `INSERT INTO ${tableName} (${cols.join( ", " )}) VALUES (${placeholders})`; params = cols.map((col) => row[col]); } else { // Update const setClauses = Object.keys(row) .filter((col) => col !== primaryKeyCol) .map((col) => `${col} = ?`) .join(", "); sql = `UPDATE ${tableName} SET ${setClauses} WHERE ${primaryKeyCol} = ?`; params = [ ...Object.keys(row) .filter((col) => col !== primaryKeyCol) .map((col) => row[col]), row[primaryKeyCol], ]; } console.log(`[SQLiteAdapter] SQL: ${sql}`, params); // In a real implementation, this would execute the SQL // const db = new SQLite3(this.databasePath); // await db.run(sql, ...params); } /** * Delete an entity from SQLite */ async delete<T>(entityType: Type<T>, id: string | number): Promise<void> { const tableName = this.getTableName(entityType); const primaryKey = this.getPrimaryKey(entityType); const primaryKeyCol = this.toSnakeCase(primaryKey); console.log( `[SQLiteAdapter] Deleting from ${tableName} where ${primaryKeyCol} = ${id}` ); const sql = `DELETE FROM ${tableName} WHERE ${primaryKeyCol} = ?`; // In a real implementation, this would execute the SQL // const db = new SQLite3(this.databasePath); // await db.run(sql, id); } /** * Execute a raw SQL query */ async runNativeQuery<T>(query: string, params?: any[]): Promise<T> { console.log(`[SQLiteAdapter] Running SQL query: ${query}`); console.log(`[SQLiteAdapter] Params:`, params); // In a real implementation, this would execute the SQL // const db = new SQLite3(this.databasePath); // // if (query.trim().toUpperCase().startsWith('SELECT')) { // return db.all(query, ...(params || [])) as T; // } else { // return db.run(query, ...(params || [])) as T; // } return {} as T; // Mock result } /** * Create schema for an entity type * This would typically be called during setup to create tables */ async createSchema<T>(entityType: Type<T>): Promise<void> { const tableName = this.getTableName(entityType); const entitySchema = this.reflector.getEntitySchema(entityType); const columns = this.getColumns(entityType); const primaryKey = this.getPrimaryKey(entityType); const foreignKeys = this.getForeignKeys(entityType); // Build column definitions const columnDefs: string[] = []; // Add column definitions for each property for (const [propName, propMeta] of Object.entries( entitySchema.properties )) { const columnConfig = columns[propName] || {}; const columnName = columnConfig.name || this.toSnakeCase(propName); let sqlType = columnConfig.type; // Derive SQL type from property type if not specified if (!sqlType) { if ((propMeta as any).type === "string") { sqlType = "TEXT"; } else if ((propMeta as any).type === "number") { sqlType = "REAL"; } else if ((propMeta as any).type === "boolean") { sqlType = "INTEGER"; // SQLite doesn't have a boolean type } else if ((propMeta as any).type === "date") { sqlType = "TEXT"; // Store dates as ISO strings } else { sqlType = "TEXT"; // Default to TEXT for objects (stored as JSON) } } // Build column definition let columnDef = `${columnName} ${sqlType}`; // Add primary key constraint if (propName === primaryKey) { columnDef += " PRIMARY KEY"; if (sqlType === "INTEGER") { columnDef += " AUTOINCREMENT"; } } // Add nullable constraint if (columnConfig.nullable === false) { columnDef += " NOT NULL"; } // Add default value if (columnConfig.defaultValue !== undefined) { let defaultValue = columnConfig.defaultValue; if (typeof defaultValue === "string") { defaultValue = `'${defaultValue}'`; } columnDef += ` DEFAULT ${defaultValue}`; } columnDefs.push(columnDef); } // Add foreign key constraints for (const fk of foreignKeys) { const columnName = this.toSnakeCase(fk.column); let constraint = `FOREIGN KEY (${columnName}) REFERENCES ${fk.references}(${fk.column})`; if (fk.onDelete) { constraint += ` ON DELETE ${fk.onDelete}`; } if (fk.onUpdate) { constraint += ` ON UPDATE ${fk.onUpdate}`; } columnDefs.push(constraint); } const createTableSQL = ` CREATE TABLE IF NOT EXISTS ${tableName} ( ${columnDefs.join(",\n ")} ) `; console.log(`[SQLiteAdapter] Creating table with SQL:`, createTableSQL); // In a real implementation, this would execute the SQL // const db = new SQLite3(this.databasePath); // await db.run(createTableSQL); // Create indexes const indexes = this.getIndexes(entityType); for (const index of indexes) { const createIndexSQL = ` CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${index.name} ON ${tableName} (${this.toSnakeCase(index.column)}) `; console.log(`[SQLiteAdapter] Creating index with SQL:`, createIndexSQL); // In a real implementation, this would execute the SQL // await db.run(createIndexSQL); } } /** * Mock method to simulate database query execution * In a real implementation, this would use the SQLite library */ private async mockQueryExecution<T>( entityType: Type<T>, criteria: object ): Promise<T | null> { // Just a mock implementation for demonstration const primaryKey = this.getPrimaryKey(entityType); const criteriaObj = criteria as Record<string, any>; if (criteriaObj.hasOwnProperty(primaryKey) && criteriaObj[primaryKey] === "123") { const entity = new entityType(); Object.assign(entity, { id: "123", name: "Mock SQLite Entity", createdAt: new Date().toISOString(), }); return entity; } return null; } }