Fix bugs, improve schema gen

This commit is contained in:
2026-10-14 01:47:55 +01:00
parent 2ddf450b9f
commit 155263a79f
25 changed files with 571 additions and 172 deletions
+17 -1
View File
@@ -1,4 +1,20 @@
import type { BUN_MARIADB_FieldSchemaType } from "../../types";
import { type BUN_MARIADB_FieldSchemaType, type BUN_MARIADB_ForeignKeyRule } from "../../types";
export declare function defaultForeignKeyName(tableName: string, fieldName: string): string;
export declare function resolveForeignKeyName(field: BUN_MARIADB_FieldSchemaType, tableName: string): string;
/**
* Normalize a referential action as MariaDB reports it in
* `information_schema.REFERENTIAL_CONSTRAINTS`. InnoDB treats `NO ACTION`
* exactly like `RESTRICT`, so both compare as `RESTRICT`.
*/
export declare function normalizeForeignKeyRule(rule: string | undefined | null): BUN_MARIADB_ForeignKeyRule;
/**
* Resolve the ON DELETE / ON UPDATE actions for a field's foreign key.
* Precedence: `foreignKey.onDelete` > field `onDeleteLiteral` / `onDelete`
* (legacy) > `cascadeDelete` > `RESTRICT`. Same for update, except the
* field-level `onUpdate*` keys are column `ON UPDATE` clauses, not FK rules.
*/
export declare function resolveForeignKeyRules(field: BUN_MARIADB_FieldSchemaType, tableName?: string): {
onDelete: BUN_MARIADB_ForeignKeyRule;
onUpdate: BUN_MARIADB_ForeignKeyRule;
};
export default function buildForeignKeyConstraint(field: BUN_MARIADB_FieldSchemaType, tableName: string): string;
+42 -4
View File
@@ -1,3 +1,4 @@
import { BUN_MARIADB_ForeignKeyRules, } from "../../types";
import MariaDBQuoteGen from "./mariadb-quote-gen";
export function defaultForeignKeyName(tableName, fieldName) {
return `fk_${tableName}_${fieldName}`;
@@ -6,15 +7,52 @@ export function resolveForeignKeyName(field, tableName) {
const fieldName = field.fieldName || "column";
return field.foreignKey?.foreignKeyName || defaultForeignKeyName(tableName, fieldName);
}
/**
* Normalize a referential action as MariaDB reports it in
* `information_schema.REFERENTIAL_CONSTRAINTS`. InnoDB treats `NO ACTION`
* exactly like `RESTRICT`, so both compare as `RESTRICT`.
*/
export function normalizeForeignKeyRule(rule) {
const normalized = String(rule || "RESTRICT")
.trim()
.toUpperCase()
.replace(/[_\s]+/g, " ");
if (normalized === "NO ACTION")
return "RESTRICT";
if (!BUN_MARIADB_ForeignKeyRules.includes(normalized)) {
throw new Error(`Invalid foreign key rule \`${rule}\`. Expected one of: ${BUN_MARIADB_ForeignKeyRules.join(", ")}`);
}
return normalized;
}
/**
* Resolve the ON DELETE / ON UPDATE actions for a field's foreign key.
* Precedence: `foreignKey.onDelete` > field `onDeleteLiteral` / `onDelete`
* (legacy) > `cascadeDelete` > `RESTRICT`. Same for update, except the
* field-level `onUpdate*` keys are column `ON UPDATE` clauses, not FK rules.
*/
export function resolveForeignKeyRules(field, tableName) {
const fk = field.foreignKey || {};
const onDelete = normalizeForeignKeyRule(fk.onDelete ||
field.onDeleteLiteral ||
field.onDelete ||
(fk.cascadeDelete ? "CASCADE" : "RESTRICT"));
const onUpdate = normalizeForeignKeyRule(fk.onUpdate || (fk.cascadeUpdate ? "CASCADE" : "RESTRICT"));
if ((onDelete === "SET NULL" || onUpdate === "SET NULL") &&
(field.notNullValue || field.primaryKey)) {
throw new Error(`Foreign key on \`${tableName ? `${tableName}.` : ""}${field.fieldName}\` uses SET NULL but the column is NOT NULL`);
}
return { onDelete, onUpdate };
}
export default function buildForeignKeyConstraint(field, tableName) {
const fk = field.foreignKey;
const constraintName = resolveForeignKeyName(field, tableName);
const { onDelete, onUpdate } = resolveForeignKeyRules(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";
if (onDelete !== "RESTRICT") {
constraint += ` ON DELETE ${onDelete}`;
}
if (fk.cascadeUpdate) {
constraint += " ON UPDATE CASCADE";
if (onUpdate !== "RESTRICT") {
constraint += ` ON UPDATE ${onUpdate}`;
}
return constraint;
}
+6 -6
View File
@@ -1,5 +1,4 @@
import buildColumnDefinition from "./build-column-definition";
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
import buildTableOptions from "./build-table-options";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery from "./run-schema-query";
@@ -8,16 +7,12 @@ export default async function createTable({ table, config, }) {
console.log(`Creating table: ${table.tableName}`);
}
const columnDefinitions = [];
const foreignKeys = [];
const primaryKeys = [];
for (const field of table.fields || []) {
columnDefinitions.push(buildColumnDefinition(field));
if (field.primaryKey && field.fieldName) {
primaryKeys.push(field.fieldName);
}
if (field.foreignKey) {
foreignKeys.push(buildForeignKeyConstraint(field, table.tableName));
}
}
if (primaryKeys.length > 0) {
const pkCols = primaryKeys.map((k) => MariaDBQuoteGen(k)).join(", ");
@@ -36,6 +31,11 @@ export default async function createTable({ table, config, }) {
columnDefinitions.push(`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`);
}
}
const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${[...columnDefinitions, ...foreignKeys].join(", ")})${buildTableOptions(table)}`;
/**
* Foreign keys are not declared inline. `handleDBSchemaTables` adds them
* with `ensureForeignKeys` once every table exists, so forward references,
* self references and FK cycles all work regardless of table order.
*/
const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${columnDefinitions.join(", ")})${buildTableOptions(table)}`;
await runSchemaQuery({ query: sql, config });
}
+3 -3
View File
@@ -3,7 +3,7 @@ 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 { dropObsoleteForeignKeys } from "./sync-foreign-keys";
import syncIndexes from "./sync-indexes";
import syncPrimaryKey from "./sync-primary-key";
import syncTableOptions from "./sync-table-options";
@@ -64,11 +64,11 @@ export default async function handleDBSchemaTable({ db_schema, config, table, db
// 2. Drop obsolete/changed FKs (unlocks index cleanup)
// 3. Primary key
// 4. Indexes / uniques (FK columns need supporting indexes)
// 5. Ensure foreign keys exist
// Foreign keys are ensured by `handleDBSchemaTables` after every table
// has been created/updated.
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 });
}
+14
View File
@@ -3,6 +3,8 @@ import { AppData } from "../../data/app-data";
import handleDBSchemaTable from "./handle-db-schema-table";
import getExistingTablesFromTablesManagerTable from "./get-existing-tables-from-tables-manager-table";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import resolveTable from "./resolve-table";
import { ensureForeignKeys } from "./sync-foreign-keys";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
import { removeDbManagerTable } from "./upsert-db-manager-table";
@@ -32,6 +34,18 @@ export default async function handleDBSchemaTables(params) {
existing_live_table,
});
}
/**
* Foreign keys last: every referenced table now exists, so forward
* references, self references and cycles between tables all resolve.
*/
for (const table of db_schema.tables) {
if (!table)
continue;
await ensureForeignKeys({
table: resolveTable(table, db_schema),
config,
});
}
/**
* Drop tables tracked by the manager but no longer in the schema.
* Skip drops when remaining schema tables still reference the table via FK
+11
View File
@@ -1,2 +1,13 @@
import type { BUN_MARIADB_DatabaseSchemaType, CreateDBSchemaParams } from "../../types";
/**
* Topologically order tables so referenced (parent) tables come before the
* tables that reference them. Declaration order is kept wherever the FK
* graph allows it.
*
* - Self references (e.g. `comments.parent_comment_id`) are ignored.
* - References to tables outside the schema are ignored.
* - Cycles do not throw: a table already on the current path is skipped,
* so every table is still emitted exactly once. Foreign keys are created
* after all tables exist, so cycles are safe to sync.
*/
export default function orderDBSchema({ db_schema, }: CreateDBSchemaParams): Promise<BUN_MARIADB_DatabaseSchemaType>;
+39 -47
View File
@@ -1,57 +1,49 @@
import _ from "lodash";
/**
* Topologically order tables so referenced (parent) tables come before the
* tables that reference them. Declaration order is kept wherever the FK
* graph allows it.
*
* - Self references (e.g. `comments.parent_comment_id`) are ignored.
* - References to tables outside the schema are ignored.
* - Cycles do not throw: a table already on the current path is skipped,
* so every table is still emitted exactly once. Foreign keys are created
* after all tables exist, so cycles are safe to sync.
*/
export default async function orderDBSchema({ db_schema, }) {
let new_db_schema = _.cloneDeep(db_schema);
const new_db_schema = _.cloneDeep(db_schema);
const tables = new_db_schema.tables;
let new_tables_set = new Set();
let new_tables_start_set = new Set();
let new_tables_end_set = new Set();
function setParentTable(table) {
const fields = table.fields;
let does_table_have_foreign_keys = false;
for (let f = 0; f < fields.length; f++) {
const field = fields[f];
if (!field)
const tables_by_name = new Map();
for (const table of tables) {
if (table)
tables_by_name.set(table.tableName, table);
}
const ordered = [];
const visited = new Set();
const visiting = new Set();
function visit(table) {
if (visited.has(table.tableName) || visiting.has(table.tableName)) {
return;
}
visiting.add(table.tableName);
for (const field of table.fields || []) {
const dst_table_name = field?.foreignKey?.destinationTableName;
if (!dst_table_name || dst_table_name === table.tableName) {
continue;
const dst_table_name = field.foreignKey?.destinationTableName;
if (dst_table_name) {
const fk_table = tables.find((tb) => tb.tableName == dst_table_name);
if (fk_table &&
!new_tables_start_set.has(fk_table) &&
!new_tables_end_set.has(fk_table)) {
setParentTable(fk_table);
}
new_tables_end_set.add(table);
if (fk_table) {
new_tables_start_set.add(fk_table);
}
does_table_have_foreign_keys = true;
}
const fk_table = tables_by_name.get(dst_table_name);
if (fk_table) {
visit(fk_table);
}
}
return { does_table_have_foreign_keys };
visiting.delete(table.tableName);
visited.add(table.tableName);
ordered.push(table);
}
for (let i = 0; i < tables.length; i++) {
const table = tables[i];
if (!table) {
continue;
}
let { does_table_have_foreign_keys } = setParentTable(table);
if (does_table_have_foreign_keys) {
new_tables_end_set.add(table);
}
else {
new_tables_start_set.add(table);
}
for (const table of tables) {
if (table)
visit(table);
}
const parsed_tables = [
...Array.from(new_tables_start_set),
...Array.from(new_tables_end_set),
];
for (let nt = 0; nt < parsed_tables.length; nt++) {
const new_table = parsed_tables[nt];
if (new_table) {
new_tables_set.add(new_table);
}
}
new_db_schema.tables = Array.from(new_tables_set);
new_db_schema.tables = ordered;
return new_db_schema;
}
+3 -3
View File
@@ -1,11 +1,11 @@
import type { BUN_MARIADB_FieldSchemaType, BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
import type { BUN_MARIADB_FieldSchemaType, BUN_MARIADB_ForeignKeyRule, BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
export type DesiredForeignKey = {
name: string;
column: string;
refTable: string;
refColumn: string;
cascadeDelete: boolean;
cascadeUpdate: boolean;
onDelete: BUN_MARIADB_ForeignKeyRule;
onUpdate: BUN_MARIADB_ForeignKeyRule;
field: BUN_MARIADB_FieldSchemaType;
};
export type LiveForeignKey = {
+6 -11
View File
@@ -1,18 +1,12 @@
import buildForeignKeyConstraint, { resolveForeignKeyName, } from "./build-foreign-key-constraint";
import buildForeignKeyConstraint, { normalizeForeignKeyRule, resolveForeignKeyName, resolveForeignKeyRules, } 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);
return (normalizeForeignKeyRule(live.deleteRule) === desired.onDelete &&
normalizeForeignKeyRule(live.updateRule) === desired.onUpdate);
}
export function grabDesiredForeignKeys(table) {
const desired = [];
@@ -23,13 +17,14 @@ export function grabDesiredForeignKeys(table) {
!fk.destinationTableColumnName) {
continue;
}
const { onDelete, onUpdate } = resolveForeignKeyRules(field, table.tableName);
desired.push({
name: resolveForeignKeyName(field, table.tableName),
column: field.fieldName,
refTable: fk.destinationTableName,
refColumn: fk.destinationTableColumnName,
cascadeDelete: Boolean(fk.cascadeDelete),
cascadeUpdate: Boolean(fk.cascadeUpdate),
onDelete,
onUpdate,
field,
});
}
+1 -1
View File
@@ -58,12 +58,12 @@ async function createIndex({ table, index, 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";
console.log(`Creating ${isSpecialType ? typeUpper : "standard"} index: ${index.indexName}`);
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
const indexSuffix = !isSpecialType && (typeUpper === "BTREE" || typeUpper === "HASH")
? ` USING ${typeUpper}`