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
+21 -1
View File
@@ -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),
);
}
+18 -4
View File
@@ -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";
+16 -14
View File
@@ -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})`,
);
}
}
+10 -1
View File
@@ -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 || "",
}));
}
+23 -8
View File
@@ -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 });
}
+3 -1
View File
@@ -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}`);
+310
View File
@@ -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<LiveForeignKey[]> {
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<string, LiveForeignKey>();
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<void> {
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<void> {
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<void> {
const desired = grabDesiredForeignKeys(table);
const live = await grabLiveForeignKeys({
tableName: table.tableName,
config,
});
const keepNames = new Set<string>();
const droppedNames = new Set<string>();
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<void> {
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<void> {
await dropObsoleteForeignKeys({ table, config });
await ensureForeignKeys({ table, config });
}
+93 -41
View File
@@ -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<void> {
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 });
}
}
}
+83
View File
@@ -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<string[]> {
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<void> {
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,
});
}
}
+57
View File
@@ -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<void> {
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,
});
}
+213
View File
@@ -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<string>();
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<UniqueConstraintLive[]> {
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<string, string[]>();
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<void> {
const desired = grabDesiredUniqueConstraints(table);
const live = await grabLiveUniqueConstraints({
tableName: table.tableName,
config,
});
const matchedLiveNames = new Set<string>();
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;
}
}
}
+139 -16
View File
@@ -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<void> {
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<void> {
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 });
}