380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
import { EnumType, StringType, TextType } from '@mikro-orm/core';
|
|
import { SchemaHelper } from '../../schema/SchemaHelper.js';
|
|
export class MySqlSchemaHelper extends SchemaHelper {
|
|
#cache = {};
|
|
static DEFAULT_VALUES = {
|
|
'now()': ['now()', 'current_timestamp'],
|
|
'current_timestamp(?)': ['current_timestamp(?)'],
|
|
0: ['0', 'false'],
|
|
};
|
|
getSchemaBeginning(charset, disableForeignKeys) {
|
|
if (disableForeignKeys) {
|
|
return `set names ${charset};\n${this.disableForeignKeysSQL()}\n\n`;
|
|
}
|
|
return `set names ${charset};\n\n`;
|
|
}
|
|
disableForeignKeysSQL() {
|
|
return 'set foreign_key_checks = 0;';
|
|
}
|
|
enableForeignKeysSQL() {
|
|
return 'set foreign_key_checks = 1;';
|
|
}
|
|
finalizeTable(table, charset, collate) {
|
|
let sql = ` default character set ${charset}`;
|
|
if (collate) {
|
|
sql += ` collate ${collate}`;
|
|
}
|
|
sql += ' engine = InnoDB';
|
|
if (table.comment) {
|
|
sql += ` comment = ${this.platform.quoteValue(table.comment)}`;
|
|
}
|
|
return sql;
|
|
}
|
|
getListTablesSQL() {
|
|
return `select table_name as table_name, nullif(table_schema, schema()) as schema_name, table_comment as table_comment from information_schema.tables where table_type = 'BASE TABLE' and table_schema = schema()`;
|
|
}
|
|
getListViewsSQL() {
|
|
return `select table_name as view_name, nullif(table_schema, schema()) as schema_name, view_definition from information_schema.views where table_schema = schema()`;
|
|
}
|
|
async loadViews(schema, connection, schemaName) {
|
|
const views = await connection.execute(this.getListViewsSQL());
|
|
for (const view of views) {
|
|
// MySQL information_schema.views.view_definition requires SHOW VIEW privilege
|
|
// and may return NULL. Use SHOW CREATE VIEW as fallback.
|
|
let definition = view.view_definition?.trim();
|
|
if (!definition) {
|
|
const createView = await connection.execute(`show create view \`${view.view_name}\``);
|
|
if (createView[0]?.['Create View']) {
|
|
// Extract SELECT statement from CREATE VIEW ... AS SELECT ...
|
|
const match = /\bAS\s+(.+)$/is.exec(createView[0]['Create View']);
|
|
definition = match?.[1]?.trim();
|
|
}
|
|
}
|
|
if (definition) {
|
|
schema.addView(view.view_name, view.schema_name ?? undefined, definition);
|
|
}
|
|
}
|
|
}
|
|
async loadInformationSchema(schema, connection, tables) {
|
|
if (tables.length === 0) {
|
|
return;
|
|
}
|
|
const columns = await this.getAllColumns(connection, tables);
|
|
const indexes = await this.getAllIndexes(connection, tables);
|
|
const checks = await this.getAllChecks(connection, tables);
|
|
const fks = await this.getAllForeignKeys(connection, tables);
|
|
const enums = await this.getAllEnumDefinitions(connection, tables);
|
|
for (const t of tables) {
|
|
const key = this.getTableKey(t);
|
|
const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
|
|
const pks = await this.getPrimaryKeys(connection, indexes[key], table.name, table.schema);
|
|
table.init(columns[key], indexes[key], checks[key], pks, fks[key], enums[key]);
|
|
}
|
|
}
|
|
async getAllIndexes(connection, tables) {
|
|
const sql = `select table_name as table_name, nullif(table_schema, schema()) as schema_name, index_name as index_name, non_unique as non_unique, column_name as column_name, index_type as index_type, sub_part as sub_part, collation as sort_order /*!80013 , expression as expression, is_visible as is_visible */
|
|
from information_schema.statistics where table_schema = database()
|
|
and table_name in (${tables.map(t => this.platform.quoteValue(t.table_name)).join(', ')})
|
|
order by schema_name, table_name, index_name, seq_in_index`;
|
|
const allIndexes = await connection.execute(sql);
|
|
const ret = {};
|
|
for (const index of allIndexes) {
|
|
const key = this.getTableKey(index);
|
|
const indexDef = {
|
|
columnNames: [index.column_name],
|
|
keyName: index.index_name,
|
|
unique: !index.non_unique,
|
|
primary: index.index_name === 'PRIMARY',
|
|
constraint: !index.non_unique,
|
|
};
|
|
// Capture column options (prefix length, sort order)
|
|
if (index.sub_part != null || index.sort_order === 'D') {
|
|
indexDef.columns = [
|
|
{
|
|
name: index.column_name,
|
|
...(index.sub_part != null && { length: index.sub_part }),
|
|
...(index.sort_order === 'D' && { sort: 'DESC' }),
|
|
},
|
|
];
|
|
}
|
|
// Capture index type for fulltext and spatial indexes
|
|
if (index.index_type === 'FULLTEXT') {
|
|
indexDef.type = 'fulltext';
|
|
} else if (index.index_type === 'SPATIAL') {
|
|
/* v8 ignore next */
|
|
indexDef.type = 'spatial';
|
|
}
|
|
// Capture invisible flag (MySQL 8.0.13+)
|
|
if (index.is_visible === 'NO') {
|
|
indexDef.invisible = true;
|
|
}
|
|
if (!index.column_name || index.expression?.match(/ where /i)) {
|
|
indexDef.expression = index.expression; // required for the `getCreateIndexSQL()` call
|
|
indexDef.expression = this.getCreateIndexSQL(index.table_name, indexDef, !!index.expression);
|
|
}
|
|
ret[key] ??= [];
|
|
ret[key].push(indexDef);
|
|
}
|
|
for (const key of Object.keys(ret)) {
|
|
ret[key] = await this.mapIndexes(ret[key]);
|
|
}
|
|
return ret;
|
|
}
|
|
getCreateIndexSQL(tableName, index, partialExpression = false) {
|
|
/* v8 ignore next */
|
|
if (index.expression && !partialExpression) {
|
|
return index.expression;
|
|
}
|
|
tableName = this.quote(tableName);
|
|
const keyName = this.quote(index.keyName);
|
|
let sql = `alter table ${tableName} add ${index.unique ? 'unique' : 'index'} ${keyName} `;
|
|
if (index.expression && partialExpression) {
|
|
sql += `(${index.expression})`;
|
|
return this.appendMySqlIndexSuffix(sql, index);
|
|
}
|
|
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
if (index.columnNames.some(column => column.includes('.'))) {
|
|
const columns = this.platform.getJsonIndexDefinition(index);
|
|
sql = `alter table ${tableName} add ${index.unique ? 'unique ' : ''}index ${keyName} `;
|
|
sql += `(${columns.join(', ')})`;
|
|
return this.appendMySqlIndexSuffix(sql, index);
|
|
}
|
|
// Build column list with advanced options
|
|
const columns = this.getIndexColumns(index);
|
|
sql += `(${columns})`;
|
|
return this.appendMySqlIndexSuffix(sql, index);
|
|
}
|
|
/**
|
|
* Build the column list for a MySQL index, with MySQL-specific handling for collation.
|
|
* MySQL requires collation to be specified as an expression: (column_name COLLATE collation_name)
|
|
*/
|
|
getIndexColumns(index) {
|
|
if (index.columns?.length) {
|
|
return index.columns
|
|
.map(col => {
|
|
const quotedName = this.quote(col.name);
|
|
// MySQL supports collation via expression: (column_name COLLATE collation_name)
|
|
// When collation is specified, wrap in parentheses as an expression
|
|
if (col.collation) {
|
|
let expr = col.length ? `${quotedName}(${col.length})` : quotedName;
|
|
expr = `(${expr} collate ${col.collation})`;
|
|
// Sort order comes after the expression
|
|
if (col.sort) {
|
|
expr += ` ${col.sort}`;
|
|
}
|
|
return expr;
|
|
}
|
|
// Standard column definition without collation
|
|
let colDef = quotedName;
|
|
// MySQL supports prefix length
|
|
if (col.length) {
|
|
colDef += `(${col.length})`;
|
|
}
|
|
// MySQL supports sort order
|
|
if (col.sort) {
|
|
colDef += ` ${col.sort}`;
|
|
}
|
|
return colDef;
|
|
})
|
|
.join(', ');
|
|
}
|
|
return index.columnNames.map(c => this.quote(c)).join(', ');
|
|
}
|
|
/**
|
|
* Append MySQL-specific index suffixes like INVISIBLE.
|
|
*/
|
|
appendMySqlIndexSuffix(sql, index) {
|
|
// MySQL 8.0+ supports INVISIBLE indexes
|
|
if (index.invisible) {
|
|
sql += ' invisible';
|
|
}
|
|
return sql;
|
|
}
|
|
async getAllColumns(connection, tables) {
|
|
const sql = `select table_name as table_name,
|
|
nullif(table_schema, schema()) as schema_name,
|
|
column_name as column_name,
|
|
column_default as column_default,
|
|
nullif(column_comment, '') as column_comment,
|
|
is_nullable as is_nullable,
|
|
data_type as data_type,
|
|
column_type as column_type,
|
|
column_key as column_key,
|
|
extra as extra,
|
|
generation_expression as generation_expression,
|
|
numeric_precision as numeric_precision,
|
|
numeric_scale as numeric_scale,
|
|
ifnull(datetime_precision, character_maximum_length) length
|
|
from information_schema.columns where table_schema = database() and table_name in (${tables.map(t => this.platform.quoteValue(t.table_name))})
|
|
order by ordinal_position`;
|
|
const allColumns = await connection.execute(sql);
|
|
const str = val => (val != null ? '' + val : val);
|
|
const extra = val =>
|
|
val.replace(/auto_increment|default_generated|(stored|virtual) generated/i, '').trim() || undefined;
|
|
const ret = {};
|
|
for (const col of allColumns) {
|
|
const mappedType = this.platform.getMappedType(col.column_type);
|
|
const defaultValue = str(
|
|
this.normalizeDefaultValue(
|
|
mappedType.compareAsType() === 'boolean' && ['0', '1'].includes(col.column_default)
|
|
? ['false', 'true'][+col.column_default]
|
|
: col.column_default,
|
|
col.length,
|
|
),
|
|
);
|
|
const key = this.getTableKey(col);
|
|
const generated = col.generation_expression
|
|
? `(${col.generation_expression.replaceAll(`\\'`, `'`)}) ${col.extra.match(/stored generated/i) ? 'stored' : 'virtual'}`
|
|
: undefined;
|
|
ret[key] ??= [];
|
|
ret[key].push({
|
|
name: col.column_name,
|
|
type: this.platform.isNumericColumn(mappedType)
|
|
? col.column_type.replace(/ unsigned$/, '').replace(/\(\d+\)$/, '')
|
|
: col.column_type,
|
|
mappedType,
|
|
unsigned: col.column_type.endsWith(' unsigned'),
|
|
length: col.length,
|
|
default: this.wrap(defaultValue, mappedType),
|
|
nullable: col.is_nullable === 'YES',
|
|
primary: col.column_key === 'PRI',
|
|
unique: col.column_key === 'UNI',
|
|
autoincrement: col.extra === 'auto_increment',
|
|
precision: col.numeric_precision,
|
|
scale: col.numeric_scale,
|
|
comment: col.column_comment,
|
|
extra: extra(col.extra),
|
|
generated,
|
|
});
|
|
}
|
|
return ret;
|
|
}
|
|
async getAllChecks(connection, tables) {
|
|
/* v8 ignore next */
|
|
if (!(await this.supportsCheckConstraints(connection))) {
|
|
return {};
|
|
}
|
|
const sql = this.getChecksSQL(tables);
|
|
const allChecks = await connection.execute(sql);
|
|
const ret = {};
|
|
for (const check of allChecks) {
|
|
const key = this.getTableKey(check);
|
|
ret[key] ??= [];
|
|
ret[key].push({
|
|
name: check.name,
|
|
columnName: check.column_name,
|
|
definition: `check ${check.expression}`,
|
|
expression: check.expression.replace(/^\((.*)\)$/, '$1'),
|
|
});
|
|
}
|
|
return ret;
|
|
}
|
|
async getAllForeignKeys(connection, tables) {
|
|
const sql = `select k.constraint_name as constraint_name, nullif(k.table_schema, schema()) as schema_name, k.table_name as table_name, k.column_name as column_name, k.referenced_table_name as referenced_table_name, k.referenced_column_name as referenced_column_name, c.update_rule as update_rule, c.delete_rule as delete_rule
|
|
from information_schema.key_column_usage k
|
|
inner join information_schema.referential_constraints c on c.constraint_name = k.constraint_name and c.table_name = k.table_name
|
|
where k.table_name in (${tables.map(t => this.platform.quoteValue(t.table_name)).join(', ')})
|
|
and k.table_schema = database() and c.constraint_schema = database() and k.referenced_column_name is not null
|
|
order by constraint_name, k.ordinal_position`;
|
|
const allFks = await connection.execute(sql);
|
|
const ret = {};
|
|
for (const fk of allFks) {
|
|
const key = this.getTableKey(fk);
|
|
ret[key] ??= [];
|
|
ret[key].push(fk);
|
|
}
|
|
Object.keys(ret).forEach(key => {
|
|
const parts = key.split('.');
|
|
/* v8 ignore next */
|
|
const schemaName = parts.length > 1 ? parts[0] : undefined;
|
|
ret[key] = this.mapForeignKeys(ret[key], key, schemaName);
|
|
});
|
|
return ret;
|
|
}
|
|
getPreAlterTable(tableDiff, safe) {
|
|
// Dropping primary keys requires to unset autoincrement attribute on the particular column first.
|
|
const pk = Object.values(tableDiff.removedIndexes).find(idx => idx.primary);
|
|
if (!pk || safe) {
|
|
return [];
|
|
}
|
|
return pk.columnNames
|
|
.filter(col => tableDiff.fromTable.hasColumn(col))
|
|
.map(col => tableDiff.fromTable.getColumn(col))
|
|
.filter(col => col.autoincrement)
|
|
.map(
|
|
col =>
|
|
`alter table \`${tableDiff.name}\` modify \`${col.name}\` ${this.getColumnDeclarationSQL({ ...col, autoincrement: false })}`,
|
|
);
|
|
}
|
|
getRenameColumnSQL(tableName, oldColumnName, to) {
|
|
tableName = this.quote(tableName);
|
|
oldColumnName = this.quote(oldColumnName);
|
|
const columnName = this.quote(to.name);
|
|
return `alter table ${tableName} change ${oldColumnName} ${columnName} ${this.getColumnDeclarationSQL(to)}`;
|
|
}
|
|
getRenameIndexSQL(tableName, index, oldIndexName) {
|
|
tableName = this.quote(tableName);
|
|
oldIndexName = this.quote(oldIndexName);
|
|
const keyName = this.quote(index.keyName);
|
|
return [`alter table ${tableName} rename index ${oldIndexName} to ${keyName}`];
|
|
}
|
|
getChangeColumnCommentSQL(tableName, to, schemaName) {
|
|
tableName = this.quote(tableName);
|
|
const columnName = this.quote(to.name);
|
|
return `alter table ${tableName} modify ${columnName} ${this.getColumnDeclarationSQL(to)}`;
|
|
}
|
|
alterTableColumn(column, table, changedProperties) {
|
|
const col = this.createTableColumn(column, table, changedProperties);
|
|
return [`alter table ${table.getQuotedName()} modify ${col}`];
|
|
}
|
|
getColumnDeclarationSQL(col) {
|
|
let ret = col.type;
|
|
ret += col.unsigned ? ' unsigned' : '';
|
|
ret += col.autoincrement ? ' auto_increment' : '';
|
|
ret += ' ';
|
|
ret += col.nullable ? 'null' : 'not null';
|
|
ret += col.default ? ' default ' + col.default : '';
|
|
ret += col.comment ? ` comment ${this.platform.quoteValue(col.comment)}` : '';
|
|
return ret;
|
|
}
|
|
async getAllEnumDefinitions(connection, tables) {
|
|
const sql = `select column_name as column_name, column_type as column_type, table_name as table_name
|
|
from information_schema.columns
|
|
where data_type = 'enum' and table_name in (${tables.map(t => `'${t.table_name}'`).join(', ')}) and table_schema = database()`;
|
|
const enums = await connection.execute(sql);
|
|
return enums.reduce((o, item) => {
|
|
o[item.table_name] ??= {};
|
|
o[item.table_name][item.column_name] = item.column_type
|
|
.match(/enum\((.*)\)/)[1]
|
|
.split(',')
|
|
.map(item => /'(.*)'/.exec(item)[1]);
|
|
return o;
|
|
}, {});
|
|
}
|
|
async supportsCheckConstraints(connection) {
|
|
if (this.#cache.supportsCheckConstraints != null) {
|
|
return this.#cache.supportsCheckConstraints;
|
|
}
|
|
const sql = `select 1 from information_schema.tables where table_name = 'CHECK_CONSTRAINTS' and table_schema = 'information_schema'`;
|
|
const res = await connection.execute(sql);
|
|
return (this.#cache.supportsCheckConstraints = res.length > 0);
|
|
}
|
|
getChecksSQL(tables) {
|
|
return `select cc.constraint_schema as table_schema, tc.table_name as table_name, cc.constraint_name as name, cc.check_clause as expression
|
|
from information_schema.check_constraints cc
|
|
join information_schema.table_constraints tc
|
|
on tc.constraint_schema = cc.constraint_schema
|
|
and tc.constraint_name = cc.constraint_name
|
|
and constraint_type = 'CHECK'
|
|
where tc.table_name in (${tables.map(t => this.platform.quoteValue(t.table_name))}) and tc.constraint_schema = database()
|
|
order by tc.constraint_name`;
|
|
}
|
|
normalizeDefaultValue(defaultValue, length) {
|
|
return super.normalizeDefaultValue(defaultValue, length, MySqlSchemaHelper.DEFAULT_VALUES);
|
|
}
|
|
wrap(val, type) {
|
|
const stringType = type instanceof StringType || type instanceof TextType || type instanceof EnumType;
|
|
return typeof val === 'string' && val.length > 0 && stringType ? this.platform.quoteValue(val) : val;
|
|
}
|
|
}
|