diff --git a/dist/commands/export.js b/dist/commands/export.js index 0aeeb34..13bf5bc 100644 --- a/dist/commands/export.js +++ b/dist/commands/export.js @@ -3,16 +3,17 @@ import path from "path"; import fs from "fs"; import chalk from "chalk"; import grabDBDir from "../utils/grab-db-dir"; -import { AppData } from "../data/app-data"; import { dumpDatabase } from "../utils/mariadb-dump-restore"; import { writeExportArchive } from "../utils/export-archive"; import trimExports from "../utils/trim-exports"; +import { resolveDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file"; +import { AppData } from "../data/app-data"; function defaultExportFileName(dbName, format) { return `${dbName}-${Date.now()}.${format}`; } export default function () { return new Command("export") - .description("Export database SQL dump + schema.ts into a portable archive") + .description("Export database SQL dump + schema into a portable archive") .option("-o, --output ", "Output archive path (.tar.gz or .zip). Defaults to db exports dir") .option("-f, --format ", "Archive format when --output is omitted: tar.gz | zip", "tar.gz") .action(async (opts) => { @@ -22,9 +23,14 @@ export default function () { if (!fs.existsSync(export_dir)) { fs.mkdirSync(export_dir, { recursive: true }); } - const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); - if (!fs.existsSync(schemaPath)) { - console.error(chalk.red(`Schema file not found: ${schemaPath}`)); + const schemaResolved = (global.SCHEMA_FILE_PATH && + fs.existsSync(global.SCHEMA_FILE_PATH) && { + path: global.SCHEMA_FILE_PATH, + basename: path.basename(global.SCHEMA_FILE_PATH), + }) || + resolveDataFile(db_dir, AppData.DbSchemaFileName); + if (!schemaResolved) { + console.error(chalk.red(`Schema file not found in \`${db_dir}\` (${supportedDataFileNames(AppData.DbSchemaFileName)})`)); process.exit(1); } const formatRaw = String(opts.format || "tar.gz").toLowerCase(); @@ -42,16 +48,20 @@ export default function () { } try { const sql = await dumpDatabase(config); - const schemaTs = fs.readFileSync(schemaPath, "utf-8"); + const schema = fs.readFileSync(schemaResolved.path, "utf-8"); await writeExportArchive({ - contents: { sql, schemaTs }, + contents: { + sql, + schema, + schemaFileName: schemaResolved.basename, + }, outPath, }); if (path.dirname(outPath) === export_dir) { trimExports({ config }); } console.log(`${chalk.bold(chalk.green(`DB Export Success!`))} → ${outPath}`); - console.log(chalk.dim(`Contains: dump.sql + ${AppData.DbSchemaFileName}`)); + console.log(chalk.dim(`Contains: dump.sql + ${schemaResolved.basename}`)); process.exit(0); } catch (error) { diff --git a/dist/commands/import.js b/dist/commands/import.js index b19bd8f..d19a06c 100644 --- a/dist/commands/import.js +++ b/dist/commands/import.js @@ -6,9 +6,10 @@ import { select } from "@inquirer/prompts"; import grabDBDir from "../utils/grab-db-dir"; import grabSortedExports from "../utils/grab-sorted-exports"; import grabBackupData from "../utils/grab-backup-data"; -import { AppData } from "../data/app-data"; import { restoreDatabase } from "../utils/mariadb-dump-restore"; import { isArchivePath, isSqlPath, readExportArchive, } from "../utils/export-archive"; +import { resolveDataFile } from "../utils/resolve-and-load-data-file"; +import { AppData } from "../data/app-data"; function formatChoice(name, index) { const { backup_date } = grabBackupData({ backup_name: name }); const time = Number.isNaN(backup_date.getTime()) @@ -18,10 +19,10 @@ function formatChoice(name, index) { } export default function () { return new Command("import") - .description("Import an SQL dump, or a full export archive (SQL + schema.ts)") + .description("Import an SQL dump, or a full export archive (SQL + schema)") .argument("[file]", "Path to .sql file or export archive (.tar.gz / .tar / .zip)") - .option("--sql-only", "When importing an archive, restore SQL only (skip writing schema.ts)") - .option("--schema-only", "When importing an archive, write schema.ts only (skip SQL restore)") + .option("--sql-only", "When importing an archive, restore SQL only (skip writing schema)") + .option("--schema-only", "When importing an archive, write schema only (skip SQL restore)") .action(async (fileArg, opts) => { console.log(`Importing database ...`); const config = global.CONFIG; @@ -65,14 +66,21 @@ export default function () { console.error(chalk.red(`Cannot combine --sql-only and --schema-only.`)); process.exit(1); } - const { sql, schemaTs } = await readExportArchive(filePath); + const { sql, schema, schemaFileName } = await readExportArchive(filePath); if (!opts.schemaOnly) { await restoreDatabase(config, sql); console.log(chalk.green(`SQL restored from archive`)); } if (!opts.sqlOnly) { - const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); - fs.writeFileSync(schemaPath, schemaTs, "utf-8"); + const existing = resolveDataFile(db_dir, AppData.DbSchemaFileName); + const schemaPath = path.join(db_dir, schemaFileName); + // Remove a differently-named schema so only one format remains + if (existing && + path.resolve(existing.path) !== path.resolve(schemaPath)) { + fs.unlinkSync(existing.path); + console.log(chalk.dim(`Removed previous schema file: ${existing.basename}`)); + } + fs.writeFileSync(schemaPath, schema, "utf-8"); console.log(chalk.green(`Schema written → ${schemaPath}`)); } console.log(`${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`); diff --git a/dist/commands/index.js b/dist/commands/index.js old mode 100644 new mode 100755 diff --git a/dist/commands/typedef.js b/dist/commands/typedef.js index fe13a6f..bad2386 100644 --- a/dist/commands/typedef.js +++ b/dist/commands/typedef.js @@ -14,7 +14,7 @@ export default function () { const { ROOT_DIR } = grabDirNames(); const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema }); if (!config.typedef_file_path) { - console.error(`\`typedef_file_path\` is required in bun-mariadb.config.ts to generate types.`); + console.error(`\`typedef_file_path\` is required in bun-mariadb.config to generate types.`); process.exit(1); } const out_file = path.resolve(ROOT_DIR, config.typedef_file_path); diff --git a/dist/data/app-data.d.ts b/dist/data/app-data.d.ts index 33f97f9..821c009 100644 --- a/dist/data/app-data.d.ts +++ b/dist/data/app-data.d.ts @@ -1,11 +1,13 @@ export declare const AppData: { - readonly ConfigFileName: "bun-mariadb.config.ts"; + readonly ConfigFileName: "bun-mariadb.config"; readonly MaxBackups: 10; readonly MaxExports: 10; readonly DefaultBackupDirName: ".backups"; readonly DefaultExportDirName: ".exports"; readonly DbSchemaManagerTableName: "__db_schema_manager__"; - readonly DbSchemaFileName: "schema.ts"; + readonly DbSchemaFileName: "schema"; + /** Priority order when resolving config/schema files */ + readonly SupportedDataFileExtensions: readonly [".ts", ".js", ".json", ".yaml", ".yml"]; readonly MaxInitRetries: 50; readonly InitRetryIntervalMilliseconds: 5000; }; diff --git a/dist/data/app-data.js b/dist/data/app-data.js index b591ea9..0f53524 100644 --- a/dist/data/app-data.js +++ b/dist/data/app-data.js @@ -1,11 +1,13 @@ export const AppData = { - ConfigFileName: "bun-mariadb.config.ts", + ConfigFileName: "bun-mariadb.config", MaxBackups: 10, MaxExports: 10, DefaultBackupDirName: ".backups", DefaultExportDirName: ".exports", DbSchemaManagerTableName: "__db_schema_manager__", - DbSchemaFileName: "schema.ts", + DbSchemaFileName: "schema", + /** Priority order when resolving config/schema files */ + SupportedDataFileExtensions: [".ts", ".js", ".json", ".yaml", ".yml"], MaxInitRetries: 50, InitRetryIntervalMilliseconds: 5000, }; diff --git a/dist/functions/init.d.ts b/dist/functions/init.d.ts index 949cd12..f254fa9 100644 --- a/dist/functions/init.d.ts +++ b/dist/functions/init.d.ts @@ -6,5 +6,7 @@ declare global { var CONFIG: BunMariaDBConfig; var DB_SCHEMA: BUN_MARIADB_DatabaseSchemaType; var MARIADB_CLIENT: Bun.SQL; + var CONFIG_FILE_PATH: string; + var SCHEMA_FILE_PATH: string; } export default function init(): void; diff --git a/dist/functions/init.js b/dist/functions/init.js index 62da169..ff85a09 100644 --- a/dist/functions/init.js +++ b/dist/functions/init.js @@ -4,19 +4,19 @@ import { AppData } from "../data/app-data"; import grabDirNames from "../data/grab-dir-names"; import { RequiredENVs, } from "../types"; import setMariaDBClient from "./set-mariadb-client"; +import { resolveAndLoadDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file"; export default function init() { try { const { ROOT_DIR } = grabDirNames(); const { ConfigFileName } = AppData; - const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); - if (!fs.existsSync(ConfigFilePath)) { - console.error(`Please create a \`${ConfigFileName}\` file at the root of your project.`); + const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName); + if (!loadedConfig) { + console.error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`); process.exit(1); } - const ConfigImport = require(ConfigFilePath); - const Config = ConfigImport["default"]; - if (!Config) { - console.error(`No default export from \`${ConfigFilePath}\`. Please export a default module.`); + const Config = loadedConfig.data; + if (!Config || typeof Config !== "object") { + console.error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`); process.exit(1); } if (!Config.db_name) { @@ -34,20 +34,23 @@ export default function init() { } }); if (!Config.db_dir) { - console.error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a \`${AppData["DbSchemaFileName"]}\` file is also required in this directory to define your database schema`); + console.error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`); process.exit(1); } const db_dir = path.resolve(ROOT_DIR, Config.db_dir); if (!fs.existsSync(db_dir)) { fs.mkdirSync(db_dir, { recursive: true }); } - const DBSchemaFilePath = path.join(db_dir, AppData["DbSchemaFileName"]); - if (!fs.existsSync(DBSchemaFilePath)) { - console.error(`Please create a schema file at \`${DBSchemaFilePath}\`. Don't forget to export a default module from this file.`); + const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName); + if (!loadedSchema) { + console.error(`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`); + process.exit(1); + } + const DbSchema = loadedSchema.data; + if (!DbSchema || typeof DbSchema !== "object") { + console.error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`); process.exit(1); } - const DbSchemaImport = require(DBSchemaFilePath); - const DbSchema = DbSchemaImport["default"]; const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"]; const BackupDir = path.resolve(db_dir, backup_dir); if (!fs.existsSync(BackupDir)) { @@ -59,6 +62,8 @@ export default function init() { } global.CONFIG = Config; global.DB_SCHEMA = DbSchema; + global.CONFIG_FILE_PATH = loadedConfig.path; + global.SCHEMA_FILE_PATH = loadedSchema.path; if (!global.CONFIG) { console.error(`Couldn't grab global Config.`); process.exit(1); diff --git a/dist/lib/schema/build-column-definition.d.ts b/dist/lib/schema/build-column-definition.d.ts index 00bc713..b33c0d1 100644 --- a/dist/lib/schema/build-column-definition.d.ts +++ b/dist/lib/schema/build-column-definition.d.ts @@ -1,2 +1,8 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types"; -export default function buildColumnDefinition(field: BUN_MARIADB_FieldSchemaType): string; +export type BuildColumnDefinitionOptions = { + /** UNIQUE is managed by syncUniqueConstraints on existing tables */ + omitUnique?: boolean; +}; +export default function buildColumnDefinition(field: BUN_MARIADB_FieldSchemaType, options?: BuildColumnDefinitionOptions): string; +/** Whether schema field requires NOT NULL */ +export declare function fieldRequiresNotNull(field: BUN_MARIADB_FieldSchemaType): boolean; diff --git a/dist/lib/schema/build-column-definition.js b/dist/lib/schema/build-column-definition.js index f5298bc..cbd0ab8 100644 --- a/dist/lib/schema/build-column-definition.js +++ b/dist/lib/schema/build-column-definition.js @@ -1,7 +1,7 @@ import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; -export default function buildColumnDefinition(field) { +export default function buildColumnDefinition(field, options = {}) { if (!field.fieldName) { throw new Error("Field name is required"); } @@ -19,7 +19,10 @@ export default function buildColumnDefinition(field) { } } // VECTOR columns cannot be UNIQUE in the usual sense - if (field.unique && !field.primaryKey && !isVectorField(field)) { + if (!options.omitUnique && + field.unique && + !field.primaryKey && + !isVectorField(field)) { parts.push("UNIQUE"); } if (field.defaultValue !== undefined) { @@ -41,3 +44,7 @@ export default function buildColumnDefinition(field) { } return parts.join(" "); } +/** Whether schema field requires NOT NULL */ +export function fieldRequiresNotNull(field) { + return Boolean(field.notNullValue || field.primaryKey || isVectorField(field)); +} diff --git a/dist/lib/schema/build-foreign-key-constraint.d.ts b/dist/lib/schema/build-foreign-key-constraint.d.ts index cd51d92..15c1809 100644 --- a/dist/lib/schema/build-foreign-key-constraint.d.ts +++ b/dist/lib/schema/build-foreign-key-constraint.d.ts @@ -1,2 +1,4 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types"; -export default function buildForeignKeyConstraint(field: BUN_MARIADB_FieldSchemaType): string; +export declare function defaultForeignKeyName(tableName: string, fieldName: string): string; +export declare function resolveForeignKeyName(field: BUN_MARIADB_FieldSchemaType, tableName: string): string; +export default function buildForeignKeyConstraint(field: BUN_MARIADB_FieldSchemaType, tableName: string): string; diff --git a/dist/lib/schema/build-foreign-key-constraint.js b/dist/lib/schema/build-foreign-key-constraint.js index 0749c98..fa89d41 100644 --- a/dist/lib/schema/build-foreign-key-constraint.js +++ b/dist/lib/schema/build-foreign-key-constraint.js @@ -1,10 +1,15 @@ import MariaDBQuoteGen from "./mariadb-quote-gen"; -export default function buildForeignKeyConstraint(field) { +export function defaultForeignKeyName(tableName, fieldName) { + return `fk_${tableName}_${fieldName}`; +} +export function resolveForeignKeyName(field, tableName) { + const fieldName = field.fieldName || "column"; + return field.foreignKey?.foreignKeyName || defaultForeignKeyName(tableName, fieldName); +} +export default function buildForeignKeyConstraint(field, tableName) { const fk = field.foreignKey; - const constraintName = fk.foreignKeyName - ? `CONSTRAINT ${MariaDBQuoteGen(fk.foreignKeyName)} ` - : ""; - let constraint = `${constraintName}FOREIGN KEY (${MariaDBQuoteGen(field.fieldName)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName)}(${MariaDBQuoteGen(fk.destinationTableColumnName)})`; + const constraintName = resolveForeignKeyName(field, tableName); + let constraint = `CONSTRAINT ${MariaDBQuoteGen(constraintName)} FOREIGN KEY (${MariaDBQuoteGen(field.fieldName)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName)}(${MariaDBQuoteGen(fk.destinationTableColumnName)})`; if (fk.cascadeDelete) { constraint += " ON DELETE CASCADE"; } diff --git a/dist/lib/schema/create-table.js b/dist/lib/schema/create-table.js index 6ede61e..771f5c5 100644 --- a/dist/lib/schema/create-table.js +++ b/dist/lib/schema/create-table.js @@ -16,7 +16,7 @@ export default async function createTable({ table, config, }) { primaryKeys.push(field.fieldName); } if (field.foreignKey && !table.isVector) { - foreignKeys.push(buildForeignKeyConstraint(field)); + foreignKeys.push(buildForeignKeyConstraint(field, table.tableName)); } } if (primaryKeys.length > 0) { @@ -25,15 +25,15 @@ export default async function createTable({ table, config, }) { } if (table.uniqueConstraints) { for (const constraint of table.uniqueConstraints) { - if (constraint.constraintTableFields && - constraint.constraintTableFields.length > 0) { - const fields = constraint.constraintTableFields - .map((field) => MariaDBQuoteGen(field.value)) - .join(", "); - const constraintName = constraint.constraintName || - `unique_${fields.replace(/`/g, "")}`; - columnDefinitions.push(`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`); + const columns = (constraint.constraintTableFields || []) + .map((field) => field.value) + .filter((value) => Boolean(value)); + if (columns.length === 0) { + continue; } + const fields = columns.map((col) => MariaDBQuoteGen(col)).join(", "); + const constraintName = constraint.constraintName || `unique_${columns.join("_")}`; + columnDefinitions.push(`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`); } } const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${[...columnDefinitions, ...foreignKeys].join(", ")})${buildTableOptions(table)}`; diff --git a/dist/lib/schema/get-table-columns.d.ts b/dist/lib/schema/get-table-columns.d.ts index 971b65a..5369915 100644 --- a/dist/lib/schema/get-table-columns.d.ts +++ b/dist/lib/schema/get-table-columns.d.ts @@ -3,6 +3,9 @@ export type ColumnInfoRow = { name: string; type: string; comment?: string; + isNullable: boolean; + columnDefault: string | null; + extra: string; }; export default function getTableColumns({ tableName, config, }: { tableName: string; diff --git a/dist/lib/schema/get-table-columns.js b/dist/lib/schema/get-table-columns.js index 7fc28b0..a0fcbff 100644 --- a/dist/lib/schema/get-table-columns.js +++ b/dist/lib/schema/get-table-columns.js @@ -3,7 +3,7 @@ import schemaCondition from "./schema-condition"; export default async function getTableColumns({ tableName, config, }) { const schemaCond = schemaCondition(config); const rows = await querySchemaRows({ - query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, + query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, values: [...schemaCond.values, tableName], config, }); @@ -11,5 +11,8 @@ export default async function getTableColumns({ tableName, config, }) { name: row.COLUMN_NAME, type: row.COLUMN_TYPE, comment: row.COLUMN_COMMENT, + isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", + columnDefault: row.COLUMN_DEFAULT, + extra: row.EXTRA || "", })); } diff --git a/dist/lib/schema/handle-db-schema-table.js b/dist/lib/schema/handle-db-schema-table.js index 71792ee..35b1b5b 100644 --- a/dist/lib/schema/handle-db-schema-table.js +++ b/dist/lib/schema/handle-db-schema-table.js @@ -3,14 +3,17 @@ import MariaDBQuoteGen from "./mariadb-quote-gen"; import resolveTable from "./resolve-table"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { dropObsoleteForeignKeys, ensureForeignKeys, } from "./sync-foreign-keys"; import syncIndexes from "./sync-indexes"; +import syncPrimaryKey from "./sync-primary-key"; +import syncTableOptions from "./sync-table-options"; +import syncUniqueConstraints from "./sync-unique-constraints"; import updateTable from "./update-table"; import upsertDbManagerTable, { removeDbManagerTable, } from "./upsert-db-manager-table"; export default async function handleDBSchemaTable({ db_schema, config, table, db_manager_table_name, existing_live_table, }) { const resolvedTable = resolveTable(table, db_schema); let tableExistsTracked = Boolean(db_manager_table_name); let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME); - let wasRenamed = false; if (resolvedTable.tableNameOld && resolvedTable.tableNameOld !== resolvedTable.tableName) { // Only hit information_schema when a rename is declared @@ -36,7 +39,6 @@ export default async function handleDBSchemaTable({ db_schema, config, table, db }); tableExistsTracked = true; tableExistsLive = true; - wasRenamed = true; } } if (!tableExistsTracked && !tableExistsLive) { @@ -47,16 +49,26 @@ export default async function handleDBSchemaTable({ db_schema, config, table, db }); } else { - if (!wasRenamed) { - await updateTable({ - table: resolvedTable, - config, - }); - } + // Columns first (also drops FKs on removed/modified columns) + await updateTable({ + table: resolvedTable, + config, + }); await upsertDbManagerTable({ tableName: resolvedTable.tableName, config, }); } + // Order matters: + // 1. Table options (engine/collation) + // 2. Drop obsolete/changed FKs (unlocks index cleanup) + // 3. Primary key + // 4. Indexes / uniques (FK columns need supporting indexes) + // 5. Ensure foreign keys exist + await syncTableOptions({ table: resolvedTable, config }); + await dropObsoleteForeignKeys({ table: resolvedTable, config }); + await syncPrimaryKey({ table: resolvedTable, config }); await syncIndexes({ table: resolvedTable, config }); + await syncUniqueConstraints({ table: resolvedTable, config }); + await ensureForeignKeys({ table: resolvedTable, config }); } diff --git a/dist/lib/schema/sync-foreign-keys.d.ts b/dist/lib/schema/sync-foreign-keys.d.ts new file mode 100644 index 0000000..d80460d --- /dev/null +++ b/dist/lib/schema/sync-foreign-keys.d.ts @@ -0,0 +1,52 @@ +import type { BUN_MARIADB_FieldSchemaType, BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types"; +export type DesiredForeignKey = { + name: string; + column: string; + refTable: string; + refColumn: string; + cascadeDelete: boolean; + cascadeUpdate: boolean; + field: BUN_MARIADB_FieldSchemaType; +}; +export type LiveForeignKey = { + name: string; + column: string; + refTable: string; + refColumn: string; + deleteRule: string; + updateRule: string; +}; +export declare function grabDesiredForeignKeys(table: BUN_MARIADB_TableSchemaType): DesiredForeignKey[]; +export declare function grabLiveForeignKeys({ tableName, config, }: { + tableName: string; + config?: BunMariaDBConfig; +}): Promise; +export declare function dropForeignKey({ tableName, constraintName, config, }: { + tableName: string; + constraintName: string; + config?: BunMariaDBConfig; +}): Promise; +export declare function dropForeignKeysOnColumns({ tableName, columns, config, }: { + tableName: string; + columns: string[]; + config?: BunMariaDBConfig; +}): Promise; +/** + * Drop foreign keys that are removed from schema or need recreation + * (name/cascade/target changed). Run before index cleanup. + */ +export declare function dropObsoleteForeignKeys({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; +/** + * Ensure all desired foreign keys exist. Run after indexes/uniques. + */ +export declare function ensureForeignKeys({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; +export default function syncForeignKeys({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; diff --git a/dist/lib/schema/sync-foreign-keys.js b/dist/lib/schema/sync-foreign-keys.js new file mode 100644 index 0000000..63c3186 --- /dev/null +++ b/dist/lib/schema/sync-foreign-keys.js @@ -0,0 +1,183 @@ +import buildForeignKeyConstraint, { resolveForeignKeyName, } from "./build-foreign-key-constraint"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +function normalizeRule(rule) { + return String(rule || "RESTRICT").toUpperCase().replace(/_/g, " "); +} +function ruleIsCascade(rule) { + return normalizeRule(rule) === "CASCADE"; +} +function fkIdentityKey(fk) { + return `${fk.column}\0${fk.refTable}\0${fk.refColumn}`; +} +function rulesMatch(live, desired) { + return (ruleIsCascade(live.deleteRule) === desired.cascadeDelete && + ruleIsCascade(live.updateRule) === desired.cascadeUpdate); +} +export function grabDesiredForeignKeys(table) { + if (table.isVector) { + return []; + } + const desired = []; + for (const field of table.fields || []) { + const fk = field.foreignKey; + if (!field.fieldName || + !fk?.destinationTableName || + !fk.destinationTableColumnName) { + continue; + } + desired.push({ + name: resolveForeignKeyName(field, table.tableName), + column: field.fieldName, + refTable: fk.destinationTableName, + refColumn: fk.destinationTableColumnName, + cascadeDelete: Boolean(fk.cascadeDelete), + cascadeUpdate: Boolean(fk.cascadeUpdate), + field, + }); + } + return desired; +} +export async function grabLiveForeignKeys({ tableName, config, }) { + const databaseName = config?.db_name || global.CONFIG?.db_name; + const schemaWhere = databaseName + ? "rc.CONSTRAINT_SCHEMA = ?" + : "rc.CONSTRAINT_SCHEMA = DATABASE()"; + const schemaValues = databaseName ? [databaseName] : []; + const rows = await querySchemaRows({ + query: ` + SELECT + rc.CONSTRAINT_NAME, + kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME, + rc.DELETE_RULE, + rc.UPDATE_RULE + FROM information_schema.REFERENTIAL_CONSTRAINTS rc + INNER JOIN information_schema.KEY_COLUMN_USAGE kcu + ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.TABLE_NAME = kcu.TABLE_NAME + WHERE ${schemaWhere} + AND rc.TABLE_NAME = ? + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY rc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + `, + values: [...schemaValues, tableName], + config, + }); + // Single-column FKs only (schema model is one FK per field) + const byName = new Map(); + for (const row of rows) { + if (byName.has(row.CONSTRAINT_NAME)) { + continue; + } + byName.set(row.CONSTRAINT_NAME, { + name: row.CONSTRAINT_NAME, + column: row.COLUMN_NAME, + refTable: row.REFERENCED_TABLE_NAME, + refColumn: row.REFERENCED_COLUMN_NAME, + deleteRule: row.DELETE_RULE, + updateRule: row.UPDATE_RULE, + }); + } + return [...byName.values()]; +} +export async function dropForeignKey({ tableName, constraintName, config, }) { + console.log(`Dropping foreign key: ${constraintName} on ${tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP FOREIGN KEY ${MariaDBQuoteGen(constraintName)}`, + config, + }); +} +export async function dropForeignKeysOnColumns({ tableName, columns, config, }) { + if (columns.length === 0) + return; + const live = await grabLiveForeignKeys({ tableName, config }); + const colSet = new Set(columns); + for (const fk of live) { + if (colSet.has(fk.column)) { + await dropForeignKey({ + tableName, + constraintName: fk.name, + config, + }); + } + } +} +/** + * Drop foreign keys that are removed from schema or need recreation + * (name/cascade/target changed). Run before index cleanup. + */ +export async function dropObsoleteForeignKeys({ table, config, }) { + const desired = grabDesiredForeignKeys(table); + const live = await grabLiveForeignKeys({ + tableName: table.tableName, + config, + }); + const keepNames = new Set(); + const droppedNames = new Set(); + const dropOnce = async (name) => { + if (droppedNames.has(name)) + return; + droppedNames.add(name); + await dropForeignKey({ + tableName: table.tableName, + constraintName: name, + config, + }); + }; + for (const want of desired) { + const byIdentity = live.find((existing) => !keepNames.has(existing.name) && + !droppedNames.has(existing.name) && + fkIdentityKey(existing) === fkIdentityKey(want)); + if (byIdentity && + rulesMatch(byIdentity, want) && + byIdentity.name === want.name) { + keepNames.add(byIdentity.name); + continue; + } + // Changed relationship → drop so ensureForeignKeys can recreate + if (byIdentity) { + await dropOnce(byIdentity.name); + } + const byName = live.find((existing) => existing.name === want.name); + if (byName && byName !== byIdentity) { + await dropOnce(byName.name); + } + } + for (const existing of live) { + if (keepNames.has(existing.name) || droppedNames.has(existing.name)) { + continue; + } + // Not in schema anymore + await dropOnce(existing.name); + } +} +/** + * Ensure all desired foreign keys exist. Run after indexes/uniques. + */ +export async function ensureForeignKeys({ table, config, }) { + const desired = grabDesiredForeignKeys(table); + const live = await grabLiveForeignKeys({ + tableName: table.tableName, + config, + }); + for (const want of desired) { + const exists = live.some((existing) => fkIdentityKey(existing) === fkIdentityKey(want) && + rulesMatch(existing, want) && + existing.name === want.name); + if (exists) { + continue; + } + console.log(`Creating foreign key: ${want.name} (${want.column} → ${want.refTable}.${want.refColumn}) on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD ${buildForeignKeyConstraint(want.field, table.tableName)}`, + config, + }); + } +} +export default async function syncForeignKeys({ table, config, }) { + await dropObsoleteForeignKeys({ table, config }); + await ensureForeignKeys({ table, config }); +} diff --git a/dist/lib/schema/sync-indexes.js b/dist/lib/schema/sync-indexes.js index 2326bd8..a39a8f1 100644 --- a/dist/lib/schema/sync-indexes.js +++ b/dist/lib/schema/sync-indexes.js @@ -2,6 +2,7 @@ import isVectorField from "./is-vector-field"; import MariaDBQuoteGen from "./mariadb-quote-gen"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { grabDesiredUniqueConstraints } from "./sync-unique-constraints"; function isVectorIndexDef(index, table) { if (index.indexType === "VECTOR") return true; @@ -20,6 +21,60 @@ function vectorDistanceMetric(index) { return "euclidean"; return "euclidean"; } +/** Normalize schema index type vs information_schema.INDEX_TYPE */ +function indexTypesMatch(liveType, schemaIndex, table) { + const live = (liveType || "").toUpperCase(); + const schemaType = schemaIndex.indexType?.toUpperCase(); + if (isVectorIndexDef(schemaIndex, table)) { + // MariaDB may report VECTOR indexes as BTREE or VECTOR depending on version + return live === "VECTOR" || live === "BTREE" || live === ""; + } + if (!schemaType || schemaType === "BTREE") { + // Default / unspecified → BTREE + return live === "BTREE" || live === ""; + } + if (schemaType === "FULLTEXT") { + return live === "FULLTEXT"; + } + if (schemaType === "SPATIAL") { + return live === "SPATIAL"; + } + if (schemaType === "HASH") { + return live === "HASH"; + } + return live === schemaType; +} +async function createIndex({ table, index, config, }) { + if (!index.indexName || + !index.indexTableFields || + index.indexTableFields.length === 0) { + return; + } + if (isVectorIndexDef(index, table)) { + console.log(`Creating Vector index: ${index.indexName}`); + const targetField = MariaDBQuoteGen(index.indexTableFields[0]); + const distanceMetric = vectorDistanceMetric(index); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`, + config, + }); + return; + } + console.log(`Creating standard index: ${index.indexName}`); + const fields = index.indexTableFields + .map((field) => MariaDBQuoteGen(field)) + .join(", "); + const typeUpper = index.indexType?.toUpperCase(); + const isSpecialType = typeUpper === "FULLTEXT" || typeUpper === "SPATIAL"; + const indexPrefix = isSpecialType ? `${typeUpper} ` : ""; + const indexSuffix = !isSpecialType && (typeUpper === "BTREE" || typeUpper === "HASH") + ? ` USING ${typeUpper}` + : ""; + await runSchemaQuery({ + query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`, + config, + }); +} export default async function syncIndexes({ table, config, }) { const schemaCond = schemaCondition(config); const rows = await querySchemaRows({ @@ -37,16 +92,8 @@ export default async function syncIndexes({ table, config, }) { config, }); const protectedIndexNames = new Set(protectedConstraintRows.map((r) => r.CONSTRAINT_NAME)); - // Column-level UNIQUE creates an index often named after the column - for (const field of table.fields || []) { - if (field.unique && field.fieldName) { - protectedIndexNames.add(field.fieldName); - } - } - for (const constraint of table.uniqueConstraints || []) { - if (constraint.constraintName) { - protectedIndexNames.add(constraint.constraintName); - } + for (const constraint of grabDesiredUniqueConstraints(table)) { + protectedIndexNames.add(constraint.name); } const existingIndexesMap = new Map(); for (const row of rows) { @@ -70,6 +117,7 @@ export default async function syncIndexes({ table, config, }) { query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, config, }); + existingIndexesMap.delete(indexName); } catch (err) { if (String(err?.message || "").includes("needed in a foreign key constraint")) { @@ -83,7 +131,8 @@ export default async function syncIndexes({ table, config, }) { const schemaColumns = schemaIndex.indexTableFields || []; const columnsMatch = details.columns.length === schemaColumns.length && details.columns.every((col, idx) => col === schemaColumns[idx]); - if (!columnsMatch) { + const typeMatch = indexTypesMatch(details.type, schemaIndex, table); + if (!columnsMatch || !typeMatch) { console.log(`Recreating changed index: ${indexName}`); await runSchemaQuery({ query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, @@ -100,32 +149,7 @@ export default async function syncIndexes({ table, config, }) { continue; } if (!existingIndexesMap.has(index.indexName)) { - if (isVectorIndexDef(index, table)) { - console.log(`Creating Vector index: ${index.indexName}`); - const targetField = MariaDBQuoteGen(index.indexTableFields[0]); - const distanceMetric = vectorDistanceMetric(index); - await runSchemaQuery({ - query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`, - config, - }); - } - else { - console.log(`Creating standard index: ${index.indexName}`); - const fields = index.indexTableFields - .map((field) => MariaDBQuoteGen(field)) - .join(", "); - const typeUpper = index.indexType?.toUpperCase(); - const isSpecialType = typeUpper === "FULLTEXT" || typeUpper === "SPATIAL"; - const indexPrefix = isSpecialType ? `${typeUpper} ` : ""; - const indexSuffix = !isSpecialType && - (typeUpper === "BTREE" || typeUpper === "HASH") - ? ` USING ${typeUpper}` - : ""; - await runSchemaQuery({ - query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`, - config, - }); - } + await createIndex({ table, index, config }); } } } diff --git a/dist/lib/schema/sync-primary-key.d.ts b/dist/lib/schema/sync-primary-key.d.ts new file mode 100644 index 0000000..bbd338d --- /dev/null +++ b/dist/lib/schema/sync-primary-key.d.ts @@ -0,0 +1,6 @@ +import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types"; +export declare function grabDesiredPrimaryKeyColumns(table: BUN_MARIADB_TableSchemaType): string[]; +export default function syncPrimaryKey({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; diff --git a/dist/lib/schema/sync-primary-key.js b/dist/lib/schema/sync-primary-key.js new file mode 100644 index 0000000..7ae1be4 --- /dev/null +++ b/dist/lib/schema/sync-primary-key.js @@ -0,0 +1,52 @@ +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +import schemaCondition from "./schema-condition"; +function columnsEqual(a, b) { + return a.length === b.length && a.every((col, i) => col === b[i]); +} +export function grabDesiredPrimaryKeyColumns(table) { + return (table.fields || []) + .filter((field) => field.primaryKey && field.fieldName) + .map((field) => field.fieldName); +} +async function grabLivePrimaryKeyColumns({ tableName, config, }) { + const schemaCond = schemaCondition(config); + const rows = await querySchemaRows({ + query: ` + SELECT COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.KEY_COLUMN_USAGE + WHERE ${schemaCond.where} + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION + `, + values: [...schemaCond.values, tableName], + config, + }); + return rows.map((row) => row.COLUMN_NAME); +} +export default async function syncPrimaryKey({ table, config, }) { + const desired = grabDesiredPrimaryKeyColumns(table); + const live = await grabLivePrimaryKeyColumns({ + tableName: table.tableName, + config, + }); + if (columnsEqual(desired, live)) { + return; + } + if (live.length > 0) { + console.log(`Dropping primary key on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`, + config, + }); + } + if (desired.length > 0) { + const cols = desired.map((col) => MariaDBQuoteGen(col)).join(", "); + console.log(`Creating primary key (${desired.join(", ")}) on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD PRIMARY KEY (${cols})`, + config, + }); + } +} diff --git a/dist/lib/schema/sync-table-options.d.ts b/dist/lib/schema/sync-table-options.d.ts new file mode 100644 index 0000000..af3c8f6 --- /dev/null +++ b/dist/lib/schema/sync-table-options.d.ts @@ -0,0 +1,5 @@ +import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types"; +export default function syncTableOptions({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; diff --git a/dist/lib/schema/sync-table-options.js b/dist/lib/schema/sync-table-options.js new file mode 100644 index 0000000..aae9b13 --- /dev/null +++ b/dist/lib/schema/sync-table-options.js @@ -0,0 +1,33 @@ +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +import schemaCondition from "./schema-condition"; +export default async function syncTableOptions({ table, config, }) { + const schemaCond = schemaCondition(config); + const rows = await querySchemaRows({ + query: `SELECT ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE'`, + values: [...schemaCond.values, table.tableName], + config, + }); + const live = rows[0]; + if (!live) { + return; + } + const alters = []; + if ((live.ENGINE || "").toUpperCase() !== "INNODB") { + alters.push("ENGINE=InnoDB"); + } + if (table.collation) { + const liveCollation = (live.TABLE_COLLATION || "").toLowerCase(); + if (liveCollation !== table.collation.toLowerCase()) { + alters.push(`CONVERT TO CHARACTER SET utf8mb4 COLLATE ${table.collation}`); + } + } + if (alters.length === 0) { + return; + } + console.log(`Updating table options on ${table.tableName}: ${alters.join(", ")}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ${alters.join(", ")}`, + config, + }); +} diff --git a/dist/lib/schema/sync-unique-constraints.d.ts b/dist/lib/schema/sync-unique-constraints.d.ts new file mode 100644 index 0000000..eadaaf8 --- /dev/null +++ b/dist/lib/schema/sync-unique-constraints.d.ts @@ -0,0 +1,11 @@ +import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types"; +type UniqueConstraintDesired = { + name: string; + columns: string[]; +}; +export declare function grabDesiredUniqueConstraints(table: BUN_MARIADB_TableSchemaType): UniqueConstraintDesired[]; +export default function syncUniqueConstraints({ table, config, }: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise; +export {}; diff --git a/dist/lib/schema/sync-unique-constraints.js b/dist/lib/schema/sync-unique-constraints.js new file mode 100644 index 0000000..ce3481c --- /dev/null +++ b/dist/lib/schema/sync-unique-constraints.js @@ -0,0 +1,138 @@ +import isVectorField from "./is-vector-field"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +function columnsKey(columns) { + return columns.join("\0"); +} +function columnsEqual(a, b) { + return a.length === b.length && a.every((col, i) => col === b[i]); +} +function defaultConstraintName(columns) { + return `unique_${columns.join("_")}`; +} +export function grabDesiredUniqueConstraints(table) { + const desired = []; + const seenColumnSets = new Set(); + for (const constraint of table.uniqueConstraints || []) { + const columns = (constraint.constraintTableFields || []) + .map((field) => field.value) + .filter((value) => Boolean(value)); + if (columns.length === 0) { + continue; + } + const key = columnsKey(columns); + if (seenColumnSets.has(key)) { + continue; + } + seenColumnSets.add(key); + desired.push({ + name: constraint.constraintName || defaultConstraintName(columns), + columns, + }); + } + for (const field of table.fields || []) { + if (!field.fieldName || + !field.unique || + field.primaryKey || + isVectorField(field)) { + continue; + } + const columns = [field.fieldName]; + const key = columnsKey(columns); + if (seenColumnSets.has(key)) { + continue; + } + seenColumnSets.add(key); + // Matches MariaDB's default name for column-level UNIQUE + desired.push({ + name: field.fieldName, + columns, + }); + } + return desired; +} +async function grabLiveUniqueConstraints({ tableName, config, }) { + const databaseName = config?.db_name || global.CONFIG?.db_name; + const schemaWhere = databaseName + ? "tc.TABLE_SCHEMA = ?" + : "tc.TABLE_SCHEMA = DATABASE()"; + const schemaValues = databaseName ? [databaseName] : []; + const rows = await querySchemaRows({ + query: ` + SELECT tc.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION + FROM information_schema.TABLE_CONSTRAINTS tc + INNER JOIN information_schema.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.TABLE_NAME = kcu.TABLE_NAME + WHERE ${schemaWhere} + AND tc.TABLE_NAME = ? + AND tc.CONSTRAINT_TYPE = 'UNIQUE' + ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + `, + values: [...schemaValues, tableName], + config, + }); + const map = new Map(); + for (const row of rows) { + if (!map.has(row.CONSTRAINT_NAME)) { + map.set(row.CONSTRAINT_NAME, []); + } + map.get(row.CONSTRAINT_NAME).push(row.COLUMN_NAME); + } + return [...map.entries()].map(([name, columns]) => ({ name, columns })); +} +export default async function syncUniqueConstraints({ table, config, }) { + const desired = grabDesiredUniqueConstraints(table); + const live = await grabLiveUniqueConstraints({ + tableName: table.tableName, + config, + }); + const matchedLiveNames = new Set(); + for (const want of desired) { + const byColumns = live.find((existing) => !matchedLiveNames.has(existing.name) && + columnsEqual(existing.columns, want.columns)); + if (byColumns) { + matchedLiveNames.add(byColumns.name); + continue; + } + const byName = live.find((existing) => existing.name === want.name); + if (byName && !matchedLiveNames.has(byName.name)) { + console.log(`Recreating changed unique constraint: ${want.name} on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(byName.name)}`, + config, + }); + matchedLiveNames.add(byName.name); + } + const fields = want.columns.map((col) => MariaDBQuoteGen(col)).join(", "); + console.log(`Creating unique constraint: ${want.name} (${want.columns.join(", ")}) on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD CONSTRAINT ${MariaDBQuoteGen(want.name)} UNIQUE (${fields})`, + config, + }); + } + for (const existing of live) { + if (matchedLiveNames.has(existing.name)) { + continue; + } + const stillDesired = desired.some((want) => columnsEqual(want.columns, existing.columns)); + if (stillDesired) { + continue; + } + console.log(`Dropping unique constraint: ${existing.name} on ${table.tableName}`); + try { + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(existing.name)}`, + config, + }); + } + catch (err) { + if (String(err?.message || "").includes("needed in a foreign key constraint")) { + console.warn(`Skipping drop of unique constraint ${existing.name}: required by a foreign key constraint`); + continue; + } + throw err; + } + } +} diff --git a/dist/lib/schema/update-table.js b/dist/lib/schema/update-table.js index 27f035a..6d7918b 100644 --- a/dist/lib/schema/update-table.js +++ b/dist/lib/schema/update-table.js @@ -1,12 +1,13 @@ -import buildColumnDefinition from "./build-column-definition"; +import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition"; import createTable from "./create-table"; -import getTableColumns from "./get-table-columns"; +import getTableColumns, {} from "./get-table-columns"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; import recreateTable from "./recreate-table"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { dropForeignKeysOnColumns } from "./sync-foreign-keys"; /** * Compare live COLUMN_TYPE with schema-mapped type. * Live types often include display widths (e.g. bigint(20) vs BIGINT). @@ -41,9 +42,85 @@ function vectorTypeDiverged(liveType, liveComment, field) { } return true; } +function normalizeDefault(value) { + if (value == null) + return ""; + let v = String(value).trim(); + // Strip surrounding quotes MariaDB may include + if ((v.startsWith("'") && v.endsWith("'")) || + (v.startsWith('"') && v.endsWith('"'))) { + v = v.slice(1, -1); + } + return v.toLowerCase().replace(/\s+/g, " "); +} +function expectedDefault(field) { + if (field.defaultValue !== undefined) { + return String(field.defaultValue); + } + if (field.defaultValueLiteral) { + return field.defaultValueLiteral; + } + return null; +} +function expectedOnUpdate(field) { + if (field.onUpdate) + return field.onUpdate; + if (field.onUpdateLiteral) + return field.onUpdateLiteral; + return null; +} +function defaultsMatch(liveDefault, expected) { + if (liveDefault === expected) + return true; + if (expected.includes("current_timestamp") && + liveDefault.includes("current_timestamp")) { + return true; + } + return false; +} +function columnAttributesDiverged(live, field) { + const wantsNotNull = fieldRequiresNotNull(field); + if (wantsNotNull && live.isNullable) { + return true; + } + if (!wantsNotNull && !live.isNullable && !field.primaryKey) { + return true; + } + const liveExtra = (live.extra || "").toLowerCase(); + const wantsAi = Boolean(field.autoIncrement); + const hasAi = liveExtra.includes("auto_increment"); + if (wantsAi !== hasAi) { + return true; + } + const wantsDefault = expectedDefault(field); + if (wantsDefault != null) { + const liveDefault = normalizeDefault(live.columnDefault); + const expected = normalizeDefault(wantsDefault); + if (!defaultsMatch(liveDefault, expected)) { + return true; + } + } + const wantsOnUpdate = expectedOnUpdate(field); + const hasOnUpdate = /on update/i.test(live.extra || ""); + if (wantsOnUpdate && !hasOnUpdate) { + return true; + } + if (!wantsOnUpdate && hasOnUpdate) { + return true; + } + if (wantsOnUpdate && hasOnUpdate) { + const liveOnUpdate = normalizeDefault((live.extra.match(/on update\s+(.+)/i) || [])[1] || ""); + const expectedOu = normalizeDefault(wantsOnUpdate); + if (liveOnUpdate && expectedOu && !defaultsMatch(liveOnUpdate, expectedOu)) { + return true; + } + } + return false; +} async function addColumn({ tableName, field, config, }) { console.log(`Adding column: ${tableName}.${field.fieldName}`); - const columnDef = buildColumnDefinition(field).trim(); + // omitUnique — unique constraints are applied by syncUniqueConstraints + const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`, config, @@ -51,7 +128,7 @@ async function addColumn({ tableName, field, config, }) { } async function modifyColumn({ tableName, field, config, }) { console.log(`Modifying column: ${tableName}.${field.fieldName}`); - const columnDef = buildColumnDefinition(field).trim(); + const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`, config, @@ -73,10 +150,7 @@ export default async function updateTable({ table, config, }) { await createTable({ table, config }); return; } - const liveFieldsMap = new Map(existingColumns.map((col) => [ - col.name, - { type: col.type.toLowerCase(), comment: col.comment || "" }, - ])); + const liveFieldsMap = new Map(existingColumns.map((col) => [col.name, col])); const codeFieldsMap = new Map((table.fields || []).map((f) => [f.fieldName, f])); const fieldsToAdd = []; const fieldsToModify = []; @@ -87,19 +161,18 @@ export default async function updateTable({ table, config, }) { continue; const liveField = liveFieldsMap.get(field.fieldName); if (!liveField) { - // Adding a new vector column can require rebuild if VECTOR INDEX - // constraints conflict; still try surgical add first. fieldsToAdd.push(field); } else { let typeDiverged = !columnTypesMatch(liveField.type, mapDataType(field)); if (isVectorField(field)) { - typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment, field); + typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment || "", field); if (typeDiverged) { needsVectorRecreate = true; } } - if (typeDiverged) { + const attrsDiverged = !typeDiverged && columnAttributesDiverged(liveField, field); + if (typeDiverged || attrsDiverged) { fieldsToModify.push(field); } } @@ -122,6 +195,12 @@ export default async function updateTable({ table, config, }) { } console.log(`Surgically updating table structure from database layout: ${table.tableName}`); if (fieldsToDrop.length > 0) { + // Drop FKs that reference columns being removed + await dropForeignKeysOnColumns({ + tableName: table.tableName, + columns: fieldsToDrop, + config, + }); const schemaCond = schemaCondition(config); const pkRows = await querySchemaRows({ query: `SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`, @@ -149,12 +228,31 @@ export default async function updateTable({ table, config, }) { } for (const indexName of indexesToDrop) { console.log(`Dropping index ${indexName} because it contains a dropped column`); - await runSchemaQuery({ - query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, - config, - }); + try { + await runSchemaQuery({ + query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, + config, + }); + } + catch (err) { + if (String(err?.message || "").includes("needed in a foreign key constraint")) { + console.warn(`Skipping drop of index ${indexName}: required by a foreign key constraint`); + continue; + } + throw err; + } } } + // Drop FKs on columns being modified (MODIFY can fail with FK present) + if (fieldsToModify.length > 0) { + await dropForeignKeysOnColumns({ + tableName: table.tableName, + columns: fieldsToModify + .map((f) => f.fieldName) + .filter((n) => Boolean(n)), + config, + }); + } for (const field of fieldsToAdd) { await addColumn({ tableName: table.tableName, field, config }); } diff --git a/dist/utils/export-archive.d.ts b/dist/utils/export-archive.d.ts index b794574..678688d 100644 --- a/dist/utils/export-archive.d.ts +++ b/dist/utils/export-archive.d.ts @@ -1,15 +1,17 @@ export declare const ExportArchiveMembers: { readonly SqlFileName: "dump.sql"; - readonly SchemaFileName: "schema.ts"; + readonly SchemaFileName: "schema"; }; export type ExportArchiveContents = { sql: string; - schemaTs: string; + schema: string; + /** Archive member basename, e.g. schema.ts / schema.json / schema.yaml */ + schemaFileName: string; }; export declare function isArchivePath(filePath: string): boolean; export declare function isSqlPath(filePath: string): boolean; /** - * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema.ts. + * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema file. */ export declare function writeExportArchive({ contents, outPath, }: { contents: ExportArchiveContents; diff --git a/dist/utils/export-archive.js b/dist/utils/export-archive.js index 9668843..f5ddf0a 100644 --- a/dist/utils/export-archive.js +++ b/dist/utils/export-archive.js @@ -2,6 +2,7 @@ import fs from "fs"; import path from "path"; import { AppData } from "../data/app-data"; import grabDirNames from "../data/grab-dir-names"; +import { isSchemaFileName } from "./resolve-and-load-data-file"; export const ExportArchiveMembers = { SqlFileName: "dump.sql", SchemaFileName: AppData.DbSchemaFileName, @@ -15,7 +16,7 @@ export function isSqlPath(filePath) { return filePath.toLowerCase().endsWith(".sql"); } /** - * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema.ts. + * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema file. */ export async function writeExportArchive({ contents, outPath, }) { const lower = outPath.toLowerCase(); @@ -25,7 +26,7 @@ export async function writeExportArchive({ contents, outPath, }) { } const members = { [ExportArchiveMembers.SqlFileName]: contents.sql, - [ExportArchiveMembers.SchemaFileName]: contents.schemaTs, + [contents.schemaFileName]: contents.schema, }; const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz"); if (gzip) { @@ -48,18 +49,20 @@ export async function readExportArchive(archivePath) { const files = await archive.files(); const sql = (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ?? (await readFirstMatching(files, (name) => name.endsWith(".sql"))); - const schemaTs = (await readArchiveMember(files, ExportArchiveMembers.SchemaFileName)) ?? - (await readFirstMatching(files, (name) => name.endsWith("schema.ts"))); + const schemaEntry = await readFirstSchemaMember(files); if (!sql) { throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`); } - if (!schemaTs) { - throw new Error(`Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`); + if (!schemaEntry) { + throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`); } - return { sql, schemaTs }; + return { + sql, + schema: schemaEntry.content, + schemaFileName: schemaEntry.fileName, + }; } async function readArchiveMember(files, name) { - // Exact match, or basename match for nested paths for (const [entry, file] of files) { if (entry === name || path.basename(entry) === name) { return await file.text(); @@ -75,15 +78,27 @@ async function readFirstMatching(files, predicate) { } return null; } +async function readFirstSchemaMember(files) { + for (const [entry, file] of files) { + const base = path.basename(entry); + if (isSchemaFileName(base)) { + return { + content: await file.text(), + fileName: base === AppData.DbSchemaFileName ? "schema.ts" : base, + }; + } + } + return null; +} async function writeZipArchive({ contents, outPath, }) { const { BUN_MARIADB_TEMP_DIR } = grabDirNames(); const tempDir = path.join(BUN_MARIADB_TEMP_DIR, `export-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); try { const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName); - const schemaPath = path.join(tempDir, ExportArchiveMembers.SchemaFileName); + const schemaPath = path.join(tempDir, contents.schemaFileName); fs.writeFileSync(sqlPath, contents.sql, "utf-8"); - fs.writeFileSync(schemaPath, contents.schemaTs, "utf-8"); + fs.writeFileSync(schemaPath, contents.schema, "utf-8"); const absOut = path.resolve(outPath); const proc = Bun.spawn([ "zip", @@ -91,7 +106,7 @@ async function writeZipArchive({ contents, outPath, }) { "-j", absOut, ExportArchiveMembers.SqlFileName, - ExportArchiveMembers.SchemaFileName, + contents.schemaFileName, ], { cwd: tempDir, stdout: "pipe", @@ -127,15 +142,18 @@ async function readZipArchive(archivePath) { throw new Error(`unzip failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`unzip\` is installed.`); } const sql = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql")); - const schemaTs = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SchemaFileName || - name.endsWith("schema.ts")); + const schemaHit = findSchemaFile(tempDir); if (!sql) { throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`); } - if (!schemaTs) { - throw new Error(`Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`); + if (!schemaHit) { + throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`); } - return { sql, schemaTs }; + return { + sql, + schema: schemaHit.content, + schemaFileName: schemaHit.fileName, + }; } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -157,3 +175,24 @@ function findFileContents(dir, predicate) { } return null; } +function findSchemaFile(dir) { + const stack = [dir]; + while (stack.length) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } + else if (isSchemaFileName(entry.name)) { + return { + content: fs.readFileSync(full, "utf-8"), + fileName: entry.name === AppData.DbSchemaFileName + ? "schema.ts" + : entry.name, + }; + } + } + } + return null; +} diff --git a/dist/utils/resolve-and-load-data-file.d.ts b/dist/utils/resolve-and-load-data-file.d.ts new file mode 100644 index 0000000..959795d --- /dev/null +++ b/dist/utils/resolve-and-load-data-file.d.ts @@ -0,0 +1,27 @@ +import { AppData } from "../data/app-data"; +export type DataFileFormat = "ts" | "js" | "json" | "yaml"; +export type ResolvedDataFile = { + path: string; + basename: string; + extension: (typeof AppData.SupportedDataFileExtensions)[number]; + format: DataFileFormat; +}; +export type LoadedDataFile = ResolvedDataFile & { + data: T; +}; +/** + * Resolve a basename (no extension) to an existing file among supported formats. + * Priority: .ts > .js > .json > .yaml > .yml + * Errors if multiple matches exist. + */ +export declare function resolveDataFile(dir: string, baseName: string): ResolvedDataFile | null; +export declare function supportedDataFileNames(baseName: string): string; +export declare function isSchemaFileName(name: string): boolean; +/** + * Load a data file as a plain object. + * - .ts / .js: require() and use default export (or module itself) + * - .json: JSON.parse + * - .yaml / .yml: Bun.YAML.parse + */ +export declare function loadDataFile(resolved: ResolvedDataFile): T; +export declare function resolveAndLoadDataFile(dir: string, baseName: string): LoadedDataFile | null; diff --git a/dist/utils/resolve-and-load-data-file.js b/dist/utils/resolve-and-load-data-file.js new file mode 100644 index 0000000..e806a61 --- /dev/null +++ b/dist/utils/resolve-and-load-data-file.js @@ -0,0 +1,86 @@ +import fs from "fs"; +import path from "path"; +import { AppData } from "../data/app-data"; +function extensionToFormat(extension) { + switch (extension) { + case ".ts": + return "ts"; + case ".js": + return "js"; + case ".json": + return "json"; + case ".yaml": + case ".yml": + return "yaml"; + } +} +/** + * Resolve a basename (no extension) to an existing file among supported formats. + * Priority: .ts > .js > .json > .yaml > .yml + * Errors if multiple matches exist. + */ +export function resolveDataFile(dir, baseName) { + const matches = []; + for (const extension of AppData.SupportedDataFileExtensions) { + const filePath = path.join(dir, `${baseName}${extension}`); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + matches.push({ + path: filePath, + basename: `${baseName}${extension}`, + extension, + format: extensionToFormat(extension), + }); + } + } + if (matches.length === 0) { + return null; + } + if (matches.length > 1) { + const found = matches.map((m) => m.basename).join(", "); + throw new Error(`Multiple \`${baseName}\` files found (${found}). Keep only one of: ${AppData.SupportedDataFileExtensions.join(", ")}`); + } + return matches[0]; +} +export function supportedDataFileNames(baseName) { + return AppData.SupportedDataFileExtensions.map((ext) => `\`${baseName}${ext}\``).join(", "); +} +export function isSchemaFileName(name) { + const base = path.basename(name); + if (base === AppData.DbSchemaFileName) { + return true; + } + return AppData.SupportedDataFileExtensions.some((ext) => base === `${AppData.DbSchemaFileName}${ext}`); +} +/** + * Load a data file as a plain object. + * - .ts / .js: require() and use default export (or module itself) + * - .json: JSON.parse + * - .yaml / .yml: Bun.YAML.parse + */ +export function loadDataFile(resolved) { + if (resolved.format === "ts" || resolved.format === "js") { + const imported = require(resolved.path); + const data = imported && typeof imported === "object" && "default" in imported + ? imported.default + : imported; + if (data == null) { + throw new Error(`No default export from \`${resolved.path}\`. Please export a default module.`); + } + return data; + } + const text = fs.readFileSync(resolved.path, "utf-8"); + if (resolved.format === "json") { + return JSON.parse(text); + } + return Bun.YAML.parse(text); +} +export function resolveAndLoadDataFile(dir, baseName) { + const resolved = resolveDataFile(dir, baseName); + if (!resolved) { + return null; + } + return { + ...resolved, + data: loadDataFile(resolved), + }; +} diff --git a/package.json b/package.json index db6810d..1064559 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moduletrace/bun-mariadb", - "version": "1.0.2", + "version": "1.0.3", "description": "Schema-driven MariaDB manager for Bun", "author": "Benjamin Toby", "license": "MIT", diff --git a/src/lib/schema/build-column-definition.ts b/src/lib/schema/build-column-definition.ts index 3cb75b9..4f15772 100644 --- a/src/lib/schema/build-column-definition.ts +++ b/src/lib/schema/build-column-definition.ts @@ -3,8 +3,14 @@ import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; +export type BuildColumnDefinitionOptions = { + /** UNIQUE is managed by syncUniqueConstraints on existing tables */ + omitUnique?: boolean; +}; + export default function buildColumnDefinition( field: BUN_MARIADB_FieldSchemaType, + options: BuildColumnDefinitionOptions = {}, ): string { if (!field.fieldName) { throw new Error("Field name is required"); @@ -29,7 +35,12 @@ export default function buildColumnDefinition( } // VECTOR columns cannot be UNIQUE in the usual sense - if (field.unique && !field.primaryKey && !isVectorField(field)) { + if ( + !options.omitUnique && + field.unique && + !field.primaryKey && + !isVectorField(field) + ) { parts.push("UNIQUE"); } @@ -51,3 +62,12 @@ export default function buildColumnDefinition( return parts.join(" "); } + +/** Whether schema field requires NOT NULL */ +export function fieldRequiresNotNull( + field: BUN_MARIADB_FieldSchemaType, +): boolean { + return Boolean( + field.notNullValue || field.primaryKey || isVectorField(field), + ); +} diff --git a/src/lib/schema/build-foreign-key-constraint.ts b/src/lib/schema/build-foreign-key-constraint.ts index a6d854b..76ac498 100644 --- a/src/lib/schema/build-foreign-key-constraint.ts +++ b/src/lib/schema/build-foreign-key-constraint.ts @@ -1,15 +1,29 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; +export function defaultForeignKeyName( + tableName: string, + fieldName: string, +): string { + return `fk_${tableName}_${fieldName}`; +} + +export function resolveForeignKeyName( + field: BUN_MARIADB_FieldSchemaType, + tableName: string, +): string { + const fieldName = field.fieldName || "column"; + return field.foreignKey?.foreignKeyName || defaultForeignKeyName(tableName, fieldName); +} + export default function buildForeignKeyConstraint( field: BUN_MARIADB_FieldSchemaType, + tableName: string, ): string { const fk = field.foreignKey!; - const constraintName = fk.foreignKeyName - ? `CONSTRAINT ${MariaDBQuoteGen(fk.foreignKeyName)} ` - : ""; + const constraintName = resolveForeignKeyName(field, tableName); - let constraint = `${constraintName}FOREIGN KEY (${MariaDBQuoteGen(field.fieldName!)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName!)}(${MariaDBQuoteGen(fk.destinationTableColumnName!)})`; + let constraint = `CONSTRAINT ${MariaDBQuoteGen(constraintName)} FOREIGN KEY (${MariaDBQuoteGen(field.fieldName!)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName!)}(${MariaDBQuoteGen(fk.destinationTableColumnName!)})`; if (fk.cascadeDelete) { constraint += " ON DELETE CASCADE"; diff --git a/src/lib/schema/create-table.ts b/src/lib/schema/create-table.ts index 4344a74..540cb60 100644 --- a/src/lib/schema/create-table.ts +++ b/src/lib/schema/create-table.ts @@ -31,7 +31,9 @@ export default async function createTable({ } if (field.foreignKey && !table.isVector) { - foreignKeys.push(buildForeignKeyConstraint(field)); + foreignKeys.push( + buildForeignKeyConstraint(field, table.tableName), + ); } } @@ -42,21 +44,21 @@ export default async function createTable({ if (table.uniqueConstraints) { for (const constraint of table.uniqueConstraints) { - if ( - constraint.constraintTableFields && - constraint.constraintTableFields.length > 0 - ) { - const fields = constraint.constraintTableFields - .map((field) => MariaDBQuoteGen(field.value)) - .join(", "); - const constraintName = - constraint.constraintName || - `unique_${fields.replace(/`/g, "")}`; + const columns = (constraint.constraintTableFields || []) + .map((field) => field.value) + .filter((value): value is string => Boolean(value)); - columnDefinitions.push( - `CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`, - ); + if (columns.length === 0) { + continue; } + + const fields = columns.map((col) => MariaDBQuoteGen(col)).join(", "); + const constraintName = + constraint.constraintName || `unique_${columns.join("_")}`; + + columnDefinitions.push( + `CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`, + ); } } diff --git a/src/lib/schema/get-table-columns.ts b/src/lib/schema/get-table-columns.ts index 5bc9b83..5c07781 100644 --- a/src/lib/schema/get-table-columns.ts +++ b/src/lib/schema/get-table-columns.ts @@ -6,6 +6,9 @@ export type ColumnInfoRow = { name: string; type: string; comment?: string; + isNullable: boolean; + columnDefault: string | null; + extra: string; }; export default async function getTableColumns({ @@ -20,8 +23,11 @@ export default async function getTableColumns({ COLUMN_NAME: string; COLUMN_TYPE: string; COLUMN_COMMENT: string; + IS_NULLABLE: string; + COLUMN_DEFAULT: string | null; + EXTRA: string; }>({ - query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, + query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, values: [...schemaCond.values, tableName], config, }); @@ -30,5 +36,8 @@ export default async function getTableColumns({ name: row.COLUMN_NAME, type: row.COLUMN_TYPE, comment: row.COLUMN_COMMENT, + isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", + columnDefault: row.COLUMN_DEFAULT, + extra: row.EXTRA || "", })); } diff --git a/src/lib/schema/handle-db-schema-table.ts b/src/lib/schema/handle-db-schema-table.ts index b80298c..88a42d8 100644 --- a/src/lib/schema/handle-db-schema-table.ts +++ b/src/lib/schema/handle-db-schema-table.ts @@ -4,7 +4,14 @@ import MariaDBQuoteGen from "./mariadb-quote-gen"; import resolveTable from "./resolve-table"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { + dropObsoleteForeignKeys, + ensureForeignKeys, +} from "./sync-foreign-keys"; import syncIndexes from "./sync-indexes"; +import syncPrimaryKey from "./sync-primary-key"; +import syncTableOptions from "./sync-table-options"; +import syncUniqueConstraints from "./sync-unique-constraints"; import updateTable from "./update-table"; import upsertDbManagerTable, { removeDbManagerTable, @@ -21,7 +28,6 @@ export default async function handleDBSchemaTable({ let tableExistsTracked = Boolean(db_manager_table_name); let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME); - let wasRenamed = false; if ( resolvedTable.tableNameOld && @@ -53,7 +59,6 @@ export default async function handleDBSchemaTable({ }); tableExistsTracked = true; tableExistsLive = true; - wasRenamed = true; } } @@ -64,17 +69,27 @@ export default async function handleDBSchemaTable({ config, }); } else { - if (!wasRenamed) { - await updateTable({ - table: resolvedTable, - config, - }); - } + // Columns first (also drops FKs on removed/modified columns) + await updateTable({ + table: resolvedTable, + config, + }); await upsertDbManagerTable({ tableName: resolvedTable.tableName, config, }); } + // Order matters: + // 1. Table options (engine/collation) + // 2. Drop obsolete/changed FKs (unlocks index cleanup) + // 3. Primary key + // 4. Indexes / uniques (FK columns need supporting indexes) + // 5. Ensure foreign keys exist + await syncTableOptions({ table: resolvedTable, config }); + await dropObsoleteForeignKeys({ table: resolvedTable, config }); + await syncPrimaryKey({ table: resolvedTable, config }); await syncIndexes({ table: resolvedTable, config }); + await syncUniqueConstraints({ table: resolvedTable, config }); + await ensureForeignKeys({ table: resolvedTable, config }); } diff --git a/src/lib/schema/handle-db-schema-tables.ts b/src/lib/schema/handle-db-schema-tables.ts index 0ca2b40..b809d62 100644 --- a/src/lib/schema/handle-db-schema-tables.ts +++ b/src/lib/schema/handle-db-schema-tables.ts @@ -91,7 +91,8 @@ export default async function handleDBSchemaTables( schemaTableNames.includes(row.TABLE_NAME) || !tablesToDrop.includes(row.TABLE_NAME) ) { - const list = referencedByRemaining.get(row.REFERENCED_TABLE_NAME) || []; + const list = + referencedByRemaining.get(row.REFERENCED_TABLE_NAME) || []; if (!list.includes(row.TABLE_NAME)) { list.push(row.TABLE_NAME); } @@ -118,6 +119,7 @@ export default async function handleDBSchemaTables( } await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config }); + try { for (const tableName of safeToDrop) { console.log(`Dropping table: ${tableName}`); diff --git a/src/lib/schema/sync-foreign-keys.ts b/src/lib/schema/sync-foreign-keys.ts new file mode 100644 index 0000000..af85347 --- /dev/null +++ b/src/lib/schema/sync-foreign-keys.ts @@ -0,0 +1,310 @@ +import type { + BUN_MARIADB_FieldSchemaType, + BUN_MARIADB_TableSchemaType, + BunMariaDBConfig, +} from "../../types"; +import buildForeignKeyConstraint, { + resolveForeignKeyName, +} from "./build-foreign-key-constraint"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; + +export type DesiredForeignKey = { + name: string; + column: string; + refTable: string; + refColumn: string; + cascadeDelete: boolean; + cascadeUpdate: boolean; + field: BUN_MARIADB_FieldSchemaType; +}; + +export type LiveForeignKey = { + name: string; + column: string; + refTable: string; + refColumn: string; + deleteRule: string; + updateRule: string; +}; + +function normalizeRule(rule: string | undefined | null): string { + return String(rule || "RESTRICT").toUpperCase().replace(/_/g, " "); +} + +function ruleIsCascade(rule: string): boolean { + return normalizeRule(rule) === "CASCADE"; +} + +function fkIdentityKey(fk: { + column: string; + refTable: string; + refColumn: string; +}): string { + return `${fk.column}\0${fk.refTable}\0${fk.refColumn}`; +} + +function rulesMatch( + live: LiveForeignKey, + desired: DesiredForeignKey, +): boolean { + return ( + ruleIsCascade(live.deleteRule) === desired.cascadeDelete && + ruleIsCascade(live.updateRule) === desired.cascadeUpdate + ); +} + +export function grabDesiredForeignKeys( + table: BUN_MARIADB_TableSchemaType, +): DesiredForeignKey[] { + if (table.isVector) { + return []; + } + + const desired: DesiredForeignKey[] = []; + + for (const field of table.fields || []) { + const fk = field.foreignKey; + if ( + !field.fieldName || + !fk?.destinationTableName || + !fk.destinationTableColumnName + ) { + continue; + } + + desired.push({ + name: resolveForeignKeyName(field, table.tableName), + column: field.fieldName, + refTable: fk.destinationTableName, + refColumn: fk.destinationTableColumnName, + cascadeDelete: Boolean(fk.cascadeDelete), + cascadeUpdate: Boolean(fk.cascadeUpdate), + field, + }); + } + + return desired; +} + +export async function grabLiveForeignKeys({ + tableName, + config, +}: { + tableName: string; + config?: BunMariaDBConfig; +}): Promise { + const databaseName = config?.db_name || global.CONFIG?.db_name; + const schemaWhere = databaseName + ? "rc.CONSTRAINT_SCHEMA = ?" + : "rc.CONSTRAINT_SCHEMA = DATABASE()"; + const schemaValues = databaseName ? [databaseName] : []; + + const rows = await querySchemaRows<{ + CONSTRAINT_NAME: string; + COLUMN_NAME: string; + REFERENCED_TABLE_NAME: string; + REFERENCED_COLUMN_NAME: string; + DELETE_RULE: string; + UPDATE_RULE: string; + }>({ + query: ` + SELECT + rc.CONSTRAINT_NAME, + kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME, + rc.DELETE_RULE, + rc.UPDATE_RULE + FROM information_schema.REFERENTIAL_CONSTRAINTS rc + INNER JOIN information_schema.KEY_COLUMN_USAGE kcu + ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.TABLE_NAME = kcu.TABLE_NAME + WHERE ${schemaWhere} + AND rc.TABLE_NAME = ? + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY rc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + `, + values: [...schemaValues, tableName], + config, + }); + + // Single-column FKs only (schema model is one FK per field) + const byName = new Map(); + for (const row of rows) { + if (byName.has(row.CONSTRAINT_NAME)) { + continue; + } + byName.set(row.CONSTRAINT_NAME, { + name: row.CONSTRAINT_NAME, + column: row.COLUMN_NAME, + refTable: row.REFERENCED_TABLE_NAME, + refColumn: row.REFERENCED_COLUMN_NAME, + deleteRule: row.DELETE_RULE, + updateRule: row.UPDATE_RULE, + }); + } + + return [...byName.values()]; +} + +export async function dropForeignKey({ + tableName, + constraintName, + config, +}: { + tableName: string; + constraintName: string; + config?: BunMariaDBConfig; +}): Promise { + console.log( + `Dropping foreign key: ${constraintName} on ${tableName}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP FOREIGN KEY ${MariaDBQuoteGen(constraintName)}`, + config, + }); +} + +export async function dropForeignKeysOnColumns({ + tableName, + columns, + config, +}: { + tableName: string; + columns: string[]; + config?: BunMariaDBConfig; +}): Promise { + if (columns.length === 0) return; + + const live = await grabLiveForeignKeys({ tableName, config }); + const colSet = new Set(columns); + + for (const fk of live) { + if (colSet.has(fk.column)) { + await dropForeignKey({ + tableName, + constraintName: fk.name, + config, + }); + } + } +} + +/** + * Drop foreign keys that are removed from schema or need recreation + * (name/cascade/target changed). Run before index cleanup. + */ +export async function dropObsoleteForeignKeys({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + const desired = grabDesiredForeignKeys(table); + const live = await grabLiveForeignKeys({ + tableName: table.tableName, + config, + }); + + const keepNames = new Set(); + const droppedNames = new Set(); + + const dropOnce = async (name: string) => { + if (droppedNames.has(name)) return; + droppedNames.add(name); + await dropForeignKey({ + tableName: table.tableName, + constraintName: name, + config, + }); + }; + + for (const want of desired) { + const byIdentity = live.find( + (existing) => + !keepNames.has(existing.name) && + !droppedNames.has(existing.name) && + fkIdentityKey(existing) === fkIdentityKey(want), + ); + + if ( + byIdentity && + rulesMatch(byIdentity, want) && + byIdentity.name === want.name + ) { + keepNames.add(byIdentity.name); + continue; + } + + // Changed relationship → drop so ensureForeignKeys can recreate + if (byIdentity) { + await dropOnce(byIdentity.name); + } + + const byName = live.find((existing) => existing.name === want.name); + if (byName && byName !== byIdentity) { + await dropOnce(byName.name); + } + } + + for (const existing of live) { + if (keepNames.has(existing.name) || droppedNames.has(existing.name)) { + continue; + } + + // Not in schema anymore + await dropOnce(existing.name); + } +} + +/** + * Ensure all desired foreign keys exist. Run after indexes/uniques. + */ +export async function ensureForeignKeys({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + const desired = grabDesiredForeignKeys(table); + const live = await grabLiveForeignKeys({ + tableName: table.tableName, + config, + }); + + for (const want of desired) { + const exists = live.some( + (existing) => + fkIdentityKey(existing) === fkIdentityKey(want) && + rulesMatch(existing, want) && + existing.name === want.name, + ); + + if (exists) { + continue; + } + + console.log( + `Creating foreign key: ${want.name} (${want.column} → ${want.refTable}.${want.refColumn}) on ${table.tableName}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD ${buildForeignKeyConstraint(want.field, table.tableName)}`, + config, + }); + } +} + +export default async function syncForeignKeys({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + await dropObsoleteForeignKeys({ table, config }); + await ensureForeignKeys({ table, config }); +} diff --git a/src/lib/schema/sync-indexes.ts b/src/lib/schema/sync-indexes.ts index c1be727..4f112cf 100644 --- a/src/lib/schema/sync-indexes.ts +++ b/src/lib/schema/sync-indexes.ts @@ -7,6 +7,7 @@ import isVectorField from "./is-vector-field"; import MariaDBQuoteGen from "./mariadb-quote-gen"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { grabDesiredUniqueConstraints } from "./sync-unique-constraints"; function isVectorIndexDef( index: BUN_MARIADB_IndexSchemaType, @@ -28,6 +29,88 @@ function vectorDistanceMetric(index: BUN_MARIADB_IndexSchemaType): string { return "euclidean"; } +/** Normalize schema index type vs information_schema.INDEX_TYPE */ +function indexTypesMatch( + liveType: string, + schemaIndex: BUN_MARIADB_IndexSchemaType, + table: BUN_MARIADB_TableSchemaType, +): boolean { + const live = (liveType || "").toUpperCase(); + const schemaType = schemaIndex.indexType?.toUpperCase(); + + if (isVectorIndexDef(schemaIndex, table)) { + // MariaDB may report VECTOR indexes as BTREE or VECTOR depending on version + return live === "VECTOR" || live === "BTREE" || live === ""; + } + + if (!schemaType || schemaType === "BTREE") { + // Default / unspecified → BTREE + return live === "BTREE" || live === ""; + } + + if (schemaType === "FULLTEXT") { + return live === "FULLTEXT"; + } + + if (schemaType === "SPATIAL") { + return live === "SPATIAL"; + } + + if (schemaType === "HASH") { + return live === "HASH"; + } + + return live === schemaType; +} + +async function createIndex({ + table, + index, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + index: BUN_MARIADB_IndexSchemaType; + config?: BunMariaDBConfig; +}): Promise { + if ( + !index.indexName || + !index.indexTableFields || + index.indexTableFields.length === 0 + ) { + return; + } + + if (isVectorIndexDef(index, table)) { + console.log(`Creating Vector index: ${index.indexName}`); + const targetField = MariaDBQuoteGen(index.indexTableFields[0]!); + const distanceMetric = vectorDistanceMetric(index); + + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`, + config, + }); + return; + } + + console.log(`Creating standard index: ${index.indexName}`); + const fields = index.indexTableFields + .map((field) => MariaDBQuoteGen(field)) + .join(", "); + const typeUpper = index.indexType?.toUpperCase(); + const isSpecialType = + typeUpper === "FULLTEXT" || typeUpper === "SPATIAL"; + const indexPrefix = isSpecialType ? `${typeUpper} ` : ""; + const indexSuffix = + !isSpecialType && (typeUpper === "BTREE" || typeUpper === "HASH") + ? ` USING ${typeUpper}` + : ""; + + await runSchemaQuery({ + query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`, + config, + }); +} + export default async function syncIndexes({ table, config, @@ -62,17 +145,8 @@ export default async function syncIndexes({ protectedConstraintRows.map((r) => r.CONSTRAINT_NAME), ); - // Column-level UNIQUE creates an index often named after the column - for (const field of table.fields || []) { - if (field.unique && field.fieldName) { - protectedIndexNames.add(field.fieldName); - } - } - - for (const constraint of table.uniqueConstraints || []) { - if (constraint.constraintName) { - protectedIndexNames.add(constraint.constraintName); - } + for (const constraint of grabDesiredUniqueConstraints(table)) { + protectedIndexNames.add(constraint.name); } const existingIndexesMap = new Map< @@ -106,6 +180,7 @@ export default async function syncIndexes({ query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, config, }); + existingIndexesMap.delete(indexName); } catch (err: any) { if ( String(err?.message || "").includes( @@ -124,8 +199,13 @@ export default async function syncIndexes({ const columnsMatch = details.columns.length === schemaColumns.length && details.columns.every((col, idx) => col === schemaColumns[idx]); + const typeMatch = indexTypesMatch( + details.type, + schemaIndex, + table, + ); - if (!columnsMatch) { + if (!columnsMatch || !typeMatch) { console.log(`Recreating changed index: ${indexName}`); await runSchemaQuery({ query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, @@ -146,35 +226,7 @@ export default async function syncIndexes({ } if (!existingIndexesMap.has(index.indexName)) { - if (isVectorIndexDef(index, table)) { - console.log(`Creating Vector index: ${index.indexName}`); - const targetField = MariaDBQuoteGen(index.indexTableFields[0]!); - const distanceMetric = vectorDistanceMetric(index); - - await runSchemaQuery({ - query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`, - config, - }); - } else { - console.log(`Creating standard index: ${index.indexName}`); - const fields = index.indexTableFields - .map((field) => MariaDBQuoteGen(field)) - .join(", "); - const typeUpper = index.indexType?.toUpperCase(); - const isSpecialType = - typeUpper === "FULLTEXT" || typeUpper === "SPATIAL"; - const indexPrefix = isSpecialType ? `${typeUpper} ` : ""; - const indexSuffix = - !isSpecialType && - (typeUpper === "BTREE" || typeUpper === "HASH") - ? ` USING ${typeUpper}` - : ""; - - await runSchemaQuery({ - query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`, - config, - }); - } + await createIndex({ table, index, config }); } } } diff --git a/src/lib/schema/sync-primary-key.ts b/src/lib/schema/sync-primary-key.ts new file mode 100644 index 0000000..beeecfb --- /dev/null +++ b/src/lib/schema/sync-primary-key.ts @@ -0,0 +1,83 @@ +import type { + BUN_MARIADB_TableSchemaType, + BunMariaDBConfig, +} from "../../types"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +import schemaCondition from "./schema-condition"; + +function columnsEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((col, i) => col === b[i]); +} + +export function grabDesiredPrimaryKeyColumns( + table: BUN_MARIADB_TableSchemaType, +): string[] { + return (table.fields || []) + .filter((field) => field.primaryKey && field.fieldName) + .map((field) => field.fieldName!); +} + +async function grabLivePrimaryKeyColumns({ + tableName, + config, +}: { + tableName: string; + config?: BunMariaDBConfig; +}): Promise { + const schemaCond = schemaCondition(config); + const rows = await querySchemaRows<{ + COLUMN_NAME: string; + ORDINAL_POSITION: number; + }>({ + query: ` + SELECT COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.KEY_COLUMN_USAGE + WHERE ${schemaCond.where} + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION + `, + values: [...schemaCond.values, tableName], + config, + }); + + return rows.map((row) => row.COLUMN_NAME); +} + +export default async function syncPrimaryKey({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + const desired = grabDesiredPrimaryKeyColumns(table); + const live = await grabLivePrimaryKeyColumns({ + tableName: table.tableName, + config, + }); + + if (columnsEqual(desired, live)) { + return; + } + + if (live.length > 0) { + console.log(`Dropping primary key on ${table.tableName}`); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`, + config, + }); + } + + if (desired.length > 0) { + const cols = desired.map((col) => MariaDBQuoteGen(col)).join(", "); + console.log( + `Creating primary key (${desired.join(", ")}) on ${table.tableName}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD PRIMARY KEY (${cols})`, + config, + }); + } +} diff --git a/src/lib/schema/sync-table-options.ts b/src/lib/schema/sync-table-options.ts new file mode 100644 index 0000000..758532c --- /dev/null +++ b/src/lib/schema/sync-table-options.ts @@ -0,0 +1,57 @@ +import type { + BUN_MARIADB_TableSchemaType, + BunMariaDBConfig, +} from "../../types"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; +import schemaCondition from "./schema-condition"; + +export default async function syncTableOptions({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + const schemaCond = schemaCondition(config); + const rows = await querySchemaRows<{ + ENGINE: string; + TABLE_COLLATION: string; + }>({ + query: `SELECT ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE'`, + values: [...schemaCond.values, table.tableName], + config, + }); + + const live = rows[0]; + if (!live) { + return; + } + + const alters: string[] = []; + + if ((live.ENGINE || "").toUpperCase() !== "INNODB") { + alters.push("ENGINE=InnoDB"); + } + + if (table.collation) { + const liveCollation = (live.TABLE_COLLATION || "").toLowerCase(); + if (liveCollation !== table.collation.toLowerCase()) { + alters.push( + `CONVERT TO CHARACTER SET utf8mb4 COLLATE ${table.collation}`, + ); + } + } + + if (alters.length === 0) { + return; + } + + console.log( + `Updating table options on ${table.tableName}: ${alters.join(", ")}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ${alters.join(", ")}`, + config, + }); +} diff --git a/src/lib/schema/sync-unique-constraints.ts b/src/lib/schema/sync-unique-constraints.ts new file mode 100644 index 0000000..fcaf187 --- /dev/null +++ b/src/lib/schema/sync-unique-constraints.ts @@ -0,0 +1,213 @@ +import type { + BUN_MARIADB_TableSchemaType, + BunMariaDBConfig, +} from "../../types"; +import isVectorField from "./is-vector-field"; +import MariaDBQuoteGen from "./mariadb-quote-gen"; +import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; + +type UniqueConstraintLive = { + name: string; + columns: string[]; +}; + +type UniqueConstraintDesired = { + name: string; + columns: string[]; +}; + +function columnsKey(columns: string[]): string { + return columns.join("\0"); +} + +function columnsEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((col, i) => col === b[i]); +} + +function defaultConstraintName(columns: string[]): string { + return `unique_${columns.join("_")}`; +} + +export function grabDesiredUniqueConstraints( + table: BUN_MARIADB_TableSchemaType, +): UniqueConstraintDesired[] { + const desired: UniqueConstraintDesired[] = []; + const seenColumnSets = new Set(); + + for (const constraint of table.uniqueConstraints || []) { + const columns = (constraint.constraintTableFields || []) + .map((field) => field.value) + .filter((value): value is string => Boolean(value)); + + if (columns.length === 0) { + continue; + } + + const key = columnsKey(columns); + if (seenColumnSets.has(key)) { + continue; + } + seenColumnSets.add(key); + + desired.push({ + name: constraint.constraintName || defaultConstraintName(columns), + columns, + }); + } + + for (const field of table.fields || []) { + if ( + !field.fieldName || + !field.unique || + field.primaryKey || + isVectorField(field) + ) { + continue; + } + + const columns = [field.fieldName]; + const key = columnsKey(columns); + if (seenColumnSets.has(key)) { + continue; + } + seenColumnSets.add(key); + + // Matches MariaDB's default name for column-level UNIQUE + desired.push({ + name: field.fieldName, + columns, + }); + } + + return desired; +} + +async function grabLiveUniqueConstraints({ + tableName, + config, +}: { + tableName: string; + config?: BunMariaDBConfig; +}): Promise { + const databaseName = config?.db_name || global.CONFIG?.db_name; + const schemaWhere = databaseName + ? "tc.TABLE_SCHEMA = ?" + : "tc.TABLE_SCHEMA = DATABASE()"; + const schemaValues = databaseName ? [databaseName] : []; + + const rows = await querySchemaRows<{ + CONSTRAINT_NAME: string; + COLUMN_NAME: string; + ORDINAL_POSITION: number; + }>({ + query: ` + SELECT tc.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION + FROM information_schema.TABLE_CONSTRAINTS tc + INNER JOIN information_schema.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.TABLE_NAME = kcu.TABLE_NAME + WHERE ${schemaWhere} + AND tc.TABLE_NAME = ? + AND tc.CONSTRAINT_TYPE = 'UNIQUE' + ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + `, + values: [...schemaValues, tableName], + config, + }); + + const map = new Map(); + for (const row of rows) { + if (!map.has(row.CONSTRAINT_NAME)) { + map.set(row.CONSTRAINT_NAME, []); + } + map.get(row.CONSTRAINT_NAME)!.push(row.COLUMN_NAME); + } + + return [...map.entries()].map(([name, columns]) => ({ name, columns })); +} + +export default async function syncUniqueConstraints({ + table, + config, +}: { + table: BUN_MARIADB_TableSchemaType; + config?: BunMariaDBConfig; +}): Promise { + const desired = grabDesiredUniqueConstraints(table); + const live = await grabLiveUniqueConstraints({ + tableName: table.tableName, + config, + }); + + const matchedLiveNames = new Set(); + + for (const want of desired) { + const byColumns = live.find( + (existing) => + !matchedLiveNames.has(existing.name) && + columnsEqual(existing.columns, want.columns), + ); + + if (byColumns) { + matchedLiveNames.add(byColumns.name); + continue; + } + + const byName = live.find((existing) => existing.name === want.name); + if (byName && !matchedLiveNames.has(byName.name)) { + console.log( + `Recreating changed unique constraint: ${want.name} on ${table.tableName}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(byName.name)}`, + config, + }); + matchedLiveNames.add(byName.name); + } + + const fields = want.columns.map((col) => MariaDBQuoteGen(col)).join(", "); + console.log( + `Creating unique constraint: ${want.name} (${want.columns.join(", ")}) on ${table.tableName}`, + ); + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD CONSTRAINT ${MariaDBQuoteGen(want.name)} UNIQUE (${fields})`, + config, + }); + } + + for (const existing of live) { + if (matchedLiveNames.has(existing.name)) { + continue; + } + + const stillDesired = desired.some((want) => + columnsEqual(want.columns, existing.columns), + ); + if (stillDesired) { + continue; + } + + console.log( + `Dropping unique constraint: ${existing.name} on ${table.tableName}`, + ); + try { + await runSchemaQuery({ + query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(existing.name)}`, + config, + }); + } catch (err: any) { + if ( + String(err?.message || "").includes( + "needed in a foreign key constraint", + ) + ) { + console.warn( + `Skipping drop of unique constraint ${existing.name}: required by a foreign key constraint`, + ); + continue; + } + throw err; + } + } +} diff --git a/src/lib/schema/update-table.ts b/src/lib/schema/update-table.ts index 34af85a..606e1e5 100644 --- a/src/lib/schema/update-table.ts +++ b/src/lib/schema/update-table.ts @@ -3,15 +3,18 @@ import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig, } from "../../types"; -import buildColumnDefinition from "./build-column-definition"; +import buildColumnDefinition, { + fieldRequiresNotNull, +} from "./build-column-definition"; import createTable from "./create-table"; -import getTableColumns from "./get-table-columns"; +import getTableColumns, { type ColumnInfoRow } from "./get-table-columns"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; import recreateTable from "./recreate-table"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +import { dropForeignKeysOnColumns } from "./sync-foreign-keys"; /** * Compare live COLUMN_TYPE with schema-mapped type. @@ -54,6 +57,95 @@ function vectorTypeDiverged( return true; } +function normalizeDefault(value: string | null | undefined): string { + if (value == null) return ""; + let v = String(value).trim(); + // Strip surrounding quotes MariaDB may include + if ( + (v.startsWith("'") && v.endsWith("'")) || + (v.startsWith('"') && v.endsWith('"')) + ) { + v = v.slice(1, -1); + } + return v.toLowerCase().replace(/\s+/g, " "); +} + +function expectedDefault(field: BUN_MARIADB_FieldSchemaType): string | null { + if (field.defaultValue !== undefined) { + return String(field.defaultValue); + } + if (field.defaultValueLiteral) { + return field.defaultValueLiteral; + } + return null; +} + +function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null { + if (field.onUpdate) return field.onUpdate; + if (field.onUpdateLiteral) return field.onUpdateLiteral; + return null; +} + +function defaultsMatch(liveDefault: string, expected: string): boolean { + if (liveDefault === expected) return true; + if ( + expected.includes("current_timestamp") && + liveDefault.includes("current_timestamp") + ) { + return true; + } + return false; +} + +function columnAttributesDiverged( + live: ColumnInfoRow, + field: BUN_MARIADB_FieldSchemaType, +): boolean { + const wantsNotNull = fieldRequiresNotNull(field); + if (wantsNotNull && live.isNullable) { + return true; + } + if (!wantsNotNull && !live.isNullable && !field.primaryKey) { + return true; + } + + const liveExtra = (live.extra || "").toLowerCase(); + const wantsAi = Boolean(field.autoIncrement); + const hasAi = liveExtra.includes("auto_increment"); + if (wantsAi !== hasAi) { + return true; + } + + const wantsDefault = expectedDefault(field); + if (wantsDefault != null) { + const liveDefault = normalizeDefault(live.columnDefault); + const expected = normalizeDefault(wantsDefault); + if (!defaultsMatch(liveDefault, expected)) { + return true; + } + } + + const wantsOnUpdate = expectedOnUpdate(field); + const hasOnUpdate = /on update/i.test(live.extra || ""); + if (wantsOnUpdate && !hasOnUpdate) { + return true; + } + if (!wantsOnUpdate && hasOnUpdate) { + return true; + } + if (wantsOnUpdate && hasOnUpdate) { + const liveOnUpdate = normalizeDefault( + (live.extra.match(/on update\s+(.+)/i) || [])[1] || "", + ); + const expectedOu = normalizeDefault(wantsOnUpdate); + if (liveOnUpdate && expectedOu && !defaultsMatch(liveOnUpdate, expectedOu)) { + return true; + } + } + + return false; +} + async function addColumn({ tableName, field, @@ -64,7 +156,8 @@ async function addColumn({ config?: BunMariaDBConfig; }): Promise { console.log(`Adding column: ${tableName}.${field.fieldName}`); - const columnDef = buildColumnDefinition(field).trim(); + // omitUnique — unique constraints are applied by syncUniqueConstraints + const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`, config, @@ -81,7 +174,7 @@ async function modifyColumn({ config?: BunMariaDBConfig; }): Promise { console.log(`Modifying column: ${tableName}.${field.fieldName}`); - const columnDef = buildColumnDefinition(field).trim(); + const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`, config, @@ -122,10 +215,7 @@ export default async function updateTable({ } const liveFieldsMap = new Map( - existingColumns.map((col) => [ - col.name, - { type: col.type.toLowerCase(), comment: col.comment || "" }, - ]), + existingColumns.map((col) => [col.name, col]), ); const codeFieldsMap = new Map( (table.fields || []).map((f) => [f.fieldName, f]), @@ -142,8 +232,6 @@ export default async function updateTable({ const liveField = liveFieldsMap.get(field.fieldName); if (!liveField) { - // Adding a new vector column can require rebuild if VECTOR INDEX - // constraints conflict; still try surgical add first. fieldsToAdd.push(field); } else { let typeDiverged = !columnTypesMatch( @@ -154,7 +242,7 @@ export default async function updateTable({ if (isVectorField(field)) { typeDiverged = vectorTypeDiverged( liveField.type, - liveField.comment, + liveField.comment || "", field, ); if (typeDiverged) { @@ -162,7 +250,10 @@ export default async function updateTable({ } } - if (typeDiverged) { + const attrsDiverged = + !typeDiverged && columnAttributesDiverged(liveField, field); + + if (typeDiverged || attrsDiverged) { fieldsToModify.push(field); } } @@ -196,6 +287,13 @@ export default async function updateTable({ ); if (fieldsToDrop.length > 0) { + // Drop FKs that reference columns being removed + await dropForeignKeysOnColumns({ + tableName: table.tableName, + columns: fieldsToDrop, + config, + }); + const schemaCond = schemaCondition(config); const pkRows = await querySchemaRows<{ COLUMN_NAME: string }>({ @@ -235,13 +333,38 @@ export default async function updateTable({ console.log( `Dropping index ${indexName} because it contains a dropped column`, ); - await runSchemaQuery({ - query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, - config, - }); + try { + await runSchemaQuery({ + query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, + config, + }); + } catch (err: any) { + if ( + String(err?.message || "").includes( + "needed in a foreign key constraint", + ) + ) { + console.warn( + `Skipping drop of index ${indexName}: required by a foreign key constraint`, + ); + continue; + } + throw err; + } } } + // Drop FKs on columns being modified (MODIFY can fail with FK present) + if (fieldsToModify.length > 0) { + await dropForeignKeysOnColumns({ + tableName: table.tableName, + columns: fieldsToModify + .map((f) => f.fieldName) + .filter((n): n is string => Boolean(n)), + config, + }); + } + for (const field of fieldsToAdd) { await addColumn({ tableName: table.tableName, field, config }); }