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:
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user