refactor(db): model migration targets by dialect (#11271)

This commit is contained in:
Taesu
2026-09-13 07:17:07 +00:00
committed by GitHub
parent 133f6a2717
commit b93f73d183
+116 -120
View File
@@ -137,13 +137,10 @@ interface DatabaseIndexDefinition {
}
interface DatabaseColumnRow {
characterMaximumLength?: number | string | null;
CHARACTER_MAXIMUM_LENGTH?: number | string | null;
columnName?: string;
COLUMN_NAME?: string;
dataType?: string;
DATA_TYPE?: string;
maxLength?: number | string;
maxIndexBytes?: number | string | null;
MAX_INDEX_BYTES?: number | string | null;
tableName?: string;
TABLE_NAME?: string;
}
@@ -152,6 +149,12 @@ interface DatabaseColumnBound {
maxIndexBytes: number | null;
}
type MigrationTarget<
DatabaseType extends KyselyDatabaseType = KyselyDatabaseType,
> = DatabaseType extends "postgres" | "mssql"
? { type: DatabaseType; schema: string }
: { type: DatabaseType };
function createDatabaseIndexKey(tableName: string, indexName: string) {
return `${getPortableDatabaseIdentifierKey(tableName)}\u0000${getPortableDatabaseIdentifierKey(indexName)}`;
}
@@ -210,8 +213,7 @@ function toDatabaseIndexMap(indexes: readonly DatabaseIndexMetadata[]) {
async function getDatabaseIndexMap(
db: Kysely<unknown>,
dbType: KyselyDatabaseType,
schemaName: string,
target: MigrationTarget,
tableNames: readonly string[],
introspectIndexes: DatabaseIndexIntrospector | undefined,
) {
@@ -221,9 +223,10 @@ async function getDatabaseIndexMap(
}
let rows: readonly DatabaseIndexRow[];
if (dbType === "sqlite") {
rows = (
await sql<DatabaseIndexRow>`
switch (target.type) {
case "sqlite":
rows = (
await sql<DatabaseIndexRow>`
SELECT
tables.name AS "tableName",
index_list.name AS "indexName",
@@ -236,10 +239,11 @@ async function getDatabaseIndexMap(
INNER JOIN pragma_index_info(index_list.name) AS index_info
WHERE tables.type = 'table'
`.execute(db)
).rows;
} else if (dbType === "postgres") {
rows = (
await sql<DatabaseIndexRow>`
).rows;
break;
case "postgres":
rows = (
await sql<DatabaseIndexRow>`
SELECT
table_class.relname AS "tableName",
index_class.relname AS "indexName",
@@ -261,14 +265,15 @@ async function getDatabaseIndexMap(
LEFT JOIN pg_attribute AS index_attribute
ON index_attribute.attrelid = table_class.oid
AND index_attribute.attnum = index_column.attribute_number
WHERE table_namespace.nspname = ${schemaName}
WHERE table_namespace.nspname = ${target.schema}
AND table_class.relkind = 'r'
AND index_column.ordinality <= index_data.indnkeyatts
`.execute(db)
).rows;
} else if (dbType === "mysql") {
rows = (
await sql<DatabaseIndexRow>`
).rows;
break;
case "mysql":
rows = (
await sql<DatabaseIndexRow>`
SELECT
table_name AS tableName,
index_name AS indexName,
@@ -280,10 +285,11 @@ async function getDatabaseIndexMap(
FROM information_schema.statistics
WHERE table_schema = DATABASE()
`.execute(db)
).rows;
} else {
rows = (
await sql<DatabaseIndexRow>`
).rows;
break;
case "mssql":
rows = (
await sql<DatabaseIndexRow>`
SELECT
tables.name AS "tableName",
indexes.name AS "indexName",
@@ -304,11 +310,12 @@ async function getDatabaseIndexMap(
INNER JOIN sys.columns AS columns
ON columns.object_id = index_columns.object_id
AND columns.column_id = index_columns.column_id
WHERE table_schemas.name = ${schemaName}
WHERE table_schemas.name = ${target.schema}
AND indexes.name IS NOT NULL
AND index_columns.key_ordinal > 0
`.execute(db)
).rows;
).rows;
break;
}
const indexMetadata = new Map<string, DatabaseIndexMetadata>();
@@ -379,74 +386,57 @@ async function getDatabaseIndexMap(
async function getDatabaseColumnBounds(
db: Kysely<unknown>,
dbType: KyselyDatabaseType,
schemaName: string,
target: MigrationTarget,
) {
if (dbType !== "mysql" && dbType !== "mssql") {
return new Map<string, DatabaseColumnBound>();
}
let rows: readonly DatabaseColumnRow[];
if (dbType === "mysql") {
rows = (
await sql<DatabaseColumnRow>`
switch (target.type) {
case "postgres":
case "sqlite":
return new Map<string, DatabaseColumnBound>();
case "mysql":
rows = (
await sql<DatabaseColumnRow>`
SELECT
table_name AS tableName,
column_name AS columnName,
data_type AS dataType,
character_maximum_length AS characterMaximumLength
character_maximum_length * 4 AS maxIndexBytes
FROM information_schema.columns
WHERE table_schema = DATABASE()
`.execute(db)
).rows;
} else {
rows = (
await sql<DatabaseColumnRow>`
).rows;
break;
case "mssql":
rows = (
await sql<DatabaseColumnRow>`
SELECT
tables.name AS "tableName",
columns.name AS "columnName",
types.name AS "dataType",
columns.max_length AS "maxLength"
columns.max_length AS "maxIndexBytes"
FROM sys.columns AS columns
INNER JOIN sys.tables AS tables
ON tables.object_id = columns.object_id
INNER JOIN sys.schemas AS table_schemas
ON table_schemas.schema_id = tables.schema_id
INNER JOIN sys.types AS types
ON types.user_type_id = columns.user_type_id
WHERE table_schemas.name = ${schemaName}
WHERE table_schemas.name = ${target.schema}
`.execute(db)
).rows;
).rows;
break;
}
return new Map(
rows.flatMap((row) => {
const table = row.tableName ?? row.TABLE_NAME;
const column = row.columnName ?? row.COLUMN_NAME;
const dataType = (row.dataType ?? row.DATA_TYPE)?.toLowerCase();
if (!table || !column || !dataType) return [];
const bounds = new Map<string, DatabaseColumnBound>();
for (const row of rows) {
const table = row.tableName ?? row.TABLE_NAME;
const column = row.columnName ?? row.COLUMN_NAME;
if (!table || !column) continue;
if (dbType === "mysql") {
const characterLength =
row.characterMaximumLength ?? row.CHARACTER_MAXIMUM_LENGTH;
const maxIndexBytes =
characterLength === null || characterLength === undefined
? null
: Number(characterLength) * 4;
return [
[createDatabaseColumnKey(table, column), { maxIndexBytes }] as const,
];
}
const maxLength = Number(row.maxLength ?? -1);
return [
[
createDatabaseColumnKey(table, column),
{ maxIndexBytes: maxLength < 0 ? null : maxLength },
] as const,
];
}),
);
const maxIndexBytes = Number(
row.maxIndexBytes ?? row.MAX_INDEX_BYTES ?? -1,
);
bounds.set(createDatabaseColumnKey(table, column), {
maxIndexBytes: maxIndexBytes < 0 ? null : maxIndexBytes,
});
}
return bounds;
}
function assertExistingTableIndexFits({
@@ -614,65 +604,71 @@ export async function getMigrations(
process.exit(1);
}
let currentSchema = dbType === "mssql" ? await getMssqlSchema(db) : "public";
if (dbType === "postgres") {
currentSchema = await getPostgresSchema(db);
logger.debug(
`PostgreSQL migration: Using schema '${currentSchema}' (from search_path)`,
);
const allTableMetadata = await db.introspection.getTables();
let target: MigrationTarget;
let tableMetadata = allTableMetadata;
switch (dbType) {
case "postgres": {
const schema = await getPostgresSchema(db);
target = { type: "postgres", schema };
logger.debug(
`PostgreSQL migration: Using schema '${schema}' (from search_path)`,
);
try {
const schemas = await db.introspection.getSchemas();
if (!schemas.some(({ name }) => name === currentSchema)) {
logger.warn(
`Schema '${currentSchema}' does not exist. Create it before running migrations or check your database configuration.`,
try {
const schemas = await db.introspection.getSchemas();
if (!schemas.some(({ name }) => name === schema)) {
logger.warn(
`Schema '${schema}' does not exist. Create it before running migrations or check your database configuration.`,
);
}
} catch (error) {
logger.debug(
`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`,
);
}
} catch (error) {
logger.debug(
`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`,
/**
* Kysely 0.28 does not expose `isForeign`, while 0.29 adds foreign table metadata.
* @see https://github.com/kysely-org/kysely/pull/1494
*/
tableMetadata = allTableMetadata.filter(
(table) =>
table.schema === schema &&
!table.isView &&
!("isForeign" in table && table.isForeign),
);
logger.debug(
`Found ${tableMetadata.length} table(s) in schema '${schema}': ${tableMetadata.map((table) => table.name).join(", ") || "(none)"}`,
);
break;
}
} else if (dbType === "mssql") {
logger.debug(
`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`,
);
case "mssql": {
const schema = await getMssqlSchema(db);
target = { type: "mssql", schema };
logger.debug(
`SQL Server migration: Using schema '${schema}' (from the current user's default schema)`,
);
tableMetadata = allTableMetadata.filter(
(table) => table.schema === schema,
);
break;
}
case "mysql":
target = { type: "mysql" };
break;
case "sqlite":
target = { type: "sqlite" };
break;
}
const allTableMetadata = await db.introspection.getTables();
const databaseIndexMap = await getDatabaseIndexMap(
db,
dbType,
currentSchema,
target,
allTableMetadata.map((table) => table.name),
introspectIndexes,
);
const databaseColumnBounds = await getDatabaseColumnBounds(
db,
dbType,
currentSchema,
);
let tableMetadata = allTableMetadata;
if (dbType === "postgres") {
/**
* Kysely 0.28 does not expose `isForeign`, while 0.29 adds foreign table metadata.
* @see https://github.com/kysely-org/kysely/pull/1494
*/
tableMetadata = allTableMetadata.filter(
(table) =>
table.schema === currentSchema &&
!table.isView &&
!("isForeign" in table && table.isForeign),
);
logger.debug(
`Found ${tableMetadata.length} table(s) in schema '${currentSchema}': ${tableMetadata.map((table) => table.name).join(", ") || "(none)"}`,
);
} else if (dbType === "mssql") {
tableMetadata = allTableMetadata.filter(
(table) => table.schema === currentSchema,
);
}
const databaseColumnBounds = await getDatabaseColumnBounds(db, target);
// Columns the migration cannot fix: required, without a default, and never
// written by Better Auth. Reported so the CLI stops before an insert fails.
const schemaProblems = diffSchema(