diff --git a/src/lib/schema/build-column-definition.ts b/src/lib/schema/build-column-definition.ts index 4f15772..aeb2312 100644 --- a/src/lib/schema/build-column-definition.ts +++ b/src/lib/schema/build-column-definition.ts @@ -2,6 +2,7 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; +import { escapeStringLiteral } from "../../utils/sql-identifier"; export type BuildColumnDefinitionOptions = { /** UNIQUE is managed by syncUniqueConstraints on existing tables */ @@ -46,7 +47,7 @@ export default function buildColumnDefinition( if (field.defaultValue !== undefined) { if (typeof field.defaultValue === "string") { - parts.push(`DEFAULT '${field.defaultValue.replace(/'/g, "''")}'`); + parts.push(`DEFAULT '${escapeStringLiteral(field.defaultValue)}'`); } else { parts.push(`DEFAULT ${field.defaultValue}`); } diff --git a/src/lib/schema/map-data-types.ts b/src/lib/schema/map-data-types.ts index 3d586d2..e3e6889 100644 --- a/src/lib/schema/map-data-types.ts +++ b/src/lib/schema/map-data-types.ts @@ -1,4 +1,5 @@ import type { BUN_MARIADB_FieldSchemaType } from "../../types"; +import { escapeStringLiteral } from "../../utils/sql-identifier"; export default function mapDataType( field: BUN_MARIADB_FieldSchemaType, @@ -82,13 +83,13 @@ export default function mapDataType( return "TINYINT(1)"; case "ENUM": { const enumVals = (field.options || []) - .map((v) => `'${String(v).replace(/'/g, "''")}'`) + .map((v) => `'${escapeStringLiteral(String(v))}'`) .join(", "); return `ENUM(${enumVals || "''"})`; } case "SET": { const setVals = (field.options || []) - .map((v) => `'${String(v).replace(/'/g, "''")}'`) + .map((v) => `'${escapeStringLiteral(String(v))}'`) .join(", "); return `SET(${setVals || "''"})`; } diff --git a/src/lib/schema/update-table.ts b/src/lib/schema/update-table.ts index 31ce9aa..1daf834 100644 --- a/src/lib/schema/update-table.ts +++ b/src/lib/schema/update-table.ts @@ -20,8 +20,8 @@ import { dropForeignKeysOnColumns } from "./sync-foreign-keys"; * Live types often include display widths (e.g. bigint(20) vs BIGINT). */ function columnTypesMatch(liveType: string, expectedType: string): boolean { - const live = liveType.toLowerCase().replace(/\s+/g, ""); - const expected = expectedType.toLowerCase().replace(/\s+/g, ""); + const live = normalizeSqlOutsideQuotes(liveType, ""); + const expected = normalizeSqlOutsideQuotes(expectedType, ""); if (live === expected) return true; // live may include display width: bigint(20) vs bigint @@ -56,27 +56,79 @@ 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, " "); +/** + * Lowercase SQL and collapse whitespace to `space`, leaving the contents of + * single-quoted literals untouched (ENUM options, string defaults). + */ +function normalizeSqlOutsideQuotes(sql: string, space: string): string { + return sql + .trim() + .split(/('(?:[^'\\]|''|\\.)*')/) + .map((part, i) => + i % 2 === 1 ? part : part.toLowerCase().replace(/\s+/g, space), + ) + .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 = { + 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) { - 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) { - return field.defaultValueLiteral; + return parseDefault(field.defaultValueLiteral); } - return null; + return { kind: "none" }; } function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null { @@ -85,17 +137,31 @@ function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null { 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; +function defaultsMatch(live: ParsedDefault, expected: ParsedDefault): boolean { + 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: string): boolean { + return value.trim() !== "" && Number.isFinite(Number(value)); +} + function columnAttributesDiverged( live: ColumnInfoRow, field: BUN_MARIADB_FieldSchemaType, @@ -116,12 +182,13 @@ function columnAttributesDiverged( } const wantsDefault = expectedDefault(field); - if (wantsDefault != null) { - const liveDefault = normalizeDefault(live.columnDefault); - const expected = normalizeDefault(wantsDefault); - if (!defaultsMatch(liveDefault, expected)) { - return true; - } + 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); @@ -133,15 +200,11 @@ function columnAttributesDiverged( return true; } if (wantsOnUpdate && hasOnUpdate) { - const liveOnUpdate = normalizeDefault( + const liveOnUpdate = parseDefault( (live.extra.match(/on update\s+(.+)/i) || [])[1] || "", ); - const expectedOu = normalizeDefault(wantsOnUpdate); - if ( - liveOnUpdate && - expectedOu && - !defaultsMatch(liveOnUpdate, expectedOu) - ) { + const expectedOu = parseDefault(wantsOnUpdate); + if (!defaultsMatch(liveOnUpdate, expectedOu)) { return true; } }