diff --git a/example_config.json b/example_config.json index 2bb760e..c3073e0 100644 --- a/example_config.json +++ b/example_config.json @@ -62,6 +62,52 @@ "unique": false } ] + }, + { + "name": "posts", + "fields": [ + { + "name": "id", + "type": "integer", + "primaryKey": true + }, + { + "name": "title", + "type": "string", + "nullable": false + }, + { + "name": "body", + "type": "text", + "nullable": true + }, + { + "name": "user_id", + "type": "integer", + "nullable": false + }, + { + "name": "created_at", + "type": "datetime" + } + ], + "indexes": [ + { + "name": "posts_user_id_idx", + "columns": ["user_id"], + "unique": false + } + ], + "foreignKeys": [ + { + "name": "fk_posts_user_id", + "columns": ["user_id"], + "referenceTable": "users", + "referenceColumns": ["id"], + "onDelete": "CASCADE", + "onUpdate": "CASCADE" + } + ] } ] } \ No newline at end of file diff --git a/src/database/fk-creator.ts b/src/database/fk-creator.ts new file mode 100644 index 0000000..8909f66 --- /dev/null +++ b/src/database/fk-creator.ts @@ -0,0 +1,74 @@ +import { DatabaseQuery, ModelConfig, DBEngine } from '../types'; + +const VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function validateIdentifier(value: string, label: string) { + if (!VALID_IDENTIFIER.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } +} + +export async function createForeignKeys( + db: DatabaseQuery, + models: ModelConfig[], + engine: DBEngine, + logger: { info: (msg: string) => void; warn: (msg: string) => void } +) { + for (const model of models) { + if (!model.foreignKeys || model.foreignKeys.length === 0) continue; + + for (const fk of model.foreignKeys) { + validateIdentifier(fk.name, 'foreign key name'); + validateIdentifier(model.name, 'table name'); + validateIdentifier(fk.referenceTable, 'reference table name'); + for (const col of fk.columns) { + validateIdentifier(col, `column name in FK "${fk.name}"`); + } + for (const col of fk.referenceColumns) { + validateIdentifier(col, `reference column name in FK "${fk.name}"`); + } + + // Check if constraint already exists + let fkExists = false; + if (engine === 'pg') { + const res = await db.query( + "SELECT EXISTS (SELECT FROM information_schema.table_constraints WHERE constraint_name = $1 AND constraint_type = 'FOREIGN KEY')", + [fk.name] + ); + fkExists = res[0].exists; + } else { + // SQLite doesn't support ALTER TABLE ADD CONSTRAINT for FKs. + // FKs must be defined at table creation time in SQLite. + logger.warn( + `SQLite does not support adding foreign keys via ALTER TABLE. FK "${fk.name}" on "${model.name}" skipped.` + ); + continue; + } + + if (fkExists) { + logger.info(`Foreign key "${fk.name}" on table "${model.name}" already exists, skipping.`); + continue; + } + + const columnList = fk.columns.map((c) => `"${c}"`).join(', '); + const refColumnList = fk.referenceColumns.map((c) => `"${c}"`).join(', '); + + let sql = `ALTER TABLE "${model.name}" ADD CONSTRAINT "${fk.name}" FOREIGN KEY (${columnList}) REFERENCES "${fk.referenceTable}" (${refColumnList})`; + + if (fk.onDelete) { + sql += ` ON DELETE ${fk.onDelete}`; + } + if (fk.onUpdate) { + sql += ` ON UPDATE ${fk.onUpdate}`; + } + + sql += ';'; + + logger.info( + `Creating foreign key "${fk.name}" on "${model.name}" referencing "${fk.referenceTable}" (${fk.referenceColumns.join(', ')})...` + ); + await db.query(sql); + logger.info(`Foreign key "${fk.name}" created successfully.`); + } + } +} diff --git a/src/server.ts b/src/server.ts index b179a31..09cc0ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { AppConfig, Mode } from './types'; import dbPlugin from './plugin/database'; import { createTables } from './database/table-creator'; import { createIndexes } from './database/index-creator'; +import { createForeignKeys } from './database/fk-creator'; async function registerSwagger(swaggerConfig: AppConfig['swagger'], app: FastifyInstance) { if (swaggerConfig.enabled) { @@ -79,10 +80,11 @@ export async function startServer(config: AppConfig, port: number, mode: Mode) { connection: config.database.connection, }); - // Auto-generate tables and indexes if models are provided + // Auto-generate tables, indexes, and foreign keys if models are provided if (config.models && config.models.length > 0) { await createTables(app.db, config.models, config.database.engine, app.log); await createIndexes(app.db, config.models, config.database.engine, app.log); + await createForeignKeys(app.db, config.models, config.database.engine, app.log); } // register swagger diff --git a/src/types/index.ts b/src/types/index.ts index e45ec08..440b953 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ export type Mode = 'dev' | 'prod'; export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; export type DBEngine = 'sqlite' | 'pg'; export type DataType = 'integer' | 'string' | 'boolean' | 'text' | 'datetime'; +export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; export interface CLIOptions { config: string; @@ -62,10 +63,20 @@ export interface ModelIndex { unique?: boolean; } +export interface ModelForeignKey { + name: string; + columns: string[]; + referenceTable: string; + referenceColumns: string[]; + onDelete?: ForeignKeyAction; + onUpdate?: ForeignKeyAction; +} + export interface ModelConfig { name: string; fields: ModelField[]; indexes?: ModelIndex[]; + foreignKeys?: ModelForeignKey[]; } export interface AppConfig {