361 lines
13 KiB
JavaScript
361 lines
13 KiB
JavaScript
import { ReferenceKind, isRaw } from '@mikro-orm/core';
|
|
import { DatabaseTable } from './DatabaseTable.js';
|
|
/**
|
|
* @internal
|
|
*/
|
|
export class DatabaseSchema {
|
|
name;
|
|
#tables = [];
|
|
#views = [];
|
|
#namespaces = new Set();
|
|
#nativeEnums = {}; // for postgres
|
|
#platform;
|
|
constructor(platform, name) {
|
|
this.name = name;
|
|
this.#platform = platform;
|
|
}
|
|
addTable(name, schema, comment) {
|
|
const namespaceName = schema ?? this.name;
|
|
const table = new DatabaseTable(this.#platform, name, namespaceName);
|
|
table.nativeEnums = this.#nativeEnums;
|
|
table.comment = comment;
|
|
this.#tables.push(table);
|
|
if (namespaceName != null) {
|
|
this.#namespaces.add(namespaceName);
|
|
}
|
|
return table;
|
|
}
|
|
getTables() {
|
|
return this.#tables;
|
|
}
|
|
/** @internal */
|
|
setTables(tables) {
|
|
this.#tables = tables;
|
|
}
|
|
/** @internal */
|
|
setNamespaces(namespaces) {
|
|
this.#namespaces = namespaces;
|
|
}
|
|
getTable(name) {
|
|
return this.#tables.find(t => t.name === name || `${t.schema}.${t.name}` === name);
|
|
}
|
|
hasTable(name) {
|
|
return !!this.getTable(name);
|
|
}
|
|
addView(name, schema, definition, materialized, withData) {
|
|
const namespaceName = schema ?? this.name;
|
|
const view = { name, schema: namespaceName, definition, materialized, withData };
|
|
this.#views.push(view);
|
|
if (namespaceName != null) {
|
|
this.#namespaces.add(namespaceName);
|
|
}
|
|
return view;
|
|
}
|
|
getViews() {
|
|
return this.#views;
|
|
}
|
|
/** @internal */
|
|
setViews(views) {
|
|
this.#views = views;
|
|
}
|
|
getView(name) {
|
|
return this.#views.find(v => v.name === name || `${v.schema}.${v.name}` === name);
|
|
}
|
|
hasView(name) {
|
|
return !!this.getView(name);
|
|
}
|
|
setNativeEnums(nativeEnums) {
|
|
this.#nativeEnums = nativeEnums;
|
|
for (const nativeEnum of Object.values(nativeEnums)) {
|
|
if (nativeEnum.schema && nativeEnum.schema !== '*') {
|
|
this.#namespaces.add(nativeEnum.schema);
|
|
}
|
|
}
|
|
}
|
|
getNativeEnums() {
|
|
return this.#nativeEnums;
|
|
}
|
|
getNativeEnum(name) {
|
|
return this.#nativeEnums[name];
|
|
}
|
|
hasNamespace(namespace) {
|
|
return this.#namespaces.has(namespace);
|
|
}
|
|
hasNativeEnum(name) {
|
|
return name in this.#nativeEnums;
|
|
}
|
|
getNamespaces() {
|
|
return [...this.#namespaces];
|
|
}
|
|
static async create(connection, platform, config, schemaName, schemas, takeTables, skipTables, skipViews) {
|
|
const schema = new DatabaseSchema(platform, schemaName ?? config.get('schema') ?? platform.getDefaultSchemaName());
|
|
const allTables = await platform.getSchemaHelper().getAllTables(connection, schemas);
|
|
const parts = config.get('migrations').tableName.split('.');
|
|
const migrationsTableName = parts[1] ?? parts[0];
|
|
const migrationsSchemaName = parts.length > 1 ? parts[0] : config.get('schema', platform.getDefaultSchemaName());
|
|
const tables = allTables.filter(
|
|
t =>
|
|
this.isTableNameAllowed(t.table_name, takeTables, skipTables) &&
|
|
(t.table_name !== migrationsTableName || (t.schema_name && t.schema_name !== migrationsSchemaName)),
|
|
);
|
|
await platform
|
|
.getSchemaHelper()
|
|
.loadInformationSchema(schema, connection, tables, schemas && schemas.length > 0 ? schemas : undefined);
|
|
// Load views from database
|
|
await platform.getSchemaHelper().loadViews(schema, connection);
|
|
// Load materialized views (PostgreSQL only)
|
|
if (platform.supportsMaterializedViews()) {
|
|
await platform.getSchemaHelper().loadMaterializedViews(schema, connection, schemaName);
|
|
}
|
|
// Filter out skipped views
|
|
if (skipViews && skipViews.length > 0) {
|
|
schema.#views = schema.#views.filter(v => this.isNameAllowed(v.name, skipViews));
|
|
}
|
|
return schema;
|
|
}
|
|
static fromMetadata(metadata, platform, config, schemaName, em) {
|
|
const schema = new DatabaseSchema(platform, schemaName ?? config.get('schema'));
|
|
const nativeEnums = {};
|
|
const skipColumns = config.get('schemaGenerator').skipColumns || {};
|
|
for (const meta of metadata) {
|
|
// Skip view entities when collecting native enums
|
|
if (meta.view) {
|
|
continue;
|
|
}
|
|
for (const prop of meta.props) {
|
|
if (prop.nativeEnumName) {
|
|
let key = prop.nativeEnumName;
|
|
let enumName = prop.nativeEnumName;
|
|
let enumSchema = meta.schema ?? schema.name;
|
|
if (key.includes('.')) {
|
|
const [explicitSchema, ...parts] = prop.nativeEnumName.split('.');
|
|
enumName = parts.join('.');
|
|
key = enumName;
|
|
enumSchema = explicitSchema;
|
|
}
|
|
if (enumSchema && enumSchema !== '*' && enumSchema !== platform.getDefaultSchemaName()) {
|
|
key = enumSchema + '.' + key;
|
|
}
|
|
nativeEnums[key] = {
|
|
name: enumName,
|
|
schema: enumSchema,
|
|
items: prop.items?.map(val => '' + val) ?? [],
|
|
};
|
|
}
|
|
}
|
|
}
|
|
schema.setNativeEnums(nativeEnums);
|
|
for (const meta of metadata) {
|
|
// Handle view entities separately
|
|
if (meta.view) {
|
|
const viewDefinition = this.getViewDefinition(meta, em, platform);
|
|
if (viewDefinition) {
|
|
schema.addView(
|
|
meta.collection,
|
|
this.getSchemaName(meta, config, schemaName),
|
|
viewDefinition,
|
|
meta.materialized,
|
|
meta.withData,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
const table = schema.addTable(meta.collection, this.getSchemaName(meta, config, schemaName));
|
|
table.comment = meta.comment;
|
|
// For TPT child entities, only use ownProps (properties defined in this entity only)
|
|
// For all other entities (including TPT root), use all props
|
|
const propsToProcess =
|
|
meta.inheritanceType === 'tpt' && meta.tptParent && meta.ownProps ? meta.ownProps : meta.props;
|
|
for (const prop of propsToProcess) {
|
|
if (!this.shouldHaveColumn(meta, prop, skipColumns)) {
|
|
continue;
|
|
}
|
|
table.addColumnFromProperty(prop, meta, config);
|
|
}
|
|
// For TPT child entities, always include the PK columns (they form the FK to parent)
|
|
if (meta.inheritanceType === 'tpt' && meta.tptParent) {
|
|
const pkProps = meta.primaryKeys.map(pk => meta.properties[pk]);
|
|
for (const pkProp of pkProps) {
|
|
// Only add if not already added (it might be in ownProps if defined in this entity)
|
|
if (!propsToProcess.includes(pkProp)) {
|
|
table.addColumnFromProperty(pkProp, meta, config);
|
|
}
|
|
// Child PK must not be autoincrement — it references the parent PK via FK
|
|
for (const field of pkProp.fieldNames) {
|
|
const col = table.getColumn(field);
|
|
if (col) {
|
|
col.autoincrement = false;
|
|
}
|
|
}
|
|
}
|
|
// Add FK from child PK to parent PK with ON DELETE CASCADE
|
|
this.addTPTForeignKey(table, meta, config, platform);
|
|
}
|
|
meta.indexes.forEach(index => table.addIndex(meta, index, 'index'));
|
|
meta.uniques.forEach(index => table.addIndex(meta, index, 'unique'));
|
|
// For TPT child entities, the PK is also defined here
|
|
const pkPropsForIndex =
|
|
meta.inheritanceType === 'tpt' && meta.tptParent
|
|
? meta.primaryKeys.map(pk => meta.properties[pk])
|
|
: meta.props.filter(prop => prop.primary);
|
|
table.addIndex(meta, { properties: pkPropsForIndex.map(prop => prop.name) }, 'primary');
|
|
for (const check of meta.checks) {
|
|
const columnName = check.property ? meta.properties[check.property].fieldNames[0] : undefined;
|
|
const expression = isRaw(check.expression)
|
|
? platform.formatQuery(check.expression.sql, check.expression.params)
|
|
: check.expression;
|
|
table.addCheck({
|
|
name: check.name,
|
|
expression,
|
|
definition: `check (${expression})`,
|
|
columnName,
|
|
});
|
|
}
|
|
}
|
|
return schema;
|
|
}
|
|
static getViewDefinition(meta, em, platform) {
|
|
if (typeof meta.expression === 'string') {
|
|
return meta.expression;
|
|
}
|
|
// Expression is a function, need to evaluate it
|
|
/* v8 ignore next */
|
|
if (!em) {
|
|
return undefined;
|
|
}
|
|
const result = meta.expression(em, {}, {});
|
|
// Async expressions are not supported for view entities
|
|
if (result && typeof result.then === 'function') {
|
|
throw new Error(
|
|
`View entity ${meta.className} expression returned a Promise. Async expressions are not supported for view entities.`,
|
|
);
|
|
}
|
|
/* v8 ignore next */
|
|
if (typeof result === 'string') {
|
|
return result;
|
|
}
|
|
/* v8 ignore next */
|
|
if (isRaw(result)) {
|
|
return platform.formatQuery(result.sql, result.params);
|
|
}
|
|
// Check if it's a QueryBuilder (has getFormattedQuery method)
|
|
if (result && typeof result.getFormattedQuery === 'function') {
|
|
return result.getFormattedQuery();
|
|
}
|
|
/* v8 ignore next - fallback for unknown result types */
|
|
return undefined;
|
|
}
|
|
static getSchemaName(meta, config, schema) {
|
|
return (meta.schema === '*' ? schema : meta.schema) ?? config.get('schema');
|
|
}
|
|
/**
|
|
* Add a foreign key from a TPT child entity's PK to its parent entity's PK.
|
|
* This FK uses ON DELETE CASCADE to ensure child rows are deleted when parent is deleted.
|
|
*/
|
|
static addTPTForeignKey(table, meta, config, platform) {
|
|
const parent = meta.tptParent;
|
|
const pkColumnNames = meta.primaryKeys.flatMap(pk => meta.properties[pk].fieldNames);
|
|
const parentPkColumnNames = parent.primaryKeys.flatMap(pk => parent.properties[pk].fieldNames);
|
|
// Determine the parent table name with schema
|
|
const parentSchema =
|
|
parent.schema === '*' ? undefined : (parent.schema ?? config.get('schema', platform.getDefaultSchemaName()));
|
|
const parentTableName = parentSchema ? `${parentSchema}.${parent.tableName}` : parent.tableName;
|
|
// Create FK constraint name
|
|
const constraintName = platform.getIndexName(table.name, pkColumnNames, 'foreign');
|
|
// Add the foreign key to the table
|
|
const fks = table.getForeignKeys();
|
|
fks[constraintName] = {
|
|
constraintName,
|
|
columnNames: pkColumnNames,
|
|
localTableName: table.getShortestName(false),
|
|
referencedColumnNames: parentPkColumnNames,
|
|
referencedTableName: parentTableName,
|
|
deleteRule: 'cascade', // TPT always uses cascade delete
|
|
updateRule: 'cascade', // TPT always uses cascade update
|
|
};
|
|
}
|
|
static matchName(name, nameToMatch) {
|
|
return typeof nameToMatch === 'string'
|
|
? name.toLocaleLowerCase() === nameToMatch.toLocaleLowerCase()
|
|
: nameToMatch.test(name);
|
|
}
|
|
static isNameAllowed(name, skipNames) {
|
|
return !(skipNames?.some(pattern => this.matchName(name, pattern)) ?? false);
|
|
}
|
|
static isTableNameAllowed(tableName, takeTables, skipTables) {
|
|
return (
|
|
(takeTables?.some(tableNameToMatch => this.matchName(tableName, tableNameToMatch)) ?? true) &&
|
|
this.isNameAllowed(tableName, skipTables)
|
|
);
|
|
}
|
|
static shouldHaveColumn(meta, prop, skipColumns) {
|
|
if (prop.persist === false || (prop.columnTypes?.length ?? 0) === 0) {
|
|
return false;
|
|
}
|
|
// Check if column should be skipped
|
|
if (skipColumns) {
|
|
const tableName = meta.tableName;
|
|
const tableSchema = meta.schema;
|
|
const fullTableName = tableSchema ? `${tableSchema}.${tableName}` : tableName;
|
|
// Check for skipColumns by table name or fully qualified table name
|
|
const columnsToSkip = skipColumns[tableName] || skipColumns[fullTableName];
|
|
if (columnsToSkip) {
|
|
for (const fieldName of prop.fieldNames) {
|
|
if (columnsToSkip.some(pattern => this.matchName(fieldName, pattern))) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (prop.kind === ReferenceKind.EMBEDDED && prop.object) {
|
|
return true;
|
|
}
|
|
const getRootProperty = prop => (prop.embedded ? getRootProperty(meta.properties[prop.embedded[0]]) : prop);
|
|
const rootProp = getRootProperty(prop);
|
|
if (rootProp.kind === ReferenceKind.EMBEDDED) {
|
|
return prop === rootProp || !rootProp.object;
|
|
}
|
|
return (
|
|
[ReferenceKind.SCALAR, ReferenceKind.MANY_TO_ONE].includes(prop.kind) ||
|
|
(prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner)
|
|
);
|
|
}
|
|
toJSON() {
|
|
return {
|
|
name: this.name,
|
|
namespaces: [...this.#namespaces],
|
|
tables: this.#tables,
|
|
views: this.#views,
|
|
nativeEnums: this.#nativeEnums,
|
|
};
|
|
}
|
|
prune(schema, wildcardSchemaTables) {
|
|
const hasWildcardSchema = wildcardSchemaTables.length > 0;
|
|
this.#tables = this.#tables.filter(table => {
|
|
return (
|
|
(!schema && !hasWildcardSchema) || // no schema specified and we don't have any multi-schema entity
|
|
table.schema === schema || // specified schema matches the table's one
|
|
(!schema && !wildcardSchemaTables.includes(table.name))
|
|
); // no schema specified and the table has fixed one provided
|
|
});
|
|
this.#views = this.#views.filter(view => {
|
|
/* v8 ignore next */
|
|
return (
|
|
(!schema && !hasWildcardSchema) ||
|
|
view.schema === schema ||
|
|
(!schema && !wildcardSchemaTables.includes(view.name))
|
|
);
|
|
});
|
|
// remove namespaces of ignored tables and views
|
|
for (const ns of this.#namespaces) {
|
|
if (
|
|
!this.#tables.some(t => t.schema === ns) &&
|
|
!this.#views.some(v => v.schema === ns) &&
|
|
!Object.values(this.#nativeEnums).some(e => e.schema === ns)
|
|
) {
|
|
this.#namespaces.delete(ns);
|
|
}
|
|
}
|
|
}
|
|
}
|