Files
bun-mariadb/dist/lib/schema/update-table.js
T

374 lines
14 KiB
JavaScript

import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition";
import createTable from "./create-table";
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 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).
*/
function columnTypesMatch(liveType, expectedType) {
const live = normalizeSqlOutsideQuotes(liveType, "");
const expected = normalizeSqlOutsideQuotes(expectedType, "");
if (live === expected)
return true;
// live may include display width: bigint(20) vs bigint
if (live.startsWith(`${expected}(`))
return true;
// expected may include length live omits in some versions
if (expected.startsWith(`${live}(`))
return true;
return false;
}
function vectorTypeDiverged(liveType, liveComment, field) {
const dimensions = field.vectorSize || 1536;
const expectedNative = `vector(${dimensions})`;
const live = liveType.toLowerCase().replace(/\s+/g, "");
if (live === expectedNative)
return false;
// Legacy LONGTEXT storage with vector_size comment
if (live.startsWith("longtext") || live.startsWith("text")) {
const match = liveComment.match(/vector_size\s*=\s*(\d+)/i);
if (match && Number(match[1]) === dimensions) {
// Still legacy storage — treat as diverged so we can migrate to VECTOR
return true;
}
return true;
}
return true;
}
/**
* Lowercase SQL and collapse whitespace to `space`, leaving the contents of
* single-quoted literals untouched (ENUM options, string defaults).
*/
function normalizeSqlOutsideQuotes(sql, space) {
return sql
.trim()
.split(/('(?:[^'\\]|''|\\.)*')/)
.map((part, i) => i % 2 === 1 ? part : part.toLowerCase().replace(/\s+/g, space))
.join("");
}
/**
* Parse a default as MariaDB reports it in COLUMN_DEFAULT (or as written in
* `defaultValueLiteral`): unquoted NULL, an escaped quoted string literal,
* or an expression / number.
*/
function parseDefault(value) {
if (value == null)
return { kind: "none" };
const v = String(value).trim();
if (/^null$/i.test(v))
return { kind: "none" };
if (/^'(?:[^'\\]|''|\\.)*'$/s.test(v)) {
const escapes = {
n: "\n",
t: "\t",
r: "\r",
"0": "\0",
};
return {
kind: "string",
value: v
.slice(1, -1)
.replace(/''|\\(.)/gs, (m, ch) => m === "''" ? "'" : (escapes[ch] ?? ch)),
};
}
let expr = normalizeSqlOutsideQuotes(v, "");
// MariaDB may wrap expressions in parentheses
while (/^\(.*\)$/.test(expr) && balancedParens(expr.slice(1, -1))) {
expr = expr.slice(1, -1);
}
return { kind: "expr", value: expr };
}
function balancedParens(s) {
let depth = 0;
for (const ch of s) {
if (ch === "(")
depth++;
if (ch === ")" && --depth < 0)
return false;
}
return depth === 0;
}
function expectedDefault(field) {
if (field.defaultValue !== undefined) {
// Strings are emitted as quoted literals; numbers verbatim
if (typeof field.defaultValue === "string") {
return { kind: "string", value: field.defaultValue };
}
return parseDefault(String(field.defaultValue));
}
if (field.defaultValueLiteral) {
return parseDefault(field.defaultValueLiteral);
}
return { kind: "none" };
}
function expectedOnUpdate(field) {
if (field.onUpdate)
return field.onUpdate;
if (field.onUpdateLiteral)
return field.onUpdateLiteral;
return null;
}
function defaultsMatch(live, expected) {
if (live.kind === "none" || expected.kind === "none") {
return live.kind === expected.kind;
}
if (live.value === expected.value) {
// A string never equals an expression of the same text, except for
// numbers, which MariaDB reports unquoted on numeric columns
return live.kind === expected.kind || isNumeric(live.value);
}
// DECIMAL defaults are reported padded: 1.5 → 1.50
if (isNumeric(live.value) && isNumeric(expected.value)) {
return Number(live.value) === Number(expected.value);
}
if (live.kind === "expr" && expected.kind === "expr") {
// now() / current_timestamp / current_timestamp(N) are aliases
const ts = /^(current_timestamp|now|localtimestamp|localtime)(\(\d*\))?$/;
return ts.test(live.value) && ts.test(expected.value);
}
return false;
}
function isNumeric(value) {
return value.trim() !== "" && Number.isFinite(Number(value));
}
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);
const liveDefault = parseDefault(live.columnDefault);
// Older servers give TIMESTAMP columns an implicit default
const implicitDefault = wantsDefault.kind === "none" &&
field.dataType?.toUpperCase() === "TIMESTAMP";
if (!implicitDefault && !defaultsMatch(liveDefault, wantsDefault)) {
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 = parseDefault((live.extra.match(/on update\s+(.+)/i) || [])[1] || "");
const expectedOu = parseDefault(wantsOnUpdate);
if (!defaultsMatch(liveOnUpdate, expectedOu)) {
return true;
}
}
return false;
}
async function addColumn({ tableName, field, config, }) {
console.log(`Adding column: ${tableName}.${field.fieldName}`);
// 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,
});
}
async function modifyColumn({ tableName, field, config, }) {
console.log(`Modifying column: ${tableName}.${field.fieldName}`);
const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim();
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`,
config,
});
}
async function dropColumn({ tableName, fieldName, config, }) {
console.log(`Dropping column: ${tableName}.${fieldName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP COLUMN ${MariaDBQuoteGen(fieldName)}`,
config,
});
}
/**
* Drop + re-add a column (values discarded). Used when VECTOR dimensions change —
* MODIFY cannot resize VECTOR, and a full table rebuild is unnecessary.
*/
async function recreateColumn({ tableName, field, config, }) {
if (!field.fieldName)
return;
console.log(`Recreating column: ${tableName}.${field.fieldName} (values will be cleared)`);
await dropForeignKeysOnColumns({
tableName,
columns: [field.fieldName],
config,
});
const schemaCond = schemaCondition(config);
const indexRows = await querySchemaRows({
query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`,
values: [...schemaCond.values, tableName],
config,
});
const indexesToDrop = new Set();
for (const row of indexRows) {
if (row.COLUMN_NAME === field.fieldName) {
indexesToDrop.add(row.INDEX_NAME);
}
}
for (const indexName of indexesToDrop) {
console.log(`Dropping index ${indexName} because column ${field.fieldName} is being recreated`);
try {
await runSchemaQuery({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(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;
}
}
await dropColumn({ tableName, fieldName: field.fieldName, config });
await addColumn({ tableName, field, config });
}
export default async function updateTable({ table, config, }) {
const existingColumns = await getTableColumns({
tableName: table.tableName,
config,
});
if (existingColumns.length === 0) {
await createTable({ table, config });
return;
}
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 = [];
const fieldsToRecreate = [];
const fieldsToDrop = [];
for (const field of table.fields || []) {
if (!field.fieldName)
continue;
const liveField = liveFieldsMap.get(field.fieldName);
if (!liveField) {
fieldsToAdd.push(field);
}
else {
let mapped_data_type = mapDataType(field);
let typeDiverged = !columnTypesMatch(liveField.type, mapped_data_type);
if (isVectorField(field)) {
typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment || "", field);
if (typeDiverged) {
// VECTOR dimensions / storage cannot be MODIFYed — drop + re-add column
fieldsToRecreate.push(field);
continue;
}
}
const attrsDiverged = !typeDiverged && columnAttributesDiverged(liveField, field);
if (typeDiverged || attrsDiverged) {
fieldsToModify.push(field);
}
}
}
for (const col of existingColumns) {
if (!codeFieldsMap.has(col.name)) {
fieldsToDrop.push(col.name);
}
}
if (fieldsToAdd.length === 0 &&
fieldsToModify.length === 0 &&
fieldsToRecreate.length === 0 &&
fieldsToDrop.length === 0) {
return;
}
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'`,
values: [...schemaCond.values, table.tableName],
config,
});
const pkColumnNames = pkRows.map((r) => r.COLUMN_NAME);
if (fieldsToDrop.some((f) => pkColumnNames.includes(f))) {
console.log(`Dropping primary key because a PK column is being dropped`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`,
config,
});
}
const indexRows = await querySchemaRows({
query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`,
values: [...schemaCond.values, table.tableName],
config,
});
const indexesToDrop = new Set();
for (const row of indexRows) {
if (fieldsToDrop.includes(row.COLUMN_NAME)) {
indexesToDrop.add(row.INDEX_NAME);
}
}
for (const indexName of indexesToDrop) {
console.log(`Dropping index ${indexName} because it contains a dropped column`);
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 });
}
for (const field of fieldsToModify) {
await modifyColumn({ tableName: table.tableName, field, config });
}
for (const field of fieldsToRecreate) {
await recreateColumn({
tableName: table.tableName,
field,
config,
});
}
for (const fieldName of fieldsToDrop) {
await dropColumn({ tableName: table.tableName, fieldName, config });
}
}