Files
bun-mariadb/dist/lib/schema/sync-foreign-keys.js
T
2026-07-30 07:19:07 +01:00

184 lines
6.6 KiB
JavaScript

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