This commit is contained in:
2026-07-30 07:19:07 +01:00
parent 246c42a214
commit 0420d50f15
43 changed files with 1942 additions and 219 deletions
+7 -1
View File
@@ -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;
+9 -2
View File
@@ -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));
}
+3 -1
View File
@@ -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;
+10 -5
View File
@@ -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";
}
+9 -9
View File
@@ -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)}`;
+3
View File
@@ -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;
+4 -1
View File
@@ -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 || "",
}));
}
+20 -8
View File
@@ -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 });
}
+52
View File
@@ -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<LiveForeignKey[]>;
export declare function dropForeignKey({ tableName, constraintName, config, }: {
tableName: string;
constraintName: string;
config?: BunMariaDBConfig;
}): Promise<void>;
export declare function dropForeignKeysOnColumns({ tableName, columns, config, }: {
tableName: string;
columns: string[];
config?: BunMariaDBConfig;
}): Promise<void>;
/**
* 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<void>;
/**
* Ensure all desired foreign keys exist. Run after indexes/uniques.
*/
export declare function ensureForeignKeys({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
export default function syncForeignKeys({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
+183
View File
@@ -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 });
}
+61 -37
View File
@@ -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 });
}
}
}
+6
View File
@@ -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<void>;
+52
View File
@@ -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,
});
}
}
+5
View File
@@ -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<void>;
+33
View File
@@ -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,
});
}
+11
View File
@@ -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<void>;
export {};
+138
View File
@@ -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;
}
}
}
+114 -16
View File
@@ -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 });
}