UNPKG

@adonisjs/lucid

Version:

SQL ORM built on top of Active Record pattern

217 lines (216 loc) 8.68 kB
/* * @adonisjs/lucid * * (c) Harminder Virk <virk@adonisjs.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import merge from 'deepmerge'; import { ImportsBag } from '@poppinss/utils'; import stringHelpers from '@adonisjs/core/helpers/string'; import { DEFAULT_SCHEMA_RULES } from "./rules.js"; import { DATA_TYPES_MAPPING, INTERNAL_TYPES } from "./mappings.js"; /** * OrmSchemaBuilder handles the core logic of generating TypeScript * model schemas from database table structures. */ export class OrmSchemaBuilder { client; /** * Schema rules for type mapping and customization */ schema = DEFAULT_SCHEMA_RULES; /** * Mapping from database types to internal type identifiers */ dataTypes = DATA_TYPES_MAPPING; constructor(client) { this.client = client; } /** * Load user-defined schema rules */ loadRules(rules) { this.schema = merge.all([this.schema, ...rules], { arrayMerge: (_target, source) => source, isMergeableObject: (value) => { if (!value || typeof value !== 'object') return false; /** * ColumnInfo objects (identified by having `tsType`) should be * replaced entirely, not deep merged. This prevents default * decorators/imports from leaking into user-defined rules. */ if ('tsType' in value) return false; const tag = Object.prototype.toString.call(value); return tag !== '[object RegExp]' && tag !== '[object Date]'; }, }); } /** * Serialize a decorator argument value to a TypeScript-safe string */ serializeArgValue(value) { if (value === null) { return 'null'; } if (typeof value === 'string') { return `'${value}'`; } return String(value); } /** * Build a decorator string from a structured DecoratorInfo */ buildDecorator(decorator) { if (!decorator.args || Object.keys(decorator.args).length === 0) { return `${decorator.name}()`; } const argEntries = Object.entries(decorator.args) .map(([key, value]) => `${key}: ${this.serializeArgValue(value)}`) .join(', '); return `${decorator.name}({ ${argEntries} })`; } /** * Build decorator strings from a ColumnInfo. Supports both the * new `decorators` array and the deprecated `decorator` string. */ buildDecorators(rule, extraColumnArgs) { /** * New structured path: build from decorators array */ if (rule.decorators) { return rule.decorators .map((dec) => { /** * Merge extra column args (like columnName) into decorators * that start with `@column` */ if (extraColumnArgs && dec.name.startsWith('@column')) { return this.buildDecorator({ name: dec.name, args: { ...extraColumnArgs, ...dec.args }, }); } return this.buildDecorator(dec); }) .join('\n '); } /** * Deprecated path: use the decorator string as-is */ return rule.decorator ?? '@column()'; } /** * Generate schema for a single column */ generateColumnSchema(columnName, column, tableName, primaryKeys, primaryKeyColumnInfo) { const dialectColumnType = `${this.client.dialect.name}.${column.type}`; // Map database type to internal type identifier // If no mapping exists, use the database type name itself const internalType = this.dataTypes[dialectColumnType] ?? this.dataTypes[column.type] ?? column.type; // For unknown internal types, allow users to define custom rules using the database type name const typeLookupKey = internalType === INTERNAL_TYPES.UNKNOWN ? column.type : internalType; // Lookup hierarchy (most specific to least specific): // 1. Table-specific column // 2. Table-specific type // 3. Global column name // 4. Primary key rule (when this column is the primary key) // 5. Global type const ruleDef = this.schema.tables[tableName]?.columns?.[columnName] ?? this.schema.tables[tableName]?.types?.[typeLookupKey] ?? this.schema.columns[columnName] ?? primaryKeyColumnInfo ?? this.schema.types[typeLookupKey]; const rule = typeof ruleDef === 'function' ? ruleDef(typeLookupKey) : ruleDef; // Default to 'any' type if no rule is defined const finalRule = rule ?? { tsType: 'any', imports: [], decorators: [{ name: '@column' }], }; let tsType = finalRule.tsType; let propertyName = stringHelpers.camelCase(columnName); let extraColumnArgs; /** * When the property name is not a valid JS identifier and the rule * uses the structured `decorators` array, prefix it with an * underscore and set an explicit columnName so Lucid maps it * back to the correct database column. */ if (/^[^a-zA-Z_$]/.test(propertyName) && finalRule.decorators) { propertyName = `_${propertyName}`; extraColumnArgs = { columnName }; } if (column.nullable && !primaryKeys.includes(columnName)) { tsType += ' | null'; } const decorators = this.buildDecorators(finalRule, extraColumnArgs); return { imports: finalRule.imports ?? [], propertyName, column: ` ${decorators}\n declare ${propertyName}: ${tsType}`, }; } /** * Generate schema for a single table (internal method) */ generateTableSchema(tableName, columns, primaryKeys, importsBag) { /** * Resolve the primary key using table-specific or global primaryKey rule */ const primaryKeyRule = this.schema.tables[tableName]?.primaryKey ?? this.schema.primaryKey; const primaryKeyResult = primaryKeyRule?.(tableName, primaryKeys, columns); const skipColumns = this.schema.tables[tableName]?.skipColumns ?? []; const columnNames = Object.keys(columns) .filter((columnName) => !skipColumns.includes(columnName)) .sort((a, b) => a.localeCompare(b)); const schema = columnNames.map((columnName) => { const column = columns[columnName]; const pkInfo = primaryKeyResult && columnName === primaryKeyResult.columnName ? primaryKeyResult.columnInfo : undefined; return this.generateColumnSchema(columnName, column, tableName, primaryKeys, pkInfo); }); // Add column-specific imports schema.forEach((col) => { col.imports.forEach((imp) => importsBag.add(imp)); }); const className = stringHelpers.pascalCase(stringHelpers.singular(tableName)); // Return complete class definition as a single string (compact format) return [ `export class ${className}Schema extends BaseModel {`, ` static $columns = [${schema.map((k) => `'${k.propertyName}'`).join(', ')}] as const`, ` $columns = ${className}Schema.$columns`, schema.map((k) => k.column).join('\n'), '}', ].join('\n'); } /** * Generate schemas for multiple tables */ generateSchemas(tables) { const importsBag = new ImportsBag(); const classesToCreate = []; // Add base import importsBag.add({ source: '@adonisjs/lucid/orm', namedImports: ['BaseModel', 'column'] }); tables.forEach((table) => { const classDefinition = this.generateTableSchema(table.name, table.columns, table.primaryKeys, importsBag); classesToCreate.push(classDefinition); }); return { imports: importsBag.toArray(), classes: classesToCreate, }; } /** * Get the final output string from generated schemas */ getOutput(schemas) { const importsBag = new ImportsBag(); schemas.imports.forEach((imp) => importsBag.add(imp)); return `${importsBag.toString()}\n\n${schemas.classes.join('\n\n')}\n`; } }