fix(db): handle mysql information schema field casing

This commit is contained in:
ciat-777
2026-03-15 15:27:09 +08:00
parent 32789947b5
commit f78cf434a9
2 changed files with 108 additions and 39 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { normalizeDefaultValue, normalizeSqlType } from './schemaIntrospection.js'; import { normalizeDefaultValue, normalizeSqlType, readMySqlField } from './schemaIntrospection.js';
describe('schema introspection normalization', () => { describe('schema introspection normalization', () => {
it('normalizes booleans consistently across dialects', () => { it('normalizes booleans consistently across dialects', () => {
@@ -13,4 +13,10 @@ describe('schema introspection normalization', () => {
expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false'); expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false');
expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')"); expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')");
}); });
it('reads mysql information_schema fields regardless of casing', () => {
expect(readMySqlField({ COLUMN_TYPE: 'varchar(191)' }, 'column_type')).toBe('varchar(191)');
expect(readMySqlField({ column_type: 'text' }, 'column_type')).toBe('text');
expect(readMySqlField({ Table_Name: 'settings' }, 'table_name')).toBe('settings');
});
}); });
+101 -38
View File
@@ -122,6 +122,20 @@ type PostgresForeignKeyRow = {
delete_rule: string | null; delete_rule: string | null;
}; };
export function readMySqlField<T>(row: Record<string, unknown>, field: string): T | undefined {
const exact = row[field];
if (exact !== undefined) return exact as T;
const upper = row[field.toUpperCase()];
if (upper !== undefined) return upper as T;
const lower = row[field.toLowerCase()];
if (lower !== undefined) return lower as T;
const matchedKey = Object.keys(row).find((key) => key.toLowerCase() === field.toLowerCase());
return matchedKey ? row[matchedKey] as T : undefined;
}
function splitMigrationStatements(sqlText: string): string[] { function splitMigrationStatements(sqlText: string): string[] {
return sqlText return sqlText
.split('--> statement-breakpoint') .split('--> statement-breakpoint')
@@ -452,16 +466,20 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
try { try {
const [tableRows] = await connection.query(` const [tableRows] = await connection.query(`
SELECT table_name SELECT table_name AS table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE' AND table_type = 'BASE TABLE'
ORDER BY table_name ASC ORDER BY table_name ASC
`); `);
const tableNames = (tableRows as Array<{ table_name: string }>).map((row) => row.table_name); const tableNames = (tableRows as Array<Record<string, unknown>>)
.map((row) => readMySqlField<string>(row, 'table_name'))
.filter((tableName): tableName is string => typeof tableName === 'string');
const [primaryKeyRows] = await connection.query(` const [primaryKeyRows] = await connection.query(`
SELECT kcu.table_name, kcu.column_name SELECT
kcu.table_name AS table_name,
kcu.column_name AS column_name
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu JOIN information_schema.key_column_usage kcu
ON tc.constraint_schema = kcu.constraint_schema ON tc.constraint_schema = kcu.constraint_schema
@@ -470,10 +488,20 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
WHERE tc.table_schema = DATABASE() WHERE tc.table_schema = DATABASE()
AND tc.constraint_type = 'PRIMARY KEY' AND tc.constraint_type = 'PRIMARY KEY'
`); `);
const primaryKeys = new Set((primaryKeyRows as MySqlPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`)); const primaryKeys = new Set((primaryKeyRows as Array<Record<string, unknown>>).map((row) => {
const tableName = readMySqlField<string>(row, 'table_name');
const columnName = readMySqlField<string>(row, 'column_name');
return tableName && columnName ? `${tableName}.${columnName}` : null;
}).filter((value): value is string => value !== null));
const [columnRows] = await connection.query(` const [columnRows] = await connection.query(`
SELECT table_name, column_name, data_type, column_type, is_nullable, column_default SELECT
table_name AS table_name,
column_name AS column_name,
data_type AS data_type,
column_type AS column_type,
is_nullable AS is_nullable,
column_default AS column_default
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
ORDER BY table_name ASC, ordinal_position ASC ORDER BY table_name ASC, ordinal_position ASC
@@ -484,40 +512,61 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
tableMap.set(tableName, { columns: {} }); tableMap.set(tableName, { columns: {} });
} }
for (const row of columnRows as MySqlColumnRow[]) { for (const row of columnRows as Array<Record<string, unknown>>) {
const logicalType = normalizeSqlType('mysql', row.column_type || row.data_type, row.column_name, row.column_default); const tableName = readMySqlField<string>(row, 'table_name');
tableMap.get(row.table_name)!.columns[row.column_name] = { const columnName = readMySqlField<string>(row, 'column_name');
const declaredType = readMySqlField<string>(row, 'column_type') || readMySqlField<string>(row, 'data_type');
const isNullable = readMySqlField<string>(row, 'is_nullable');
const columnDefault = readMySqlField<string | null>(row, 'column_default') ?? null;
if (!tableName || !columnName || !declaredType) {
continue;
}
const logicalType = normalizeSqlType('mysql', declaredType, columnName, columnDefault);
tableMap.get(tableName)!.columns[columnName] = {
logicalType, logicalType,
notNull: row.is_nullable === 'NO', notNull: isNullable === 'NO',
defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`) defaultValue: primaryKeys.has(`${tableName}.${columnName}`)
? null ? null
: normalizeDefaultValueForColumn(row.column_default, logicalType), : normalizeDefaultValueForColumn(columnDefault, logicalType),
primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`), primaryKey: primaryKeys.has(`${tableName}.${columnName}`),
}; };
} }
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!])); const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
const [indexRows] = await connection.query(` const [indexRows] = await connection.query(`
SELECT table_name, index_name, column_name, non_unique SELECT
table_name AS table_name,
index_name AS index_name,
column_name AS column_name,
non_unique AS non_unique
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND index_name <> 'PRIMARY' AND index_name <> 'PRIMARY'
ORDER BY table_name ASC, index_name ASC, seq_in_index ASC ORDER BY table_name ASC, index_name ASC, seq_in_index ASC
`); `);
const indexGroups = new Map<string, SchemaContractIndex>(); const indexGroups = new Map<string, SchemaContractIndex>();
for (const row of indexRows as MySqlIndexRow[]) { for (const row of indexRows as Array<Record<string, unknown>>) {
const key = `${row.table_name}:${row.index_name}`; const tableName = readMySqlField<string>(row, 'table_name');
const indexName = readMySqlField<string>(row, 'index_name');
const columnName = readMySqlField<string>(row, 'column_name');
const nonUnique = readMySqlField<number>(row, 'non_unique');
if (!tableName || !indexName || !columnName) {
continue;
}
const key = `${tableName}:${indexName}`;
const existing = indexGroups.get(key); const existing = indexGroups.get(key);
if (existing) { if (existing) {
existing.columns.push(row.column_name); existing.columns.push(columnName);
continue; continue;
} }
indexGroups.set(key, { indexGroups.set(key, {
name: row.index_name, name: indexName,
table: row.table_name, table: tableName,
columns: [row.column_name], columns: [columnName],
unique: Number(row.non_unique) === 0, unique: Number(nonUnique) === 0,
}); });
} }
@@ -529,12 +578,12 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
const [foreignKeyRows] = await connection.query(` const [foreignKeyRows] = await connection.query(`
SELECT SELECT
kcu.table_name, kcu.table_name AS table_name,
kcu.constraint_name, kcu.constraint_name AS constraint_name,
kcu.column_name, kcu.column_name AS column_name,
kcu.referenced_table_name, kcu.referenced_table_name AS referenced_table_name,
kcu.referenced_column_name, kcu.referenced_column_name AS referenced_column_name,
rc.delete_rule rc.delete_rule AS delete_rule
FROM information_schema.key_column_usage kcu FROM information_schema.key_column_usage kcu
JOIN information_schema.referential_constraints rc JOIN information_schema.referential_constraints rc
ON rc.constraint_schema = kcu.constraint_schema ON rc.constraint_schema = kcu.constraint_schema
@@ -544,20 +593,30 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC
`); `);
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>(); const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
for (const row of foreignKeyRows as MySqlForeignKeyRow[]) { for (const row of foreignKeyRows as Array<Record<string, unknown>>) {
const key = `${row.table_name}:${row.constraint_name}`; const tableName = readMySqlField<string>(row, 'table_name');
const constraintName = readMySqlField<string>(row, 'constraint_name');
const columnName = readMySqlField<string>(row, 'column_name');
const referencedTableName = readMySqlField<string>(row, 'referenced_table_name');
const referencedColumnName = readMySqlField<string>(row, 'referenced_column_name');
const deleteRule = readMySqlField<string | null>(row, 'delete_rule') ?? null;
if (!tableName || !constraintName || !columnName || !referencedTableName || !referencedColumnName) {
continue;
}
const key = `${tableName}:${constraintName}`;
const existing = foreignKeyGroups.get(key); const existing = foreignKeyGroups.get(key);
if (existing) { if (existing) {
existing.columns.push(row.column_name); existing.columns.push(columnName);
existing.referencedColumns.push(row.referenced_column_name); existing.referencedColumns.push(referencedColumnName);
continue; continue;
} }
foreignKeyGroups.set(key, { foreignKeyGroups.set(key, {
table: row.table_name, table: tableName,
columns: [row.column_name], columns: [columnName],
referencedTable: row.referenced_table_name, referencedTable: referencedTableName,
referencedColumns: [row.referenced_column_name], referencedColumns: [referencedColumnName],
onDelete: normalizeDeleteRule(row.delete_rule), onDelete: normalizeDeleteRule(deleteRule),
}); });
} }
@@ -736,13 +795,17 @@ function readBootstrapSql(dialect: Exclude<SchemaIntrospectionDialect, 'sqlite'>
async function resetMySqlSchema(connection: mysql.Connection): Promise<void> { async function resetMySqlSchema(connection: mysql.Connection): Promise<void> {
await connection.query('SET FOREIGN_KEY_CHECKS = 0'); await connection.query('SET FOREIGN_KEY_CHECKS = 0');
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT table_name SELECT table_name AS table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE' AND table_type = 'BASE TABLE'
`); `);
for (const row of rows as Array<{ table_name: string }>) { for (const row of rows as Array<Record<string, unknown>>) {
await connection.query(`DROP TABLE IF EXISTS \`${row.table_name}\``); const tableName = readMySqlField<string>(row, 'table_name');
if (!tableName) {
continue;
}
await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``);
} }
await connection.query('SET FOREIGN_KEY_CHECKS = 1'); await connection.query('SET FOREIGN_KEY_CHECKS = 1');
} }