@datazod/zod-sql
Version:
Convert Zod schemas to SQL table definitions with support for SQLite, PostgreSQL, and MySQL
198 lines (197 loc) • 7.89 kB
JavaScript
import { extractTableStructure } from '../schema';
import { createTableDDL } from '../tables/table-ddl';
/**
* Get current table columns from database
*/
export async function getTableColumns(tableName, client, dialect = 'sqlite') {
try {
let sql;
switch (dialect) {
case 'sqlite':
sql = `PRAGMA table_info(${tableName})`;
break;
case 'mysql':
sql = `SHOW COLUMNS FROM ${tableName}`;
break;
case 'postgres':
sql = `SELECT column_name FROM information_schema.columns WHERE table_name = '${tableName}'`;
break;
default:
sql = `PRAGMA table_info(${tableName})`;
}
const result = await client.execute(sql);
if (dialect === 'sqlite') {
return result.rows?.map((row) => row.name) || [];
}
else if (dialect === 'mysql') {
return result.rows?.map((row) => row.Field) || [];
}
else {
return result.rows?.map((row) => row.column_name) || [];
}
}
catch (error) {
// Table doesn't exist
return [];
}
}
/**
* Generate SQL column definition from table structure
*/
function getColumnDefinition(column, dialect = 'sqlite') {
let sqlType = column.type || 'TEXT';
// Map generic types to dialect-specific types
if (dialect === 'sqlite') {
switch (sqlType.toUpperCase()) {
case 'STRING':
case 'VARCHAR':
sqlType = 'TEXT';
break;
case 'INTEGER':
case 'INT':
sqlType = 'INTEGER';
break;
case 'FLOAT':
case 'DOUBLE':
sqlType = 'REAL';
break;
case 'BOOLEAN':
sqlType = 'INTEGER';
break;
case 'DATETIME':
case 'TIMESTAMP':
sqlType = 'TEXT';
break;
}
}
// For migration safety: Always make new columns nullable to avoid constraint errors
// SQLite cannot add NOT NULL columns without default values to existing tables
const defaultValue = column.defaultValue ? ` DEFAULT ${column.defaultValue}` : '';
return `${sqlType}${defaultValue}`;
}
/**
* Backup table data for safe migration
*/
async function backupTableData(tableName, client, columns) {
const columnList = columns.map(col => `"${col}"`).join(', ');
const sql = `SELECT ${columnList} FROM ${tableName}`;
const result = await client.execute(sql);
return result.rows || [];
}
/**
* Restore data to recreated table
*/
async function restoreTableData(tableName, client, data, newColumns) {
if (data.length === 0)
return;
for (const row of data) {
// Filter row data to only include columns that exist in new schema AND have actual values
const filteredRow = {};
newColumns.forEach(col => {
if (row[col] !== undefined && row[col] !== null) {
filteredRow[col] = row[col];
}
});
// Only insert columns that actually have data, let DEFAULT values handle the rest
const columns = Object.keys(filteredRow);
const values = Object.values(filteredRow);
if (columns.length === 0) {
// If no columns have data, insert an empty row to trigger all defaults
const sql = `INSERT INTO ${tableName} DEFAULT VALUES`;
await client.execute(sql);
}
else {
const columnList = columns.map(col => `"${col}"`).join(', ');
// Escape values for SQL safety
const escapedValues = values.map(val => {
if (val === null || val === undefined)
return 'NULL';
if (typeof val === 'string')
return `'${val.replace(/'/g, "''")}'`;
return String(val);
}).join(', ');
const sql = `INSERT INTO ${tableName} (${columnList}) VALUES (${escapedValues})`;
await client.execute(sql);
}
}
}
/**
* Migrate table schema automatically
*/
export async function migrateTableSchema(tableName, schema, client, options = {}) {
const { dialect = 'sqlite', allowDrop = false, debug = false } = options;
const currentColumns = await getTableColumns(tableName, client, dialect);
if (currentColumns.length === 0) {
if (debug)
console.log(`[Migration] Table ${tableName} doesn't exist, skipping migration`);
return;
}
// Extract new schema structure
const structure = extractTableStructure(schema, options);
const newColumns = structure.columns.map(col => col.name);
// Find columns to add and remove
const columnsToAdd = newColumns.filter(col => !currentColumns.includes(col));
const columnsToRemove = currentColumns.filter(col => !newColumns.includes(col));
if (debug) {
console.log(`[Migration] Table ${tableName}:`);
console.log(`[Migration] Current columns:`, currentColumns);
console.log(`[Migration] New columns:`, newColumns);
console.log(`[Migration] To add:`, columnsToAdd);
console.log(`[Migration] To remove:`, columnsToRemove);
}
// Handle column additions
if (columnsToAdd.length > 0) {
for (const columnName of columnsToAdd) {
const columnDef = structure.columns.find(col => col.name === columnName);
if (!columnDef)
continue;
const sqlType = getColumnDefinition(columnDef, dialect);
const sql = `ALTER TABLE ${tableName} ADD COLUMN "${columnName}" ${sqlType}`;
if (debug)
console.log(`[Migration] Adding column: ${sql}`);
try {
await client.execute(sql);
if (debug)
console.log(`[Migration] Successfully added column ${columnName}`);
}
catch (error) {
if (debug)
console.log(`[Migration] Error adding column ${columnName}:`, error.message);
throw error;
}
}
}
// Handle column removals (requires table recreation)
if (columnsToRemove.length > 0) {
if (!allowDrop) {
throw new Error(`Cannot remove columns [${columnsToRemove.join(', ')}] from table ${tableName}. ` +
`Set allowDrop: true to enable table recreation with data preservation.`);
}
if (debug)
console.log(`[Migration] Recreating table ${tableName} to remove columns:`, columnsToRemove);
// Step 1: Backup existing data
const backupData = await backupTableData(tableName, client, currentColumns);
if (debug)
console.log(`[Migration] Backed up ${backupData.length} rows`);
// Step 2: Drop old table
const dropSql = `DROP TABLE ${tableName}`;
if (debug)
console.log(`[Migration] Dropping table: ${dropSql}`);
await client.execute(dropSql);
// Step 3: Create new table with updated schema
const newTableDDL = createTableDDL(tableName, schema, options);
if (debug)
console.log(`[Migration] Creating new table: ${newTableDDL}`);
await client.execute(newTableDDL);
// Step 4: Restore data (only columns that exist in new schema)
if (debug)
console.log(`[Migration] Restoring data...`);
await restoreTableData(tableName, client, backupData, newColumns);
if (debug)
console.log(`[Migration] Successfully recreated table ${tableName}`);
}
if (columnsToAdd.length === 0 && columnsToRemove.length === 0) {
if (debug)
console.log(`[Migration] No schema changes needed for ${tableName}`);
}
}