1023 lines
41 KiB
JavaScript
1023 lines
41 KiB
JavaScript
import {
|
|
DecimalType,
|
|
EntitySchema,
|
|
RawQueryFragment,
|
|
ReferenceKind,
|
|
t,
|
|
Type,
|
|
UnknownType,
|
|
Utils,
|
|
} from '@mikro-orm/core';
|
|
/**
|
|
* @internal
|
|
*/
|
|
export class DatabaseTable {
|
|
name;
|
|
schema;
|
|
#columns = {};
|
|
#indexes = [];
|
|
#checks = [];
|
|
#foreignKeys = {};
|
|
#platform;
|
|
nativeEnums = {}; // for postgres
|
|
comment;
|
|
constructor(platform, name, schema) {
|
|
this.name = name;
|
|
this.schema = schema;
|
|
this.#platform = platform;
|
|
}
|
|
getQuotedName() {
|
|
return this.#platform.quoteIdentifier(this.getShortestName());
|
|
}
|
|
getColumns() {
|
|
return Object.values(this.#columns);
|
|
}
|
|
getColumn(name) {
|
|
return this.#columns[name];
|
|
}
|
|
removeColumn(name) {
|
|
delete this.#columns[name];
|
|
}
|
|
getIndexes() {
|
|
return Utils.removeDuplicates(this.#indexes);
|
|
}
|
|
getChecks() {
|
|
return this.#checks;
|
|
}
|
|
/** @internal */
|
|
setIndexes(indexes) {
|
|
this.#indexes = indexes;
|
|
}
|
|
/** @internal */
|
|
setChecks(checks) {
|
|
this.#checks = checks;
|
|
}
|
|
/** @internal */
|
|
setForeignKeys(fks) {
|
|
this.#foreignKeys = fks;
|
|
}
|
|
init(cols, indexes = [], checks = [], pks, fks = {}, enums = {}) {
|
|
this.#indexes = indexes;
|
|
this.#checks = checks;
|
|
this.#foreignKeys = fks;
|
|
this.#columns = cols.reduce((o, v) => {
|
|
const index = indexes.filter(i => i.columnNames[0] === v.name);
|
|
v.primary = v.primary || pks.includes(v.name);
|
|
v.unique = index.some(i => i.unique && !i.primary);
|
|
const type = v.name in enums ? 'enum' : v.type;
|
|
v.mappedType = this.#platform.getMappedType(type);
|
|
v.default = v.default?.toString().startsWith('nextval(') ? null : v.default;
|
|
v.enumItems ??= enums[v.name] || [];
|
|
o[v.name] = v;
|
|
return o;
|
|
}, {});
|
|
}
|
|
addColumn(column) {
|
|
this.#columns[column.name] = column;
|
|
}
|
|
addColumnFromProperty(prop, meta, config) {
|
|
prop.fieldNames?.forEach((field, idx) => {
|
|
const type = prop.enum ? 'enum' : prop.columnTypes[idx];
|
|
const mappedType = this.#platform.getMappedType(type);
|
|
if (mappedType instanceof DecimalType) {
|
|
const match = /\w+\((\d+), ?(\d+)\)/.exec(prop.columnTypes[idx]);
|
|
/* v8 ignore next */
|
|
if (match) {
|
|
prop.precision ??= +match[1];
|
|
prop.scale ??= +match[2];
|
|
prop.length = undefined;
|
|
}
|
|
}
|
|
if (prop.length == null && prop.columnTypes[idx]) {
|
|
prop.length = this.#platform.getSchemaHelper().inferLengthFromColumnType(prop.columnTypes[idx]);
|
|
if (typeof mappedType.getDefaultLength !== 'undefined') {
|
|
prop.length ??= mappedType.getDefaultLength(this.#platform);
|
|
}
|
|
}
|
|
const primary = !meta.compositePK && prop.fieldNames.length === 1 && !!prop.primary;
|
|
this.#columns[field] = {
|
|
name: prop.fieldNames[idx],
|
|
type: prop.columnTypes[idx],
|
|
generated:
|
|
prop.generated instanceof RawQueryFragment
|
|
? this.#platform.formatQuery(prop.generated.sql, prop.generated.params)
|
|
: prop.generated,
|
|
mappedType,
|
|
unsigned: prop.unsigned && this.#platform.isNumericColumn(mappedType),
|
|
autoincrement:
|
|
prop.autoincrement ??
|
|
(primary && prop.kind === ReferenceKind.SCALAR && this.#platform.isNumericColumn(mappedType)),
|
|
primary,
|
|
nullable: this.#columns[field]?.nullable ?? !!prop.nullable,
|
|
nativeEnumName: prop.nativeEnumName,
|
|
length: prop.length,
|
|
precision: prop.precision,
|
|
scale: prop.scale,
|
|
default: prop.defaultRaw,
|
|
enumItems: prop.nativeEnumName || prop.items?.every(i => typeof i === 'string') ? prop.items : undefined,
|
|
comment: prop.comment,
|
|
extra: prop.extra,
|
|
ignoreSchemaChanges: prop.ignoreSchemaChanges,
|
|
};
|
|
this.#columns[field].unsigned ??= this.#columns[field].autoincrement;
|
|
if (this.nativeEnums[type]) {
|
|
this.#columns[field].enumItems ??= this.nativeEnums[type].items;
|
|
}
|
|
const defaultValue = this.#platform.getSchemaHelper().normalizeDefaultValue(prop.defaultRaw, prop.length);
|
|
this.#columns[field].default = defaultValue;
|
|
});
|
|
if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && !prop.polymorphic) {
|
|
const constraintName = this.getIndexName(prop.foreignKeyName ?? true, prop.fieldNames, 'foreign');
|
|
let schema =
|
|
prop.targetMeta.root.schema === '*'
|
|
? this.schema
|
|
: (prop.targetMeta.root.schema ?? config.get('schema', this.#platform.getDefaultSchemaName()));
|
|
if (prop.referencedTableName.includes('.')) {
|
|
schema = undefined;
|
|
}
|
|
// For cross-schema FKs on MySQL/MariaDB (where schema = database), when the referenced
|
|
// table has no explicit schema but the current table does, qualify with dbName so the
|
|
// FK can resolve the referenced table in the correct database
|
|
if (!schema && this.schema && !this.#platform.getDefaultSchemaName()) {
|
|
schema = config.get('dbName');
|
|
}
|
|
if (prop.createForeignKeyConstraint) {
|
|
this.#foreignKeys[constraintName] = {
|
|
constraintName,
|
|
columnNames: prop.fieldNames,
|
|
localTableName: this.getShortestName(false),
|
|
referencedColumnNames: prop.referencedColumnNames,
|
|
referencedTableName: schema ? `${schema}.${prop.referencedTableName}` : prop.referencedTableName,
|
|
};
|
|
const schemaConfig = config.get('schemaGenerator');
|
|
this.#foreignKeys[constraintName].deleteRule = prop.deleteRule ?? schemaConfig.defaultDeleteRule;
|
|
this.#foreignKeys[constraintName].updateRule = prop.updateRule ?? schemaConfig.defaultUpdateRule;
|
|
if (prop.deferMode) {
|
|
this.#foreignKeys[constraintName].deferMode = prop.deferMode;
|
|
}
|
|
}
|
|
}
|
|
if (prop.index) {
|
|
this.#indexes.push({
|
|
columnNames: prop.fieldNames,
|
|
composite: prop.fieldNames.length > 1,
|
|
keyName: this.getIndexName(prop.index, prop.fieldNames, 'index'),
|
|
constraint: false,
|
|
primary: false,
|
|
unique: false,
|
|
});
|
|
}
|
|
if (prop.unique && !(prop.primary && !meta.compositePK)) {
|
|
this.#indexes.push({
|
|
columnNames: prop.fieldNames,
|
|
composite: prop.fieldNames.length > 1,
|
|
keyName: this.getIndexName(prop.unique, prop.fieldNames, 'unique'),
|
|
constraint: !prop.fieldNames.some(d => d.includes('.')),
|
|
primary: false,
|
|
unique: true,
|
|
deferMode: prop.deferMode,
|
|
});
|
|
}
|
|
}
|
|
getIndexName(value, columnNames, type) {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
return this.#platform.getIndexName(this.name, columnNames, type);
|
|
}
|
|
getEntityDeclaration(namingStrategy, schemaHelper, scalarPropertiesForRelations) {
|
|
const { fksOnColumnProps, fksOnStandaloneProps, columnFks, fkIndexes, nullableForeignKeys, skippedColumnNames } =
|
|
this.foreignKeysToProps(namingStrategy, scalarPropertiesForRelations);
|
|
const name = namingStrategy.getEntityName(this.name, this.schema);
|
|
const schema = new EntitySchema({ name, collection: this.name, schema: this.schema, comment: this.comment });
|
|
const compositeFkIndexes = {};
|
|
const compositeFkUniques = {};
|
|
const potentiallyUnmappedIndexes = this.#indexes.filter(
|
|
index =>
|
|
!index.primary && // Skip primary index. Whether it's in use by scalar column or FK, it's already mapped.
|
|
// Non-trivial non-composite indexes will be declared at the entity's metadata, though later outputted in the property
|
|
(index.columnNames.length > 1 || // All composite indexes are to be mapped to entity decorators or FK props.
|
|
skippedColumnNames.includes(index.columnNames[0]) || // Non-composite indexes for skipped columns are to be mapped as entity decorators.
|
|
index.deferMode ||
|
|
index.expression ||
|
|
!(index.columnNames[0] in columnFks)) && // Trivial non-composite indexes for scalar props are to be mapped to the column.
|
|
// ignore indexes that don't have all column names (this can happen in sqlite where there is no way to infer this for expressions)
|
|
!(index.columnNames.some(col => !col) && !index.expression),
|
|
);
|
|
// Helper to map column name to property name
|
|
const columnToPropertyName = colName => this.getPropertyName(namingStrategy, colName);
|
|
for (const index of potentiallyUnmappedIndexes) {
|
|
// Build the index/unique options object with advanced options
|
|
const ret = {
|
|
name: index.keyName,
|
|
deferMode: index.deferMode,
|
|
expression: index.expression,
|
|
// Advanced index options - convert column names to property names
|
|
columns: index.columns?.map(col => ({
|
|
...col,
|
|
name: columnToPropertyName(col.name),
|
|
})),
|
|
include: index.include?.map(colName => columnToPropertyName(colName)),
|
|
fillFactor: index.fillFactor,
|
|
disabled: index.disabled,
|
|
};
|
|
// Index-only options (not valid for Unique)
|
|
if (!index.unique) {
|
|
if (index.type) {
|
|
// Convert index type - IndexDef.type can be string or object, IndexOptions.type is just string
|
|
ret.type = typeof index.type === 'string' ? index.type : index.type.indexType;
|
|
}
|
|
if (index.invisible) {
|
|
ret.invisible = index.invisible;
|
|
}
|
|
if (index.clustered) {
|
|
ret.clustered = index.clustered;
|
|
}
|
|
}
|
|
// An index is trivial if it has no special options that require entity-level declaration
|
|
const hasAdvancedOptions =
|
|
index.columns?.length ||
|
|
index.include?.length ||
|
|
index.fillFactor ||
|
|
index.type ||
|
|
index.invisible ||
|
|
index.disabled ||
|
|
index.clustered;
|
|
const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
|
|
if (isTrivial) {
|
|
// Index is for FK. Map to the FK prop and move on.
|
|
const fkForIndex = fkIndexes.get(index);
|
|
if (fkForIndex && !fkForIndex.fk.columnNames.some(col => !index.columnNames.includes(col))) {
|
|
ret.properties = [this.getPropertyName(namingStrategy, fkForIndex.baseName, fkForIndex.fk)];
|
|
const map = index.unique ? compositeFkUniques : compositeFkIndexes;
|
|
if (typeof map[ret.properties[0]] === 'undefined') {
|
|
map[ret.properties[0]] = index;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
const properties =
|
|
ret.properties ??
|
|
this.getIndexProperties(index, columnFks, fksOnColumnProps, fksOnStandaloneProps, namingStrategy);
|
|
// If there is a column that cannot be unambiguously mapped to a prop, render an expression.
|
|
if (typeof properties === 'undefined') {
|
|
ret.expression ??= schemaHelper.getCreateIndexSQL(this.name, index);
|
|
} else {
|
|
ret.properties ??= properties;
|
|
// If the index is for one property that is not a FK prop, map to the column prop and move on.
|
|
if (properties.length === 1 && isTrivial && !fksOnStandaloneProps.has(properties[0])) {
|
|
const map = index.unique ? compositeFkUniques : compositeFkIndexes;
|
|
// Only map one trivial index. If the same column is indexed many times over, output
|
|
if (typeof map[properties[0]] === 'undefined') {
|
|
map[properties[0]] = index;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
// Composite indexes that aren't exclusively mapped to FK props get an entity decorator.
|
|
if (index.unique) {
|
|
schema.addUnique(ret);
|
|
continue;
|
|
}
|
|
schema.addIndex(ret);
|
|
}
|
|
const addedStandaloneFkPropsBasedOnColumn = new Set();
|
|
const nonSkippedColumns = this.getColumns().filter(column => !skippedColumnNames.includes(column.name));
|
|
for (const column of nonSkippedColumns) {
|
|
const columnName = column.name;
|
|
const standaloneFkPropBasedOnColumn = fksOnStandaloneProps.get(columnName);
|
|
if (standaloneFkPropBasedOnColumn && !fksOnColumnProps.get(columnName)) {
|
|
addedStandaloneFkPropsBasedOnColumn.add(columnName);
|
|
const { fkIndex, currentFk } = standaloneFkPropBasedOnColumn;
|
|
const prop = this.getForeignKeyDeclaration(
|
|
currentFk,
|
|
namingStrategy,
|
|
schemaHelper,
|
|
fkIndex,
|
|
nullableForeignKeys.has(currentFk),
|
|
columnName,
|
|
fksOnColumnProps,
|
|
);
|
|
schema.addProperty(prop.name, prop.type, prop);
|
|
}
|
|
const prop = this.getPropertyDeclaration(
|
|
column,
|
|
namingStrategy,
|
|
schemaHelper,
|
|
compositeFkIndexes,
|
|
compositeFkUniques,
|
|
columnFks,
|
|
fksOnColumnProps.get(columnName),
|
|
);
|
|
schema.addProperty(prop.name, prop.type, prop);
|
|
}
|
|
for (const [propBaseName, { fkIndex, currentFk }] of fksOnStandaloneProps.entries()) {
|
|
if (addedStandaloneFkPropsBasedOnColumn.has(propBaseName)) {
|
|
continue;
|
|
}
|
|
const prop = this.getForeignKeyDeclaration(
|
|
currentFk,
|
|
namingStrategy,
|
|
schemaHelper,
|
|
fkIndex,
|
|
nullableForeignKeys.has(currentFk),
|
|
propBaseName,
|
|
fksOnColumnProps,
|
|
);
|
|
schema.addProperty(prop.name, prop.type, prop);
|
|
}
|
|
const meta = schema.init().meta;
|
|
const oneToOneCandidateProperties = meta.relations.filter(
|
|
prop => prop.primary && prop.kind === ReferenceKind.MANY_TO_ONE,
|
|
);
|
|
if (
|
|
oneToOneCandidateProperties.length === 1 &&
|
|
oneToOneCandidateProperties[0].fieldNames.length ===
|
|
new Set(meta.getPrimaryProps().flatMap(prop => prop.fieldNames)).size
|
|
) {
|
|
oneToOneCandidateProperties[0].kind = ReferenceKind.ONE_TO_ONE;
|
|
}
|
|
return meta;
|
|
}
|
|
foreignKeysToProps(namingStrategy, scalarPropertiesForRelations) {
|
|
const fks = Object.values(this.getForeignKeys());
|
|
const fksOnColumnProps = new Map();
|
|
const fksOnStandaloneProps = new Map();
|
|
const columnFks = {};
|
|
const fkIndexes = new Map();
|
|
const nullableForeignKeys = new Set();
|
|
const standaloneFksBasedOnColumnNames = new Map();
|
|
for (const currentFk of fks) {
|
|
const fkIndex = this.findFkIndex(currentFk);
|
|
if (
|
|
currentFk.columnNames.length === 1 &&
|
|
!fks.some(
|
|
fk => fk !== currentFk && fk.columnNames.length === 1 && currentFk.columnNames[0] === fk.columnNames[0],
|
|
)
|
|
) {
|
|
// Non-composite FK is the only possible one for a column. Render the column with it.
|
|
const columnName = currentFk.columnNames[0];
|
|
columnFks[columnName] ??= [];
|
|
columnFks[columnName].push(currentFk);
|
|
if (this.getColumn(columnName)?.nullable) {
|
|
nullableForeignKeys.add(currentFk);
|
|
}
|
|
if (scalarPropertiesForRelations === 'always') {
|
|
const baseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName);
|
|
standaloneFksBasedOnColumnNames.set(baseName, currentFk);
|
|
fksOnStandaloneProps.set(baseName, { fkIndex, currentFk });
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName });
|
|
}
|
|
} else {
|
|
fksOnColumnProps.set(columnName, currentFk);
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName: columnName });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const specificColumnNames = [];
|
|
const nullableColumnsInFk = [];
|
|
for (const columnName of currentFk.columnNames) {
|
|
columnFks[columnName] ??= [];
|
|
columnFks[columnName].push(currentFk);
|
|
if (!fks.some(fk => fk !== currentFk && fk.columnNames.includes(columnName))) {
|
|
specificColumnNames.push(columnName);
|
|
}
|
|
if (this.getColumn(columnName)?.nullable) {
|
|
nullableColumnsInFk.push(columnName);
|
|
}
|
|
}
|
|
if (nullableColumnsInFk.length > 0) {
|
|
nullableForeignKeys.add(currentFk);
|
|
}
|
|
if (
|
|
specificColumnNames.length === 1 &&
|
|
(nullableColumnsInFk.length === currentFk.columnNames.length ||
|
|
nullableColumnsInFk.length === 0 ||
|
|
(nullableColumnsInFk.length === 1 && nullableColumnsInFk[0] === specificColumnNames[0]))
|
|
) {
|
|
// Composite FK has exactly one column which is not used in any other FK.
|
|
// The FK also doesn't have a mix of nullable and non-nullable columns,
|
|
// or its only nullable column is this very one.
|
|
// It is safe to just render this FK attached to the specific column.
|
|
const columnName = specificColumnNames[0];
|
|
if (scalarPropertiesForRelations === 'always') {
|
|
const baseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName);
|
|
standaloneFksBasedOnColumnNames.set(baseName, currentFk);
|
|
fksOnStandaloneProps.set(baseName, { fkIndex, currentFk });
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName });
|
|
}
|
|
} else {
|
|
fksOnColumnProps.set(columnName, currentFk);
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName: columnName });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (specificColumnNames.length === currentFk.columnNames.length) {
|
|
// All columns involved with this FK are only covered by this one FK.
|
|
if (nullableColumnsInFk.length <= 1) {
|
|
// Also, this FK is either not nullable, or has only one nullable column.
|
|
// It is safe to name the FK after the nullable column, or any non-nullable one (the first one is picked).
|
|
const columnName = nullableColumnsInFk.at(0) ?? currentFk.columnNames[0];
|
|
if (scalarPropertiesForRelations === 'always') {
|
|
const baseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName);
|
|
standaloneFksBasedOnColumnNames.set(baseName, currentFk);
|
|
fksOnStandaloneProps.set(baseName, { fkIndex, currentFk });
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName });
|
|
}
|
|
} else {
|
|
fksOnColumnProps.set(columnName, currentFk);
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName: columnName });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// If the first nullable column's name with FK is different from the name without FK,
|
|
// name a standalone prop after the column, but treat the column prop itself as not having FK.
|
|
const columnName = nullableColumnsInFk[0];
|
|
const baseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName);
|
|
standaloneFksBasedOnColumnNames.set(baseName, currentFk);
|
|
fksOnStandaloneProps.set(baseName, { fkIndex, currentFk });
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName });
|
|
}
|
|
continue;
|
|
}
|
|
// FK is not unambiguously mappable to a column. Pick another name for a standalone FK prop.
|
|
const baseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks);
|
|
fksOnStandaloneProps.set(baseName, { fkIndex, currentFk });
|
|
if (fkIndex) {
|
|
fkIndexes.set(fkIndex, { fk: currentFk, baseName });
|
|
}
|
|
}
|
|
const columnsInFks = Object.keys(columnFks);
|
|
const skippingHandlers = {
|
|
// Never generate scalar props for composite keys,
|
|
// i.e. always skip columns if they are covered by foreign keys.
|
|
never: column => columnsInFks.includes(column.name) && !fksOnColumnProps.has(column.name),
|
|
// Always generate scalar props for composite keys,
|
|
// i.e. do not skip columns, even if they are covered by foreign keys.
|
|
always: column => false,
|
|
// Smart scalar props generation.
|
|
// Skips columns if they are covered by foreign keys.
|
|
// But also does not skip if the column is not nullable, and yet all involved FKs are nullable,
|
|
// or if one or more FKs involved has multiple nullable columns.
|
|
smart: column => {
|
|
return (
|
|
columnsInFks.includes(column.name) &&
|
|
!fksOnColumnProps.has(column.name) &&
|
|
(column.nullable
|
|
? columnFks[column.name].some(
|
|
fk =>
|
|
!fk.columnNames.some(
|
|
fkColumnName => fkColumnName !== column.name && this.getColumn(fkColumnName)?.nullable,
|
|
),
|
|
)
|
|
: columnFks[column.name].some(fk => !nullableForeignKeys.has(fk)))
|
|
);
|
|
},
|
|
};
|
|
const skippedColumnNames = this.getColumns()
|
|
.filter(skippingHandlers[scalarPropertiesForRelations])
|
|
.map(column => column.name);
|
|
// Check standalone FKs named after columns for potential conflicts among themselves.
|
|
// This typically happens when two standalone FKs named after a column resolve to the same prop name
|
|
// because the respective columns include the referenced table in the name.
|
|
// Depending on naming strategy and actual names, it may also originate from other scenarios.
|
|
// We do our best to de-duplicate them here.
|
|
const safePropNames = new Set();
|
|
const unsafePropNames = new Map();
|
|
for (const [unsafeBaseName, currentFk] of standaloneFksBasedOnColumnNames) {
|
|
const propName = this.getPropertyName(namingStrategy, unsafeBaseName, currentFk);
|
|
if (safePropNames.has(propName)) {
|
|
if (!unsafePropNames.has(propName)) {
|
|
unsafePropNames.set(propName, []);
|
|
}
|
|
unsafePropNames.get(propName).push({ unsafeBaseName, currentFk });
|
|
continue;
|
|
}
|
|
safePropNames.add(propName);
|
|
}
|
|
for (const [unsafePropName, affectedBaseNames] of unsafePropNames) {
|
|
safePropNames.delete(unsafePropName);
|
|
for (const { unsafeBaseName, currentFk } of affectedBaseNames) {
|
|
const newBaseName = this.getSafeBaseNameForFkProp(namingStrategy, currentFk, fks);
|
|
fksOnStandaloneProps.delete(unsafeBaseName);
|
|
let fkIndex;
|
|
for (const [indexDef, fkIndexDesc] of fkIndexes) {
|
|
if (fkIndexDesc.fk !== currentFk) {
|
|
continue;
|
|
}
|
|
fkIndexDesc.baseName = newBaseName;
|
|
fkIndex = indexDef;
|
|
break;
|
|
}
|
|
fksOnStandaloneProps.set(newBaseName, { fkIndex, currentFk });
|
|
}
|
|
}
|
|
return { fksOnColumnProps, fksOnStandaloneProps, columnFks, fkIndexes, nullableForeignKeys, skippedColumnNames };
|
|
}
|
|
findFkIndex(currentFk) {
|
|
const fkColumnsLength = currentFk.columnNames.length;
|
|
const possibleIndexes = this.#indexes.filter(index => {
|
|
return (
|
|
index.columnNames.length === fkColumnsLength &&
|
|
!currentFk.columnNames.some((columnName, i) => index.columnNames[i] !== columnName)
|
|
);
|
|
});
|
|
possibleIndexes.sort((a, b) => {
|
|
if (a.primary !== b.primary) {
|
|
return a.primary ? -1 : 1;
|
|
}
|
|
if (a.unique !== b.unique) {
|
|
return a.unique ? -1 : 1;
|
|
}
|
|
return a.keyName.localeCompare(b.keyName);
|
|
});
|
|
return possibleIndexes.at(0);
|
|
}
|
|
getIndexProperties(index, columnFks, fksOnColumnProps, fksOnStandaloneProps, namingStrategy) {
|
|
const propBaseNames = new Set();
|
|
const columnNames = index.columnNames;
|
|
const l = columnNames.length;
|
|
if (columnNames.some(col => !col)) {
|
|
return;
|
|
}
|
|
for (let i = 0; i < l; ++i) {
|
|
const columnName = columnNames[i];
|
|
// The column is not involved with FKs.
|
|
if (!(columnName in columnFks)) {
|
|
// If there is no such column, the "name" is actually an expression.
|
|
if (!this.hasColumn(columnName)) {
|
|
return;
|
|
}
|
|
// It has a prop named after it.
|
|
// Add it and move on.
|
|
propBaseNames.add(columnName);
|
|
continue;
|
|
}
|
|
// If the prop named after the column has a FK and the FK's columns are a subset of this index,
|
|
// include this prop and move on.
|
|
const columnPropFk = fksOnColumnProps.get(columnName);
|
|
if (columnPropFk && !columnPropFk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
|
|
propBaseNames.add(columnName);
|
|
continue;
|
|
}
|
|
// If there is at least one standalone FK featuring this column,
|
|
// and all of its columns are a subset of this index,
|
|
// include that FK, and consider mapping of this column to a prop a success.
|
|
let propAdded = false;
|
|
for (const [propName, { currentFk: fk }] of fksOnStandaloneProps) {
|
|
if (!columnFks[columnName].includes(fk)) {
|
|
continue;
|
|
}
|
|
if (!fk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
|
|
propBaseNames.add(propName);
|
|
propAdded = true;
|
|
}
|
|
}
|
|
if (propAdded) {
|
|
continue;
|
|
}
|
|
// If we have reached this point, it means the column is not mappable to a prop name.
|
|
// Break the whole prop creation.
|
|
return;
|
|
}
|
|
return Array.from(propBaseNames).map(baseName =>
|
|
this.getPropertyName(namingStrategy, baseName, fksOnColumnProps.get(baseName)),
|
|
);
|
|
}
|
|
getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName) {
|
|
if (
|
|
columnName &&
|
|
this.getPropertyName(namingStrategy, columnName, currentFk) !== this.getPropertyName(namingStrategy, columnName)
|
|
) {
|
|
// The eligible scalar column name is different from the name of the FK prop of the same column.
|
|
// Both can be safely rendered.
|
|
// Use the column name as a base for the FK prop.
|
|
return columnName;
|
|
}
|
|
// Strip schema prefix from referenced table name (e.g., "public.fr_usuario" -> "fr_usuario")
|
|
const getTableName = fullName => {
|
|
const parts = fullName.split('.');
|
|
return parts[parts.length - 1];
|
|
};
|
|
const referencedTableName = getTableName(currentFk.referencedTableName);
|
|
// Check for conflicts using stripped table names (handles cross-schema FKs to same-named tables)
|
|
const hasConflictingFk = fks.some(
|
|
fk => fk !== currentFk && getTableName(fk.referencedTableName) === referencedTableName,
|
|
);
|
|
if (!hasConflictingFk && !this.getColumn(referencedTableName)) {
|
|
// FK is the only one in this table that references a table with this name.
|
|
// The name of the referenced table is not shared with a column in this table,
|
|
// so it is safe to output prop name based on the referenced entity.
|
|
return referencedTableName;
|
|
}
|
|
// Any ambiguous FK is rendered with a name based on the FK constraint name
|
|
let finalPropBaseName = currentFk.constraintName;
|
|
while (this.getColumn(finalPropBaseName)) {
|
|
// In the unlikely event that the FK constraint name is shared by a column name, generate a name by
|
|
// continuously prefixing with "fk_", until a non-existent column is hit.
|
|
// The worst case scenario is a very long name with several repeated "fk_"
|
|
// that is not really a valid DB identifier but a valid JS variable name.
|
|
finalPropBaseName = `fk_${finalPropBaseName}`;
|
|
}
|
|
return finalPropBaseName;
|
|
}
|
|
/**
|
|
* The shortest name is stripped of the default namespace. All other namespaced elements are returned as full-qualified names.
|
|
*/
|
|
getShortestName(skipDefaultSchema = true) {
|
|
const defaultSchema = this.#platform.getDefaultSchemaName();
|
|
if (
|
|
!this.schema ||
|
|
this.name.startsWith(defaultSchema + '.') ||
|
|
(this.schema === defaultSchema && skipDefaultSchema)
|
|
) {
|
|
return this.name;
|
|
}
|
|
return `${this.schema}.${this.name}`;
|
|
}
|
|
getForeignKeys() {
|
|
return this.#foreignKeys;
|
|
}
|
|
hasColumn(columnName) {
|
|
return columnName in this.#columns;
|
|
}
|
|
getIndex(indexName) {
|
|
return this.#indexes.find(i => i.keyName === indexName);
|
|
}
|
|
hasIndex(indexName) {
|
|
return !!this.getIndex(indexName);
|
|
}
|
|
getCheck(checkName) {
|
|
return this.#checks.find(i => i.name === checkName);
|
|
}
|
|
hasCheck(checkName) {
|
|
return !!this.getCheck(checkName);
|
|
}
|
|
getPrimaryKey() {
|
|
return this.#indexes.find(i => i.primary);
|
|
}
|
|
hasPrimaryKey() {
|
|
return !!this.getPrimaryKey();
|
|
}
|
|
getForeignKeyDeclaration(fk, namingStrategy, schemaHelper, fkIndex, nullable, propNameBase, fksOnColumnProps) {
|
|
const prop = this.getPropertyName(namingStrategy, propNameBase, fk);
|
|
const kind = fkIndex?.unique && !fkIndex.primary ? this.getReferenceKind(fk, fkIndex) : this.getReferenceKind(fk);
|
|
const runtimeType = this.getPropertyTypeForForeignKey(namingStrategy, fk);
|
|
const fkOptions = {};
|
|
fkOptions.fieldNames = fk.columnNames;
|
|
fkOptions.referencedTableName = fk.referencedTableName;
|
|
fkOptions.referencedColumnNames = fk.referencedColumnNames;
|
|
fkOptions.updateRule = fk.updateRule?.toLowerCase();
|
|
fkOptions.deleteRule = fk.deleteRule?.toLowerCase();
|
|
fkOptions.deferMode = fk.deferMode;
|
|
fkOptions.columnTypes = fk.columnNames.map(c => this.getColumn(c).type);
|
|
const columnOptions = {};
|
|
if (fk.columnNames.length === 1) {
|
|
const column = this.getColumn(fk.columnNames[0]);
|
|
const defaultRaw = this.getPropertyDefaultValue(schemaHelper, column, column.type, true);
|
|
const defaultTs = this.getPropertyDefaultValue(schemaHelper, column, column.type);
|
|
columnOptions.default = defaultRaw !== defaultTs || defaultRaw === '' ? defaultTs : undefined;
|
|
columnOptions.defaultRaw = column.nullable && defaultRaw === 'null' ? undefined : defaultRaw;
|
|
columnOptions.optional = typeof column.generated !== 'undefined' || defaultRaw !== 'null';
|
|
columnOptions.generated = column.generated;
|
|
columnOptions.nullable = column.nullable;
|
|
columnOptions.primary = column.primary;
|
|
columnOptions.length = column.length;
|
|
columnOptions.precision = column.precision;
|
|
columnOptions.scale = column.scale;
|
|
columnOptions.extra = column.extra;
|
|
columnOptions.comment = column.comment;
|
|
columnOptions.enum = !!column.enumItems?.length;
|
|
columnOptions.items = column.enumItems;
|
|
}
|
|
return {
|
|
name: prop,
|
|
type: runtimeType,
|
|
runtimeType,
|
|
kind,
|
|
...columnOptions,
|
|
nullable,
|
|
primary:
|
|
fkIndex?.primary || !fk.columnNames.some(columnName => !this.getPrimaryKey()?.columnNames.includes(columnName)),
|
|
index: !fkIndex?.unique ? fkIndex?.keyName : undefined,
|
|
unique: fkIndex?.unique && !fkIndex.primary ? fkIndex.keyName : undefined,
|
|
...fkOptions,
|
|
};
|
|
}
|
|
getPropertyDeclaration(column, namingStrategy, schemaHelper, compositeFkIndexes, compositeFkUniques, columnFks, fk) {
|
|
const prop = this.getPropertyName(namingStrategy, column.name, fk);
|
|
const persist = !(column.name in columnFks && typeof fk === 'undefined');
|
|
const index =
|
|
compositeFkIndexes[prop] ||
|
|
this.#indexes.find(idx => idx.columnNames[0] === column.name && !idx.composite && !idx.unique && !idx.primary);
|
|
const unique =
|
|
compositeFkUniques[prop] ||
|
|
this.#indexes.find(idx => idx.columnNames[0] === column.name && !idx.composite && idx.unique && !idx.primary);
|
|
const kind = this.getReferenceKind(fk, unique);
|
|
const runtimeType = this.getPropertyTypeForColumn(namingStrategy, column, fk);
|
|
const type = fk
|
|
? runtimeType
|
|
: (Utils.keys(t).find(k => {
|
|
const typeInCoreMap = this.#platform.getMappedType(k);
|
|
return (
|
|
(typeInCoreMap !== Type.getType(UnknownType) || k === 'unknown') && typeInCoreMap === column.mappedType
|
|
);
|
|
}) ?? runtimeType);
|
|
const ignoreSchemaChanges =
|
|
type === 'unknown' && column.length ? (column.extra ? ['type', 'extra'] : ['type']) : undefined;
|
|
const defaultRaw = this.getPropertyDefaultValue(schemaHelper, column, runtimeType, true);
|
|
const defaultParsed = this.getPropertyDefaultValue(schemaHelper, column, runtimeType);
|
|
const defaultTs = defaultRaw !== defaultParsed || defaultParsed === '' ? defaultParsed : undefined;
|
|
const fkOptions = {};
|
|
if (fk) {
|
|
fkOptions.fieldNames = fk.columnNames;
|
|
fkOptions.referencedTableName = fk.referencedTableName;
|
|
fkOptions.referencedColumnNames = fk.referencedColumnNames;
|
|
fkOptions.updateRule = fk.updateRule?.toLowerCase();
|
|
fkOptions.deleteRule = fk.deleteRule?.toLowerCase();
|
|
fkOptions.deferMode = fk.deferMode;
|
|
fkOptions.columnTypes = fk.columnNames.map(col => this.getColumn(col).type);
|
|
}
|
|
const ret = {
|
|
name: prop,
|
|
type,
|
|
runtimeType,
|
|
kind,
|
|
ignoreSchemaChanges,
|
|
generated: column.generated,
|
|
optional: defaultRaw !== 'null' || defaultTs != null || typeof column.generated !== 'undefined',
|
|
columnType: column.type,
|
|
default: defaultTs,
|
|
defaultRaw: column.nullable && defaultRaw === 'null' ? undefined : defaultRaw,
|
|
nullable: column.nullable,
|
|
primary: column.primary && persist,
|
|
autoincrement: column.autoincrement,
|
|
fieldName: column.name,
|
|
unsigned: column.unsigned,
|
|
length: column.length,
|
|
precision: column.precision,
|
|
scale: column.scale,
|
|
extra: column.extra,
|
|
comment: column.comment,
|
|
index: index ? index.keyName : undefined,
|
|
unique: unique ? unique.keyName : undefined,
|
|
enum: !!column.enumItems?.length,
|
|
items: column.enumItems,
|
|
persist,
|
|
...fkOptions,
|
|
};
|
|
const nativeEnumName = Object.keys(this.nativeEnums).find(name => name === column.type);
|
|
if (nativeEnumName) {
|
|
ret.nativeEnumName = nativeEnumName;
|
|
}
|
|
return ret;
|
|
}
|
|
getReferenceKind(fk, unique) {
|
|
if (fk && unique) {
|
|
return ReferenceKind.ONE_TO_ONE;
|
|
}
|
|
if (fk) {
|
|
return ReferenceKind.MANY_TO_ONE;
|
|
}
|
|
return ReferenceKind.SCALAR;
|
|
}
|
|
getPropertyName(namingStrategy, baseName, fk) {
|
|
let field = baseName;
|
|
if (fk) {
|
|
const idx = fk.columnNames.indexOf(baseName);
|
|
let replacedFieldName = field.replace(new RegExp(`_${fk.referencedColumnNames[idx]}$`), '');
|
|
if (replacedFieldName === field) {
|
|
replacedFieldName = field.replace(new RegExp(`_${namingStrategy.referenceColumnName()}$`), '');
|
|
}
|
|
field = replacedFieldName;
|
|
}
|
|
if (field.startsWith('_')) {
|
|
return field;
|
|
}
|
|
return namingStrategy.columnNameToProperty(field);
|
|
}
|
|
getPropertyTypeForForeignKey(namingStrategy, fk) {
|
|
const parts = fk.referencedTableName.split('.', 2);
|
|
return namingStrategy.getEntityName(...parts.reverse());
|
|
}
|
|
getPropertyTypeForColumn(namingStrategy, column, fk) {
|
|
if (fk) {
|
|
return this.getPropertyTypeForForeignKey(namingStrategy, fk);
|
|
}
|
|
const enumMode = this.#platform.getConfig().get('entityGenerator').enumMode;
|
|
// If this column is using an enum.
|
|
if (column.enumItems?.length) {
|
|
const name = column.nativeEnumName ?? column.name;
|
|
const tableName = column.nativeEnumName ? undefined : this.name;
|
|
if (enumMode === 'ts-enum') {
|
|
// We will create a new enum name for this type and set it as the property type as well.
|
|
return namingStrategy.getEnumClassName(name, tableName, this.schema);
|
|
}
|
|
// With other enum strategies, we need to use the type name.
|
|
return namingStrategy.getEnumTypeName(name, tableName, this.schema);
|
|
}
|
|
return column.mappedType?.runtimeType ?? 'unknown';
|
|
}
|
|
getPropertyDefaultValue(schemaHelper, column, propType, raw = false) {
|
|
const defaultValue = column.default ?? 'null';
|
|
const val = schemaHelper.normalizeDefaultValue(defaultValue, column.length);
|
|
if (val === 'null') {
|
|
return raw ? 'null' : column.nullable ? null : undefined;
|
|
}
|
|
if (propType === 'boolean' && !raw) {
|
|
return !['0', 'false', 'f', 'n', 'no', 'off'].includes('' + column.default);
|
|
}
|
|
if (propType === 'number' && !raw) {
|
|
return +defaultValue;
|
|
}
|
|
// unquote string defaults if `raw = false`
|
|
const match = /^'(.*)'$/.exec('' + val);
|
|
if (!raw && match) {
|
|
return match[1];
|
|
}
|
|
return '' + val;
|
|
}
|
|
processIndexExpression(indexName, expression, meta) {
|
|
if (expression instanceof Function) {
|
|
const qualifiedName = this.schema ? `${this.schema}.${this.name}` : this.name;
|
|
const table = {
|
|
name: this.name,
|
|
schema: this.schema,
|
|
qualifiedName,
|
|
toString: () => qualifiedName,
|
|
};
|
|
const columns = meta.createSchemaColumnMappingObject();
|
|
const exp = expression(columns, table, indexName);
|
|
return exp instanceof RawQueryFragment ? this.#platform.formatQuery(exp.sql, exp.params) : exp;
|
|
}
|
|
return expression;
|
|
}
|
|
addIndex(meta, index, type) {
|
|
// If columns are specified but properties are not, derive properties from column names
|
|
if (
|
|
index.columns?.length &&
|
|
!index.expression &&
|
|
(!index.properties || Utils.asArray(index.properties).length === 0)
|
|
) {
|
|
index = { ...index, properties: index.columns.map(c => c.name) };
|
|
}
|
|
const properties = Utils.unique(
|
|
Utils.flatten(
|
|
Utils.asArray(index.properties).map(prop => {
|
|
const parts = prop.split('.');
|
|
const root = parts[0];
|
|
if (meta.properties[prop]) {
|
|
if (meta.properties[prop].embeddedPath) {
|
|
return [meta.properties[prop].embeddedPath.join('.')];
|
|
}
|
|
return meta.properties[prop].fieldNames;
|
|
}
|
|
const rootProp = meta.properties[root];
|
|
// inline embedded property index, we need to find the field name of the child property
|
|
if (rootProp?.embeddable && !rootProp.object && parts.length > 1) {
|
|
const expand = (p, i) => {
|
|
if (parts.length === i) {
|
|
return p.fieldNames[0];
|
|
}
|
|
return expand(p.embeddedProps[parts[i]], i + 1);
|
|
};
|
|
return [expand(rootProp, 1)];
|
|
}
|
|
// json index, we need to rename the column only
|
|
if (rootProp) {
|
|
return [prop.replace(root, rootProp.fieldNames[0])];
|
|
}
|
|
/* v8 ignore next */
|
|
return [prop];
|
|
}),
|
|
),
|
|
);
|
|
if (properties.length === 0 && !index.expression) {
|
|
return;
|
|
}
|
|
const name = this.getIndexName(index.name, properties, type);
|
|
// Process include columns (map property names to field names)
|
|
const includeColumns = index.include
|
|
? Utils.unique(
|
|
Utils.flatten(
|
|
Utils.asArray(index.include).map(prop => {
|
|
if (meta.properties[prop]) {
|
|
return meta.properties[prop].fieldNames;
|
|
}
|
|
/* v8 ignore next */
|
|
return [prop];
|
|
}),
|
|
),
|
|
)
|
|
: undefined;
|
|
// Process columns with advanced options (map property names to field names)
|
|
const columns = index.columns?.map(col => {
|
|
const fieldName = meta.properties[col.name]?.fieldNames[0] ?? col.name;
|
|
return {
|
|
name: fieldName,
|
|
sort: col.sort?.toUpperCase(),
|
|
nulls: col.nulls?.toUpperCase(),
|
|
length: col.length,
|
|
collation: col.collation,
|
|
};
|
|
});
|
|
// Validate that column options reference fields in the index properties
|
|
if (columns?.length && properties.length > 0) {
|
|
for (const col of columns) {
|
|
if (!properties.includes(col.name)) {
|
|
throw new Error(
|
|
`Index '${name}' on entity '${meta.className}': column option references field '${col.name}' which is not in the index properties`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Validate fillFactor range
|
|
if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
|
|
throw new Error(
|
|
`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`,
|
|
);
|
|
}
|
|
this.#indexes.push({
|
|
keyName: name,
|
|
columnNames: properties,
|
|
composite: properties.length > 1,
|
|
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
constraint: type !== 'index' && !properties.some(d => d.includes('.')),
|
|
primary: type === 'primary',
|
|
unique: type !== 'index',
|
|
type: index.type,
|
|
expression: this.processIndexExpression(name, index.expression, meta),
|
|
options: index.options,
|
|
deferMode: index.deferMode,
|
|
columns,
|
|
include: includeColumns,
|
|
fillFactor: index.fillFactor,
|
|
invisible: index.invisible,
|
|
disabled: index.disabled,
|
|
clustered: index.clustered,
|
|
});
|
|
}
|
|
addCheck(check) {
|
|
this.#checks.push(check);
|
|
}
|
|
toJSON() {
|
|
const columns = this.#columns;
|
|
const columnsMapped = Utils.keys(columns).reduce((o, col) => {
|
|
const c = columns[col];
|
|
const normalized = {
|
|
name: c.name,
|
|
type: c.type,
|
|
unsigned: !!c.unsigned,
|
|
autoincrement: !!c.autoincrement,
|
|
primary: !!c.primary,
|
|
nullable: !!c.nullable,
|
|
unique: !!c.unique,
|
|
length: c.length ?? null,
|
|
precision: c.precision ?? null,
|
|
scale: c.scale ?? null,
|
|
default: c.default ?? null,
|
|
comment: c.comment ?? null,
|
|
enumItems: c.enumItems ?? [],
|
|
mappedType: Utils.keys(t).find(k => t[k] === c.mappedType.constructor),
|
|
};
|
|
if (c.generated) {
|
|
normalized.generated = c.generated;
|
|
}
|
|
if (c.nativeEnumName) {
|
|
normalized.nativeEnumName = c.nativeEnumName;
|
|
}
|
|
if (c.extra) {
|
|
normalized.extra = c.extra;
|
|
}
|
|
if (c.ignoreSchemaChanges) {
|
|
normalized.ignoreSchemaChanges = c.ignoreSchemaChanges;
|
|
}
|
|
if (c.defaultConstraint) {
|
|
normalized.defaultConstraint = c.defaultConstraint;
|
|
}
|
|
o[col] = normalized;
|
|
return o;
|
|
}, {});
|
|
return {
|
|
name: this.name,
|
|
schema: this.schema,
|
|
columns: columnsMapped,
|
|
indexes: this.#indexes,
|
|
checks: this.#checks,
|
|
foreignKeys: this.#foreignKeys,
|
|
nativeEnums: this.nativeEnums,
|
|
comment: this.comment,
|
|
};
|
|
}
|
|
}
|