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 }); }