Fix schema sync re-applying or ignoring column default changes

- Escape backslashes in string defaults and ENUM/SET options, so MariaDB
  stores the value as written instead of dropping the backslashes
- Parse live COLUMN_DEFAULT as none / escaped string literal / expression
  and compare strings exactly, numbers by value (1.5 vs 1.50) and
  expressions case-insensitively
- Detect removed defaults, and case or spacing changes in string defaults
  and ENUM/SET options, which were previously ignored

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 15:57:35 +01:00
co-authored by Claude Opus 5.5
parent b8dd453519
commit d872c3661a
3 changed files with 105 additions and 40 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types";
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
import { escapeStringLiteral } from "../../utils/sql-identifier";
export type BuildColumnDefinitionOptions = { export type BuildColumnDefinitionOptions = {
/** UNIQUE is managed by syncUniqueConstraints on existing tables */ /** UNIQUE is managed by syncUniqueConstraints on existing tables */
@@ -46,7 +47,7 @@ export default function buildColumnDefinition(
if (field.defaultValue !== undefined) { if (field.defaultValue !== undefined) {
if (typeof field.defaultValue === "string") { if (typeof field.defaultValue === "string") {
parts.push(`DEFAULT '${field.defaultValue.replace(/'/g, "''")}'`); parts.push(`DEFAULT '${escapeStringLiteral(field.defaultValue)}'`);
} else { } else {
parts.push(`DEFAULT ${field.defaultValue}`); parts.push(`DEFAULT ${field.defaultValue}`);
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import type { BUN_MARIADB_FieldSchemaType } from "../../types";
import { escapeStringLiteral } from "../../utils/sql-identifier";
export default function mapDataType( export default function mapDataType(
field: BUN_MARIADB_FieldSchemaType, field: BUN_MARIADB_FieldSchemaType,
@@ -82,13 +83,13 @@ export default function mapDataType(
return "TINYINT(1)"; return "TINYINT(1)";
case "ENUM": { case "ENUM": {
const enumVals = (field.options || []) const enumVals = (field.options || [])
.map((v) => `'${String(v).replace(/'/g, "''")}'`) .map((v) => `'${escapeStringLiteral(String(v))}'`)
.join(", "); .join(", ");
return `ENUM(${enumVals || "''"})`; return `ENUM(${enumVals || "''"})`;
} }
case "SET": { case "SET": {
const setVals = (field.options || []) const setVals = (field.options || [])
.map((v) => `'${String(v).replace(/'/g, "''")}'`) .map((v) => `'${escapeStringLiteral(String(v))}'`)
.join(", "); .join(", ");
return `SET(${setVals || "''"})`; return `SET(${setVals || "''"})`;
} }
+100 -37
View File
@@ -20,8 +20,8 @@ import { dropForeignKeysOnColumns } from "./sync-foreign-keys";
* Live types often include display widths (e.g. bigint(20) vs BIGINT). * Live types often include display widths (e.g. bigint(20) vs BIGINT).
*/ */
function columnTypesMatch(liveType: string, expectedType: string): boolean { function columnTypesMatch(liveType: string, expectedType: string): boolean {
const live = liveType.toLowerCase().replace(/\s+/g, ""); const live = normalizeSqlOutsideQuotes(liveType, "");
const expected = expectedType.toLowerCase().replace(/\s+/g, ""); const expected = normalizeSqlOutsideQuotes(expectedType, "");
if (live === expected) return true; if (live === expected) return true;
// live may include display width: bigint(20) vs bigint // live may include display width: bigint(20) vs bigint
@@ -56,27 +56,79 @@ function vectorTypeDiverged(
return true; return true;
} }
function normalizeDefault(value: string | null | undefined): string { /**
if (value == null) return ""; * Lowercase SQL and collapse whitespace to `space`, leaving the contents of
let v = String(value).trim(); * single-quoted literals untouched (ENUM options, string defaults).
// Strip surrounding quotes MariaDB may include */
if ( function normalizeSqlOutsideQuotes(sql: string, space: string): string {
(v.startsWith("'") && v.endsWith("'")) || return sql
(v.startsWith('"') && v.endsWith('"')) .trim()
) { .split(/('(?:[^'\\]|''|\\.)*')/)
v = v.slice(1, -1); .map((part, i) =>
} i % 2 === 1 ? part : part.toLowerCase().replace(/\s+/g, space),
return v.toLowerCase().replace(/\s+/g, " "); )
.join("");
} }
function expectedDefault(field: BUN_MARIADB_FieldSchemaType): string | null { type ParsedDefault =
| { kind: "none" }
| { kind: "string"; value: string }
| { kind: "expr"; value: string };
/**
* 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: string | null | undefined): ParsedDefault {
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: Record<string, string> = {
n: "\n",
t: "\t",
r: "\r",
"0": "\0",
};
return {
kind: "string",
value: v
.slice(1, -1)
.replace(/''|\\(.)/gs, (m, ch?: string) =>
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: string): boolean {
let depth = 0;
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")" && --depth < 0) return false;
}
return depth === 0;
}
function expectedDefault(field: BUN_MARIADB_FieldSchemaType): ParsedDefault {
if (field.defaultValue !== undefined) { if (field.defaultValue !== undefined) {
return String(field.defaultValue); // 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) { if (field.defaultValueLiteral) {
return field.defaultValueLiteral; return parseDefault(field.defaultValueLiteral);
} }
return null; return { kind: "none" };
} }
function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null { function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null {
@@ -85,17 +137,31 @@ function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null {
return null; return null;
} }
function defaultsMatch(liveDefault: string, expected: string): boolean { function defaultsMatch(live: ParsedDefault, expected: ParsedDefault): boolean {
if (liveDefault === expected) return true; if (live.kind === "none" || expected.kind === "none") {
if ( return live.kind === expected.kind;
expected.includes("current_timestamp") && }
liveDefault.includes("current_timestamp") if (live.value === expected.value) {
) { // A string never equals an expression of the same text, except for
return true; // 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; return false;
} }
function isNumeric(value: string): boolean {
return value.trim() !== "" && Number.isFinite(Number(value));
}
function columnAttributesDiverged( function columnAttributesDiverged(
live: ColumnInfoRow, live: ColumnInfoRow,
field: BUN_MARIADB_FieldSchemaType, field: BUN_MARIADB_FieldSchemaType,
@@ -116,12 +182,13 @@ function columnAttributesDiverged(
} }
const wantsDefault = expectedDefault(field); const wantsDefault = expectedDefault(field);
if (wantsDefault != null) { const liveDefault = parseDefault(live.columnDefault);
const liveDefault = normalizeDefault(live.columnDefault); // Older servers give TIMESTAMP columns an implicit default
const expected = normalizeDefault(wantsDefault); const implicitDefault =
if (!defaultsMatch(liveDefault, expected)) { wantsDefault.kind === "none" &&
return true; field.dataType?.toUpperCase() === "TIMESTAMP";
} if (!implicitDefault && !defaultsMatch(liveDefault, wantsDefault)) {
return true;
} }
const wantsOnUpdate = expectedOnUpdate(field); const wantsOnUpdate = expectedOnUpdate(field);
@@ -133,15 +200,11 @@ function columnAttributesDiverged(
return true; return true;
} }
if (wantsOnUpdate && hasOnUpdate) { if (wantsOnUpdate && hasOnUpdate) {
const liveOnUpdate = normalizeDefault( const liveOnUpdate = parseDefault(
(live.extra.match(/on update\s+(.+)/i) || [])[1] || "", (live.extra.match(/on update\s+(.+)/i) || [])[1] || "",
); );
const expectedOu = normalizeDefault(wantsOnUpdate); const expectedOu = parseDefault(wantsOnUpdate);
if ( if (!defaultsMatch(liveOnUpdate, expectedOu)) {
liveOnUpdate &&
expectedOu &&
!defaultsMatch(liveOnUpdate, expectedOu)
) {
return true; return true;
} }
} }