2124 lines
87 KiB
JavaScript
2124 lines
87 KiB
JavaScript
import {
|
|
ALIAS_REPLACEMENT_RE,
|
|
DatabaseDriver,
|
|
EntityManagerType,
|
|
getLoadingStrategy,
|
|
getOnConflictFields,
|
|
getOnConflictReturningFields,
|
|
helper,
|
|
isRaw,
|
|
LoadStrategy,
|
|
parseJsonSafe,
|
|
PolymorphicRef,
|
|
QueryFlag,
|
|
QueryHelper,
|
|
QueryOrder,
|
|
raw,
|
|
RawQueryFragment,
|
|
ReferenceKind,
|
|
Utils,
|
|
} from '@mikro-orm/core';
|
|
import { QueryBuilder } from './query/QueryBuilder.js';
|
|
import { JoinType, QueryType } from './query/enums.js';
|
|
import { SqlEntityManager } from './SqlEntityManager.js';
|
|
import { PivotCollectionPersister } from './PivotCollectionPersister.js';
|
|
/** Base class for SQL database drivers, implementing find/insert/update/delete using QueryBuilder. */
|
|
export class AbstractSqlDriver extends DatabaseDriver {
|
|
[EntityManagerType];
|
|
connection;
|
|
replicas = [];
|
|
platform;
|
|
constructor(config, platform, connection, connector) {
|
|
super(config, connector);
|
|
this.connection = new connection(this.config);
|
|
this.replicas = this.createReplicas(conf => new connection(this.config, conf, 'read'));
|
|
this.platform = platform;
|
|
}
|
|
getPlatform() {
|
|
return this.platform;
|
|
}
|
|
/** Evaluates a formula callback, handling both string and Raw return values. */
|
|
evaluateFormula(formula, columns, table) {
|
|
const result = formula(columns, table);
|
|
return isRaw(result) ? this.platform.formatQuery(result.sql, result.params) : result;
|
|
}
|
|
/** For TPT entities, returns ownProps (columns in this table); otherwise returns all props. */
|
|
getTableProps(meta) {
|
|
return meta.inheritanceType === 'tpt' && meta.ownProps ? meta.ownProps : meta.props;
|
|
}
|
|
/** Creates a FormulaTable object for use in formula callbacks. */
|
|
createFormulaTable(alias, meta, schema) {
|
|
const effectiveSchema = schema ?? (meta.schema !== '*' ? meta.schema : undefined);
|
|
const qualifiedName = effectiveSchema ? `${effectiveSchema}.${meta.tableName}` : meta.tableName;
|
|
return { alias, name: meta.tableName, schema: effectiveSchema, qualifiedName, toString: () => alias };
|
|
}
|
|
validateSqlOptions(options) {
|
|
if (options.collation != null && typeof options.collation !== 'string') {
|
|
throw new Error(
|
|
'Collation option for SQL drivers must be a string (collation name). Use a CollationOptions object only with MongoDB.',
|
|
);
|
|
}
|
|
if (options.indexHint != null && typeof options.indexHint !== 'string') {
|
|
throw new Error(
|
|
"indexHint for SQL drivers must be a string (e.g. 'force index(my_index)'). Use an object only with MongoDB.",
|
|
);
|
|
}
|
|
}
|
|
createEntityManager(useContext) {
|
|
const EntityManagerClass = this.config.get('entityManager', SqlEntityManager);
|
|
return new EntityManagerClass(this.config, this, this.metadata, useContext);
|
|
}
|
|
async createQueryBuilderFromOptions(meta, where, options = {}) {
|
|
const connectionType = this.resolveConnectionType({ ctx: options.ctx, connectionType: options.connectionType });
|
|
const populate = this.autoJoinOneToOneOwner(meta, options.populate, options.fields);
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
const schema = this.getSchemaName(meta, options);
|
|
const qb = this.createQueryBuilder(
|
|
meta.class,
|
|
options.ctx,
|
|
connectionType,
|
|
false,
|
|
options.logging,
|
|
undefined,
|
|
options.em,
|
|
).withSchema(schema);
|
|
const fields = this.buildFields(meta, populate, joinedProps, qb, qb.alias, options, schema);
|
|
const orderBy = this.buildOrderBy(qb, meta, populate, options);
|
|
const populateWhere = this.buildPopulateWhere(meta, joinedProps, options);
|
|
Utils.asArray(options.flags).forEach(flag => qb.setFlag(flag));
|
|
if (Utils.isPrimaryKey(where, meta.compositePK)) {
|
|
where = { [Utils.getPrimaryKeyHash(meta.primaryKeys)]: where };
|
|
}
|
|
this.validateSqlOptions(options);
|
|
const { first, last, before, after } = options;
|
|
const isCursorPagination = [first, last, before, after].some(v => v != null);
|
|
qb.state.resolvedPopulateWhere = options._populateWhere;
|
|
qb.select(fields)
|
|
// only add populateWhere if we are populate-joining, as this will be used to add `on` conditions
|
|
.populate(
|
|
populate,
|
|
joinedProps.length > 0 ? populateWhere : undefined,
|
|
joinedProps.length > 0 ? options.populateFilter : undefined,
|
|
)
|
|
.where(where)
|
|
.groupBy(options.groupBy)
|
|
.having(options.having)
|
|
.indexHint(options.indexHint)
|
|
.collation(options.collation)
|
|
.comment(options.comments)
|
|
.hintComment(options.hintComments);
|
|
if (isCursorPagination) {
|
|
const { orderBy: newOrderBy, where } = this.processCursorOptions(meta, options, orderBy);
|
|
qb.andWhere(where).orderBy(newOrderBy);
|
|
} else {
|
|
qb.orderBy(orderBy);
|
|
}
|
|
if (options.limit != null || options.offset != null) {
|
|
qb.limit(options.limit, options.offset);
|
|
}
|
|
if (options.lockMode) {
|
|
qb.setLockMode(options.lockMode, options.lockTableAliases);
|
|
}
|
|
if (options.em) {
|
|
await qb.applyJoinedFilters(options.em, options.filters);
|
|
}
|
|
return qb;
|
|
}
|
|
async find(entityName, where, options = {}) {
|
|
options = { populate: [], orderBy: [], ...options };
|
|
const meta = this.metadata.get(entityName);
|
|
if (meta.virtual) {
|
|
return this.findVirtual(entityName, where, options);
|
|
}
|
|
if (options.unionWhere?.length) {
|
|
where = await this.applyUnionWhere(meta, where, options);
|
|
}
|
|
const qb = await this.createQueryBuilderFromOptions(meta, where, options);
|
|
const result = await this.rethrow(qb.execute('all'));
|
|
if (options.last && !options.first) {
|
|
result.reverse();
|
|
}
|
|
return result;
|
|
}
|
|
async findOne(entityName, where, options) {
|
|
const opts = { populate: [], ...options };
|
|
const meta = this.metadata.find(entityName);
|
|
const populate = this.autoJoinOneToOneOwner(meta, opts.populate, opts.fields);
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
const hasToManyJoins = joinedProps.some(hint => this.hasToManyJoins(hint, meta));
|
|
if (joinedProps.length === 0 || !hasToManyJoins) {
|
|
opts.limit = 1;
|
|
}
|
|
if (opts.limit > 0 && !opts.flags?.includes(QueryFlag.DISABLE_PAGINATE)) {
|
|
opts.flags ??= [];
|
|
opts.flags.push(QueryFlag.DISABLE_PAGINATE);
|
|
}
|
|
const res = await this.find(entityName, where, opts);
|
|
return res[0] || null;
|
|
}
|
|
hasToManyJoins(hint, meta) {
|
|
const [propName] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName];
|
|
if (prop && [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
|
|
return true;
|
|
}
|
|
if (hint.children && prop.targetMeta) {
|
|
return hint.children.some(hint => this.hasToManyJoins(hint, prop.targetMeta));
|
|
}
|
|
return false;
|
|
}
|
|
async findVirtual(entityName, where, options) {
|
|
return this.findFromVirtual(entityName, where, options, QueryType.SELECT);
|
|
}
|
|
async countVirtual(entityName, where, options) {
|
|
return this.findFromVirtual(entityName, where, options, QueryType.COUNT);
|
|
}
|
|
async findFromVirtual(entityName, where, options, type) {
|
|
const meta = this.metadata.get(entityName);
|
|
/* v8 ignore next */
|
|
if (!meta.expression) {
|
|
return type === QueryType.SELECT ? [] : 0;
|
|
}
|
|
if (typeof meta.expression === 'string') {
|
|
return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options, type);
|
|
}
|
|
const em = this.createEntityManager();
|
|
em.setTransactionContext(options.ctx);
|
|
const res = meta.expression(em, where, options);
|
|
if (typeof res === 'string') {
|
|
return this.wrapVirtualExpressionInSubquery(meta, res, where, options, type);
|
|
}
|
|
if (res instanceof QueryBuilder) {
|
|
return this.wrapVirtualExpressionInSubquery(meta, res.getFormattedQuery(), where, options, type);
|
|
}
|
|
if (res instanceof RawQueryFragment) {
|
|
const expr = this.platform.formatQuery(res.sql, res.params);
|
|
return this.wrapVirtualExpressionInSubquery(meta, expr, where, options, type);
|
|
}
|
|
/* v8 ignore next */
|
|
return res;
|
|
}
|
|
async *streamFromVirtual(entityName, where, options) {
|
|
const meta = this.metadata.get(entityName);
|
|
/* v8 ignore next */
|
|
if (!meta.expression) {
|
|
return;
|
|
}
|
|
if (typeof meta.expression === 'string') {
|
|
yield* this.wrapVirtualExpressionInSubqueryStream(meta, meta.expression, where, options, QueryType.SELECT);
|
|
return;
|
|
}
|
|
const em = this.createEntityManager();
|
|
em.setTransactionContext(options.ctx);
|
|
const res = meta.expression(em, where, options, true);
|
|
if (typeof res === 'string') {
|
|
yield* this.wrapVirtualExpressionInSubqueryStream(meta, res, where, options, QueryType.SELECT);
|
|
return;
|
|
}
|
|
if (res instanceof QueryBuilder) {
|
|
yield* this.wrapVirtualExpressionInSubqueryStream(
|
|
meta,
|
|
res.getFormattedQuery(),
|
|
where,
|
|
options,
|
|
QueryType.SELECT,
|
|
);
|
|
return;
|
|
}
|
|
if (res instanceof RawQueryFragment) {
|
|
const expr = this.platform.formatQuery(res.sql, res.params);
|
|
yield* this.wrapVirtualExpressionInSubqueryStream(meta, expr, where, options, QueryType.SELECT);
|
|
return;
|
|
}
|
|
/* v8 ignore next */
|
|
yield* res;
|
|
}
|
|
async wrapVirtualExpressionInSubquery(meta, expression, where, options, type) {
|
|
const qb = await this.createQueryBuilderFromOptions(meta, where, this.forceBalancedStrategy(options));
|
|
qb.setFlag(QueryFlag.DISABLE_PAGINATE);
|
|
const isCursorPagination = [options.first, options.last, options.before, options.after].some(v => v != null);
|
|
const native = qb.getNativeQuery(false);
|
|
if (type === QueryType.COUNT) {
|
|
native.clear('select').clear('limit').clear('offset').count();
|
|
}
|
|
const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
|
|
native.from(raw(`(${expression})${asKeyword}${this.platform.quoteIdentifier(qb.alias)}`));
|
|
const query = native.compile();
|
|
const res = await this.execute(query.sql, query.params, 'all', options.ctx);
|
|
if (type === QueryType.COUNT) {
|
|
return res[0].count;
|
|
}
|
|
if (isCursorPagination && !options.first && !!options.last) {
|
|
res.reverse();
|
|
}
|
|
return res.map(row => this.mapResult(row, meta));
|
|
}
|
|
async *wrapVirtualExpressionInSubqueryStream(meta, expression, where, options, type) {
|
|
const qb = await this.createQueryBuilderFromOptions(meta, where, this.forceBalancedStrategy(options));
|
|
qb.unsetFlag(QueryFlag.DISABLE_PAGINATE);
|
|
const native = qb.getNativeQuery(false);
|
|
const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
|
|
native.from(raw(`(${expression})${asKeyword}${this.platform.quoteIdentifier(qb.alias)}`));
|
|
const query = native.compile();
|
|
const connectionType = this.resolveConnectionType({ ctx: options.ctx, connectionType: options.connectionType });
|
|
const res = this.getConnection(connectionType).stream(query.sql, query.params, options.ctx, options.loggerContext);
|
|
for await (const row of res) {
|
|
yield this.mapResult(row, meta);
|
|
}
|
|
}
|
|
/**
|
|
* Virtual entities have no PKs, so to-many populate joins can't be deduplicated.
|
|
* Force balanced strategy to load to-many relations via separate queries.
|
|
*/
|
|
forceBalancedStrategy(options) {
|
|
const clearStrategy = hints => {
|
|
return hints.map(hint => ({
|
|
...hint,
|
|
strategy: undefined,
|
|
children: hint.children ? clearStrategy(hint.children) : undefined,
|
|
}));
|
|
};
|
|
const opts = { ...options, strategy: 'balanced' };
|
|
if (Array.isArray(opts.populate)) {
|
|
opts.populate = clearStrategy(opts.populate);
|
|
}
|
|
return opts;
|
|
}
|
|
mapResult(result, meta, populate = [], qb, map = {}) {
|
|
// For TPT inheritance, map aliased parent table columns back to their field names
|
|
if (qb && meta.inheritanceType === 'tpt' && meta.tptParent) {
|
|
this.mapTPTColumns(result, meta, qb);
|
|
}
|
|
// For TPT polymorphic queries (querying a base class), map child table fields
|
|
if (qb && meta.inheritanceType === 'tpt' && meta.allTPTDescendants?.length) {
|
|
const mainAlias = qb.mainAlias?.aliasName ?? 'e0';
|
|
this.mapTPTChildFields(result, meta, mainAlias, qb, result);
|
|
}
|
|
const ret = super.mapResult(result, meta);
|
|
/* v8 ignore next */
|
|
if (!ret) {
|
|
return null;
|
|
}
|
|
if (qb) {
|
|
// here we map the aliased results (cartesian product) to an object graph
|
|
this.mapJoinedProps(ret, meta, populate, qb, ret, map);
|
|
}
|
|
return ret;
|
|
}
|
|
/**
|
|
* Maps aliased columns from TPT parent tables back to their original field names.
|
|
* TPT parent columns are selected with aliases like `parent_alias__column_name`,
|
|
* and need to be renamed back to `column_name` for the result mapper to work.
|
|
*/
|
|
mapTPTColumns(result, meta, qb) {
|
|
const tptAliases = qb.state.tptAlias;
|
|
// Walk up the TPT hierarchy
|
|
let parentMeta = meta.tptParent;
|
|
while (parentMeta) {
|
|
const parentAlias = tptAliases[parentMeta.className];
|
|
if (parentAlias) {
|
|
// Rename columns from this parent table
|
|
for (const prop of parentMeta.ownProps) {
|
|
for (const fieldName of prop.fieldNames) {
|
|
const aliasedKey = `${parentAlias}__${fieldName}`;
|
|
if (aliasedKey in result) {
|
|
// Copy the value to the unaliased field name and remove the aliased key
|
|
result[fieldName] = result[aliasedKey];
|
|
delete result[aliasedKey];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
parentMeta = parentMeta.tptParent;
|
|
}
|
|
}
|
|
mapJoinedProps(result, meta, populate, qb, root, map, parentJoinPath) {
|
|
const joinedProps = this.joinedProps(meta, populate);
|
|
joinedProps.forEach(hint => {
|
|
const [propName, ref] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName];
|
|
/* v8 ignore next */
|
|
if (!prop) {
|
|
return;
|
|
}
|
|
// Polymorphic to-one: iterate targets, find the matching one, build entity from its columns.
|
|
// Skip :ref hints — no JOINs were created, so the FK reference is already set by the result mapper.
|
|
if (
|
|
prop.polymorphic &&
|
|
prop.polymorphTargets?.length &&
|
|
!ref &&
|
|
[ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)
|
|
) {
|
|
const basePath = parentJoinPath ? `${parentJoinPath}.${prop.name}` : `${meta.name}.${prop.name}`;
|
|
const pathPrefix = !parentJoinPath ? '[populate]' : '';
|
|
let matched = false;
|
|
for (const targetMeta of prop.polymorphTargets) {
|
|
const targetPath = `${pathPrefix}${basePath}[${targetMeta.className}]`;
|
|
const relationAlias = qb.getAliasForJoinPath(targetPath, { matchPopulateJoins: true });
|
|
const meta2 = targetMeta;
|
|
const targetProps = meta2.props.filter(p => this.platform.shouldHaveColumn(p, hint.children || []));
|
|
const hasPK = meta2
|
|
.getPrimaryProps()
|
|
.every(pk => pk.fieldNames.every(name => root[`${relationAlias}__${name}`] != null));
|
|
if (hasPK && !matched) {
|
|
matched = true;
|
|
const relationPojo = {};
|
|
const tz = this.platform.getTimezone();
|
|
for (const p of targetProps) {
|
|
this.mapJoinedProp(relationPojo, p, relationAlias, root, tz, meta2);
|
|
}
|
|
// Inject the entity class constructor so that the factory creates the correct type
|
|
Object.defineProperty(relationPojo, 'constructor', {
|
|
value: meta2.class,
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
result[prop.name] = relationPojo;
|
|
const populateChildren = hint.children || [];
|
|
this.mapJoinedProps(relationPojo, meta2, populateChildren, qb, root, map, targetPath);
|
|
}
|
|
// Clean up aliased columns for ALL targets (even non-matching ones)
|
|
for (const p of targetProps) {
|
|
for (const name of p.fieldNames) {
|
|
delete root[`${relationAlias}__${name}`];
|
|
}
|
|
}
|
|
}
|
|
if (!matched) {
|
|
result[prop.name] = null;
|
|
}
|
|
return;
|
|
}
|
|
const pivotRefJoin = prop.kind === ReferenceKind.MANY_TO_MANY && ref;
|
|
const meta2 = prop.targetMeta;
|
|
let path = parentJoinPath ? `${parentJoinPath}.${prop.name}` : `${meta.name}.${prop.name}`;
|
|
if (!parentJoinPath) {
|
|
path = '[populate]' + path;
|
|
}
|
|
if (pivotRefJoin) {
|
|
path += '[pivot]';
|
|
}
|
|
const relationAlias = qb.getAliasForJoinPath(path, { matchPopulateJoins: true });
|
|
/* v8 ignore next */
|
|
if (!relationAlias) {
|
|
return;
|
|
}
|
|
// pivot ref joins via joined strategy need to be handled separately here, as they dont join the target entity
|
|
if (pivotRefJoin) {
|
|
let item;
|
|
if (prop.inverseJoinColumns.length > 1) {
|
|
// composite keys
|
|
item = prop.inverseJoinColumns.map(name => root[`${relationAlias}__${name}`]);
|
|
} else {
|
|
const alias = `${relationAlias}__${prop.inverseJoinColumns[0]}`;
|
|
item = root[alias];
|
|
}
|
|
prop.joinColumns.forEach(name => delete root[`${relationAlias}__${name}`]);
|
|
prop.inverseJoinColumns.forEach(name => delete root[`${relationAlias}__${name}`]);
|
|
result[prop.name] ??= [];
|
|
if (item) {
|
|
result[prop.name].push(item);
|
|
}
|
|
return;
|
|
}
|
|
const mapToPk = !hint.dataOnly && !!(ref || prop.mapToPk);
|
|
const targetProps = mapToPk
|
|
? meta2.getPrimaryProps()
|
|
: meta2.props.filter(prop => this.platform.shouldHaveColumn(prop, hint.children || []));
|
|
// If the primary key value for the relation is null, we know we haven't joined to anything
|
|
// and therefore we don't return any record (since all values would be null)
|
|
const hasPK = meta2.getPrimaryProps().every(pk =>
|
|
pk.fieldNames.every(name => {
|
|
return root[`${relationAlias}__${name}`] != null;
|
|
}),
|
|
);
|
|
if (!hasPK) {
|
|
if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind)) {
|
|
result[prop.name] = [];
|
|
}
|
|
if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
|
|
result[prop.name] = null;
|
|
}
|
|
for (const prop of targetProps) {
|
|
for (const name of prop.fieldNames) {
|
|
delete root[`${relationAlias}__${name}`];
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
let relationPojo = {};
|
|
meta2.props
|
|
.filter(prop => !ref && prop.persist === false && prop.fieldNames)
|
|
.forEach(prop => {
|
|
/* v8 ignore next */
|
|
if (prop.fieldNames.length > 1) {
|
|
// composite keys
|
|
relationPojo[prop.name] = prop.fieldNames.map(name => root[`${relationAlias}__${name}`]);
|
|
} else {
|
|
const alias = `${relationAlias}__${prop.fieldNames[0]}`;
|
|
relationPojo[prop.name] = root[alias];
|
|
}
|
|
});
|
|
const tz = this.platform.getTimezone();
|
|
for (const prop of targetProps) {
|
|
this.mapJoinedProp(relationPojo, prop, relationAlias, root, tz, meta2);
|
|
}
|
|
// Handle TPT polymorphic child fields - map fields from child table aliases
|
|
this.mapTPTChildFields(relationPojo, meta2, relationAlias, qb, root);
|
|
// properties can be mapped to multiple places, e.g. when sharing a column in multiple FKs,
|
|
// so we need to delete them after everything is mapped from given level
|
|
for (const prop of targetProps) {
|
|
for (const name of prop.fieldNames) {
|
|
delete root[`${relationAlias}__${name}`];
|
|
}
|
|
}
|
|
if (mapToPk) {
|
|
const tmp = Object.values(relationPojo);
|
|
/* v8 ignore next */
|
|
relationPojo = meta2.compositePK ? tmp : tmp[0];
|
|
}
|
|
if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind)) {
|
|
result[prop.name] ??= [];
|
|
result[prop.name].push(relationPojo);
|
|
} else {
|
|
result[prop.name] = relationPojo;
|
|
}
|
|
const populateChildren = hint.children || [];
|
|
this.mapJoinedProps(relationPojo, meta2, populateChildren, qb, root, map, path);
|
|
});
|
|
}
|
|
/**
|
|
* Maps a single property from a joined result row into the relation pojo.
|
|
* Handles polymorphic FKs, composite keys, Date parsing, and embedded objects.
|
|
*/
|
|
mapJoinedProp(relationPojo, prop, relationAlias, root, tz, meta, options) {
|
|
if (prop.fieldNames.every(name => typeof root[`${relationAlias}__${name}`] === 'undefined')) {
|
|
return;
|
|
}
|
|
if (prop.polymorphic) {
|
|
const discriminatorAlias = `${relationAlias}__${prop.fieldNames[0]}`;
|
|
const discriminatorValue = root[discriminatorAlias];
|
|
const pkFieldNames = prop.fieldNames.slice(1);
|
|
const pkValues = pkFieldNames.map(name => root[`${relationAlias}__${name}`]);
|
|
const pkValue = pkValues.length === 1 ? pkValues[0] : pkValues;
|
|
if (discriminatorValue != null && pkValue != null) {
|
|
relationPojo[prop.name] = new PolymorphicRef(discriminatorValue, pkValue);
|
|
} else {
|
|
relationPojo[prop.name] = null;
|
|
}
|
|
} else if (prop.fieldNames.length > 1) {
|
|
// composite keys
|
|
const fk = prop.fieldNames.map(name => root[`${relationAlias}__${name}`]);
|
|
const pk = Utils.mapFlatCompositePrimaryKey(fk, prop);
|
|
relationPojo[prop.name] = pk.every(val => val != null) ? pk : null;
|
|
} else if (prop.runtimeType === 'Date') {
|
|
const alias = `${relationAlias}__${prop.fieldNames[0]}`;
|
|
const value = root[alias];
|
|
if (
|
|
tz &&
|
|
tz !== 'local' &&
|
|
typeof value === 'string' &&
|
|
!value.includes('+') &&
|
|
value.lastIndexOf('-') < 11 &&
|
|
!value.endsWith('Z')
|
|
) {
|
|
relationPojo[prop.name] = this.platform.parseDate(value + tz);
|
|
} else if (['string', 'number'].includes(typeof value)) {
|
|
relationPojo[prop.name] = this.platform.parseDate(value);
|
|
} else {
|
|
relationPojo[prop.name] = value;
|
|
}
|
|
} else {
|
|
const alias = `${relationAlias}__${prop.fieldNames[0]}`;
|
|
relationPojo[prop.name] = root[alias];
|
|
if (prop.kind === ReferenceKind.EMBEDDED && (prop.object || meta.embeddable)) {
|
|
const item = parseJsonSafe(relationPojo[prop.name]);
|
|
if (Array.isArray(item)) {
|
|
relationPojo[prop.name] = item.map(row =>
|
|
row == null ? row : this.comparator.mapResult(prop.targetMeta, row),
|
|
);
|
|
} else {
|
|
relationPojo[prop.name] = item == null ? item : this.comparator.mapResult(prop.targetMeta, item);
|
|
}
|
|
}
|
|
}
|
|
if (options?.deleteFromRoot) {
|
|
for (const name of prop.fieldNames) {
|
|
delete root[`${relationAlias}__${name}`];
|
|
}
|
|
}
|
|
}
|
|
async count(entityName, where, options = {}) {
|
|
const meta = this.metadata.get(entityName);
|
|
if (meta.virtual) {
|
|
return this.countVirtual(entityName, where, options);
|
|
}
|
|
if (options.unionWhere?.length) {
|
|
where = await this.applyUnionWhere(meta, where, options);
|
|
}
|
|
options = { populate: [], ...options };
|
|
const populate = options.populate;
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
const schema = this.getSchemaName(meta, options);
|
|
const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false, options.logging);
|
|
const populateWhere = this.buildPopulateWhere(meta, joinedProps, options);
|
|
if (meta && !Utils.isEmpty(populate)) {
|
|
this.buildFields(meta, populate, joinedProps, qb, qb.alias, options, schema);
|
|
}
|
|
this.validateSqlOptions(options);
|
|
qb.state.resolvedPopulateWhere = options._populateWhere;
|
|
qb.indexHint(options.indexHint)
|
|
.collation(options.collation)
|
|
.comment(options.comments)
|
|
.hintComment(options.hintComments)
|
|
.groupBy(options.groupBy)
|
|
.having(options.having)
|
|
.populate(
|
|
populate,
|
|
joinedProps.length > 0 ? populateWhere : undefined,
|
|
joinedProps.length > 0 ? options.populateFilter : undefined,
|
|
)
|
|
.withSchema(schema)
|
|
.where(where);
|
|
if (options.em) {
|
|
await qb.applyJoinedFilters(options.em, options.filters);
|
|
}
|
|
return this.rethrow(qb.getCount());
|
|
}
|
|
async nativeInsert(entityName, data, options = {}) {
|
|
options.convertCustomTypes ??= true;
|
|
const meta = this.metadata.get(entityName);
|
|
const collections = this.extractManyToMany(meta, data);
|
|
const qb = this.createQueryBuilder(
|
|
entityName,
|
|
options.ctx,
|
|
'write',
|
|
options.convertCustomTypes,
|
|
options.loggerContext,
|
|
).withSchema(this.getSchemaName(meta, options));
|
|
const res = await this.rethrow(qb.insert(data).execute('run', false));
|
|
res.row = res.row || {};
|
|
let pk;
|
|
if (meta.primaryKeys.length > 1) {
|
|
// owner has composite pk
|
|
pk = Utils.getPrimaryKeyCond(data, meta.primaryKeys);
|
|
} else {
|
|
/* v8 ignore next */
|
|
res.insertId = data[meta.primaryKeys[0]] ?? res.insertId ?? res.row[meta.primaryKeys[0]];
|
|
if (options.convertCustomTypes && meta?.getPrimaryProp().customType) {
|
|
pk = [meta.getPrimaryProp().customType.convertToDatabaseValue(res.insertId, this.platform)];
|
|
} else {
|
|
pk = [res.insertId];
|
|
}
|
|
}
|
|
await this.processManyToMany(meta, pk, collections, false, options);
|
|
return res;
|
|
}
|
|
async nativeInsertMany(entityName, data, options = {}, transform) {
|
|
options.processCollections ??= true;
|
|
options.convertCustomTypes ??= true;
|
|
const entityMeta = this.metadata.get(entityName);
|
|
const meta = entityMeta.inheritanceType === 'tpt' ? entityMeta : entityMeta.root;
|
|
const collections = options.processCollections ? data.map(d => this.extractManyToMany(meta, d)) : [];
|
|
const pks = this.getPrimaryKeyFields(meta);
|
|
const set = new Set();
|
|
data.forEach(row => Utils.keys(row).forEach(k => set.add(k)));
|
|
const props = [...set].map(name => meta.properties[name] ?? { name, fieldNames: [name] });
|
|
// For STI with conflicting fieldNames, include all alternative columns
|
|
let fields = Utils.flatten(props.map(prop => prop.stiFieldNames ?? prop.fieldNames));
|
|
const duplicates = Utils.findDuplicates(fields);
|
|
const params = [];
|
|
if (duplicates.length) {
|
|
fields = Utils.unique(fields);
|
|
}
|
|
const tableName = this.getTableName(meta, options);
|
|
let sql = `insert into ${tableName} `;
|
|
sql +=
|
|
fields.length > 0
|
|
? '(' + fields.map(k => this.platform.quoteIdentifier(k)).join(', ') + ')'
|
|
: `(${this.platform.quoteIdentifier(pks[0])})`;
|
|
if (this.platform.usesOutputStatement()) {
|
|
const returningProps = this.getTableProps(meta)
|
|
.filter(prop => (prop.persist !== false && prop.defaultRaw) || prop.autoincrement || prop.generated)
|
|
.filter(prop => !(prop.name in data[0]) || isRaw(data[0][prop.name]));
|
|
const returningFields = Utils.flatten(returningProps.map(prop => prop.fieldNames));
|
|
sql +=
|
|
returningFields.length > 0
|
|
? ` output ${returningFields.map(field => 'inserted.' + this.platform.quoteIdentifier(field)).join(', ')}`
|
|
: '';
|
|
}
|
|
if (fields.length > 0 || this.platform.usesDefaultKeyword()) {
|
|
sql += ' values ';
|
|
} else {
|
|
sql += ' ' + data.map(() => `select null as ${this.platform.quoteIdentifier(pks[0])}`).join(' union all ');
|
|
}
|
|
const addParams = (prop, row) => {
|
|
const rowValue = row[prop.name];
|
|
if (prop.nullable && rowValue === null) {
|
|
params.push(null);
|
|
return;
|
|
}
|
|
let value = rowValue ?? prop.default;
|
|
if (prop.kind === ReferenceKind.EMBEDDED && prop.object) {
|
|
if (prop.array && value) {
|
|
value = this.platform.cloneEmbeddable(value);
|
|
for (let i = 0; i < value.length; i++) {
|
|
const item = value[i];
|
|
value[i] = this.mapDataToFieldNames(item, false, prop.embeddedProps, options.convertCustomTypes);
|
|
}
|
|
} else {
|
|
value = this.mapDataToFieldNames(value, false, prop.embeddedProps, options.convertCustomTypes);
|
|
}
|
|
}
|
|
if (typeof value === 'undefined' && this.platform.usesDefaultKeyword()) {
|
|
params.push(raw('default'));
|
|
return;
|
|
}
|
|
if (options.convertCustomTypes && prop.customType) {
|
|
params.push(
|
|
prop.customType.convertToDatabaseValue(value, this.platform, { key: prop.name, mode: 'query-data' }),
|
|
);
|
|
return;
|
|
}
|
|
params.push(value);
|
|
};
|
|
if (fields.length > 0 || this.platform.usesDefaultKeyword()) {
|
|
sql += data
|
|
.map(row => {
|
|
const keys = [];
|
|
const usedDups = [];
|
|
props.forEach(prop => {
|
|
// For STI with conflicting fieldNames, use discriminator to determine which field gets value
|
|
if (prop.stiFieldNames && prop.stiFieldNameMap && meta.discriminatorColumn) {
|
|
const activeField = prop.stiFieldNameMap[row[meta.discriminatorColumn]];
|
|
for (const field of prop.stiFieldNames) {
|
|
params.push(field === activeField ? row[prop.name] : null);
|
|
keys.push('?');
|
|
}
|
|
return;
|
|
}
|
|
if (prop.fieldNames.length > 1) {
|
|
const newFields = [];
|
|
let rawParam;
|
|
const target = row[prop.name];
|
|
if (prop.polymorphic && target instanceof PolymorphicRef) {
|
|
rawParam = target.toTuple();
|
|
} else {
|
|
rawParam = Utils.asArray(target) ?? prop.fieldNames.map(() => null);
|
|
}
|
|
// Deep flatten nested arrays when needed (for deeply nested composite keys like Tag -> Comment -> Post -> User)
|
|
const needsFlatten = rawParam.length !== prop.fieldNames.length && rawParam.some(v => Array.isArray(v));
|
|
const allParam = needsFlatten ? Utils.flatten(rawParam, true) : rawParam;
|
|
// TODO(v7): instead of making this conditional here, the entity snapshot should respect `ownColumns`,
|
|
// but that means changing the compiled PK getters, which might be seen as breaking
|
|
const columns = allParam.length > 1 ? prop.fieldNames : prop.ownColumns;
|
|
const param = [];
|
|
columns.forEach((field, idx) => {
|
|
if (usedDups.includes(field)) {
|
|
return;
|
|
}
|
|
newFields.push(field);
|
|
param.push(allParam[idx]);
|
|
});
|
|
newFields.forEach((field, idx) => {
|
|
if (!duplicates.includes(field) || !usedDups.includes(field)) {
|
|
params.push(param[idx]);
|
|
keys.push('?');
|
|
usedDups.push(field);
|
|
}
|
|
});
|
|
} else {
|
|
const field = prop.fieldNames[0];
|
|
if (!duplicates.includes(field) || !usedDups.includes(field)) {
|
|
if (
|
|
prop.customType &&
|
|
!prop.object &&
|
|
'convertToDatabaseValueSQL' in prop.customType &&
|
|
row[prop.name] != null &&
|
|
!isRaw(row[prop.name])
|
|
) {
|
|
keys.push(prop.customType.convertToDatabaseValueSQL('?', this.platform));
|
|
} else {
|
|
keys.push('?');
|
|
}
|
|
addParams(prop, row);
|
|
usedDups.push(field);
|
|
}
|
|
}
|
|
});
|
|
return '(' + (keys.join(', ') || 'default') + ')';
|
|
})
|
|
.join(', ');
|
|
}
|
|
if (meta && this.platform.usesReturningStatement()) {
|
|
const returningProps = this.getTableProps(meta)
|
|
.filter(prop => (prop.persist !== false && prop.defaultRaw) || prop.autoincrement || prop.generated)
|
|
.filter(prop => !(prop.name in data[0]) || isRaw(data[0][prop.name]));
|
|
const returningFields = Utils.flatten(returningProps.map(prop => prop.fieldNames));
|
|
/* v8 ignore next */
|
|
sql +=
|
|
returningFields.length > 0
|
|
? ` returning ${returningFields.map(field => this.platform.quoteIdentifier(field)).join(', ')}`
|
|
: '';
|
|
}
|
|
if (transform) {
|
|
sql = transform(sql);
|
|
}
|
|
const res = await this.execute(sql, params, 'run', options.ctx, options.loggerContext);
|
|
let pk;
|
|
/* v8 ignore next */
|
|
if (pks.length > 1) {
|
|
// owner has composite pk
|
|
pk = data.map(d => Utils.getPrimaryKeyCond(d, pks));
|
|
} else {
|
|
res.row ??= {};
|
|
res.rows ??= [];
|
|
pk = data.map((d, i) => d[pks[0]] ?? res.rows[i]?.[pks[0]]).map(d => [d]);
|
|
res.insertId = res.insertId || res.row[pks[0]];
|
|
}
|
|
for (let i = 0; i < collections.length; i++) {
|
|
await this.processManyToMany(meta, pk[i], collections[i], false, options);
|
|
}
|
|
return res;
|
|
}
|
|
async nativeUpdate(entityName, where, data, options = {}) {
|
|
options.convertCustomTypes ??= true;
|
|
const meta = this.metadata.get(entityName);
|
|
const pks = this.getPrimaryKeyFields(meta);
|
|
const collections = this.extractManyToMany(meta, data);
|
|
let res = { affectedRows: 0, insertId: 0, row: {} };
|
|
if (Utils.isPrimaryKey(where) && pks.length === 1) {
|
|
/* v8 ignore next */
|
|
where = { [meta.primaryKeys[0] ?? pks[0]]: where };
|
|
}
|
|
if (!options.upsert && options.unionWhere?.length) {
|
|
where = await this.applyUnionWhere(meta, where, options, true);
|
|
}
|
|
if (Utils.hasObjectKeys(data)) {
|
|
const qb = this.createQueryBuilder(
|
|
entityName,
|
|
options.ctx,
|
|
'write',
|
|
options.convertCustomTypes,
|
|
options.loggerContext,
|
|
).withSchema(this.getSchemaName(meta, options));
|
|
if (options.upsert) {
|
|
/* v8 ignore next */
|
|
const uniqueFields =
|
|
options.onConflictFields ?? (Utils.isPlainObject(where) ? Utils.keys(where) : meta.primaryKeys);
|
|
const returning = getOnConflictReturningFields(meta, data, uniqueFields, options);
|
|
qb.insert(data).onConflict(uniqueFields).returning(returning);
|
|
if (!options.onConflictAction || options.onConflictAction === 'merge') {
|
|
const fields = getOnConflictFields(meta, data, uniqueFields, options);
|
|
qb.merge(fields);
|
|
}
|
|
if (options.onConflictAction === 'ignore') {
|
|
qb.ignore();
|
|
}
|
|
if (options.onConflictWhere) {
|
|
qb.where(options.onConflictWhere);
|
|
}
|
|
} else {
|
|
qb.update(data).where(where);
|
|
// reload generated columns and version fields
|
|
const returning = [];
|
|
meta.props
|
|
.filter(prop => (prop.generated && !prop.primary) || prop.version)
|
|
.forEach(prop => returning.push(prop.name));
|
|
qb.returning(returning);
|
|
}
|
|
res = await this.rethrow(qb.execute('run', false));
|
|
}
|
|
/* v8 ignore next */
|
|
const pk = pks.map(pk => Utils.extractPK(data[pk] || where, meta));
|
|
await this.processManyToMany(meta, pk, collections, true, options);
|
|
return res;
|
|
}
|
|
async nativeUpdateMany(entityName, where, data, options = {}, transform) {
|
|
options.processCollections ??= true;
|
|
options.convertCustomTypes ??= true;
|
|
const meta = this.metadata.get(entityName);
|
|
if (options.upsert) {
|
|
const uniqueFields =
|
|
options.onConflictFields ??
|
|
(Utils.isPlainObject(where[0])
|
|
? Object.keys(where[0]).flatMap(key => Utils.splitPrimaryKeys(key))
|
|
: meta.primaryKeys);
|
|
const qb = this.createQueryBuilder(
|
|
entityName,
|
|
options.ctx,
|
|
'write',
|
|
options.convertCustomTypes,
|
|
options.loggerContext,
|
|
).withSchema(this.getSchemaName(meta, options));
|
|
const returning = getOnConflictReturningFields(meta, data[0], uniqueFields, options);
|
|
qb.insert(data).onConflict(uniqueFields).returning(returning);
|
|
if (!options.onConflictAction || options.onConflictAction === 'merge') {
|
|
const fields = getOnConflictFields(meta, data[0], uniqueFields, options);
|
|
qb.merge(fields);
|
|
}
|
|
if (options.onConflictAction === 'ignore') {
|
|
qb.ignore();
|
|
}
|
|
if (options.onConflictWhere) {
|
|
qb.where(options.onConflictWhere);
|
|
}
|
|
return this.rethrow(qb.execute('run', false));
|
|
}
|
|
const collections = options.processCollections ? data.map(d => this.extractManyToMany(meta, d)) : [];
|
|
const keys = new Set();
|
|
const fields = new Set();
|
|
const returning = new Set();
|
|
for (const row of data) {
|
|
for (const k of Utils.keys(row)) {
|
|
keys.add(k);
|
|
if (isRaw(row[k])) {
|
|
returning.add(k);
|
|
}
|
|
}
|
|
}
|
|
// reload generated columns and version fields
|
|
meta.props.filter(prop => prop.generated || prop.version || prop.primary).forEach(prop => returning.add(prop.name));
|
|
const pkCond = Utils.flatten(meta.primaryKeys.map(pk => meta.properties[pk].fieldNames))
|
|
.map(pk => `${this.platform.quoteIdentifier(pk)} = ?`)
|
|
.join(' and ');
|
|
const params = [];
|
|
let sql = `update ${this.getTableName(meta, options)} set `;
|
|
const addParams = (prop, value) => {
|
|
if (prop.kind === ReferenceKind.EMBEDDED && prop.object) {
|
|
if (prop.array && value) {
|
|
for (let i = 0; i < value.length; i++) {
|
|
const item = value[i];
|
|
value[i] = this.mapDataToFieldNames(item, false, prop.embeddedProps, options.convertCustomTypes);
|
|
}
|
|
} else {
|
|
value = this.mapDataToFieldNames(value, false, prop.embeddedProps, options.convertCustomTypes);
|
|
}
|
|
}
|
|
params.push(value);
|
|
};
|
|
for (const key of keys) {
|
|
const prop = meta.properties[key] ?? meta.root.properties[key];
|
|
if (prop.polymorphic && prop.fieldNames.length > 1) {
|
|
for (let idx = 0; idx < data.length; idx++) {
|
|
const rowValue = data[idx][key];
|
|
if (rowValue instanceof PolymorphicRef) {
|
|
data[idx][key] = rowValue.toTuple();
|
|
}
|
|
}
|
|
}
|
|
prop.fieldNames.forEach((fieldName, fieldNameIdx) => {
|
|
if (fields.has(fieldName) || (prop.ownColumns && !prop.ownColumns.includes(fieldName))) {
|
|
return;
|
|
}
|
|
fields.add(fieldName);
|
|
sql += `${this.platform.quoteIdentifier(fieldName)} = case`;
|
|
where.forEach((cond, idx) => {
|
|
if (key in data[idx]) {
|
|
const pks = Utils.getOrderedPrimaryKeys(cond, meta);
|
|
sql += ` when (${pkCond}) then `;
|
|
if (
|
|
prop.customType &&
|
|
!prop.object &&
|
|
'convertToDatabaseValueSQL' in prop.customType &&
|
|
data[idx][prop.name] != null &&
|
|
!isRaw(data[idx][key])
|
|
) {
|
|
sql += prop.customType.convertToDatabaseValueSQL('?', this.platform);
|
|
} else {
|
|
sql += '?';
|
|
}
|
|
params.push(...pks);
|
|
addParams(prop, prop.fieldNames.length > 1 ? data[idx][key]?.[fieldNameIdx] : data[idx][key]);
|
|
}
|
|
});
|
|
sql += ` else ${this.platform.quoteIdentifier(fieldName)} end, `;
|
|
return sql;
|
|
});
|
|
}
|
|
if (meta.versionProperty) {
|
|
const versionProperty = meta.properties[meta.versionProperty];
|
|
const quotedFieldName = this.platform.quoteIdentifier(versionProperty.fieldNames[0]);
|
|
sql += `${quotedFieldName} = `;
|
|
if (versionProperty.runtimeType === 'Date') {
|
|
sql += this.platform.getCurrentTimestampSQL(versionProperty.length);
|
|
} else {
|
|
sql += `${quotedFieldName} + 1`;
|
|
}
|
|
sql += `, `;
|
|
}
|
|
sql = sql.substring(0, sql.length - 2) + ' where ';
|
|
const pkProps = meta.primaryKeys.concat(...meta.concurrencyCheckKeys);
|
|
const pks = Utils.flatten(pkProps.map(pk => meta.properties[pk].fieldNames));
|
|
sql +=
|
|
pks.length > 1
|
|
? `(${pks.map(pk => this.platform.quoteIdentifier(pk)).join(', ')})`
|
|
: this.platform.quoteIdentifier(pks[0]);
|
|
const conds = where.map(cond => {
|
|
if (Utils.isPlainObject(cond) && Utils.getObjectKeysSize(cond) === 1) {
|
|
cond = Object.values(cond)[0];
|
|
}
|
|
if (pks.length > 1) {
|
|
pkProps.forEach(pk => {
|
|
if (Array.isArray(cond[pk])) {
|
|
params.push(...Utils.flatten(cond[pk]));
|
|
} else {
|
|
params.push(cond[pk]);
|
|
}
|
|
});
|
|
return `(${Array.from({ length: pks.length }).fill('?').join(', ')})`;
|
|
}
|
|
params.push(cond);
|
|
return '?';
|
|
});
|
|
sql += ` in (${conds.join(', ')})`;
|
|
if (this.platform.usesReturningStatement() && returning.size > 0) {
|
|
const returningFields = Utils.flatten([...returning].map(prop => meta.properties[prop].fieldNames));
|
|
/* v8 ignore next */
|
|
sql +=
|
|
returningFields.length > 0
|
|
? ` returning ${returningFields.map(field => this.platform.quoteIdentifier(field)).join(', ')}`
|
|
: '';
|
|
}
|
|
if (transform) {
|
|
sql = transform(sql, params);
|
|
}
|
|
const res = await this.rethrow(this.execute(sql, params, 'run', options.ctx, options.loggerContext));
|
|
for (let i = 0; i < collections.length; i++) {
|
|
await this.processManyToMany(meta, where[i], collections[i], false, options);
|
|
}
|
|
return res;
|
|
}
|
|
async nativeDelete(entityName, where, options = {}) {
|
|
const meta = this.metadata.get(entityName);
|
|
const pks = this.getPrimaryKeyFields(meta);
|
|
if (Utils.isPrimaryKey(where) && pks.length === 1) {
|
|
where = { [pks[0]]: where };
|
|
}
|
|
if (options.unionWhere?.length) {
|
|
where = await this.applyUnionWhere(meta, where, options, true);
|
|
}
|
|
const qb = this.createQueryBuilder(entityName, options.ctx, 'write', false, options.loggerContext)
|
|
.delete(where)
|
|
.withSchema(this.getSchemaName(meta, options));
|
|
return this.rethrow(qb.execute('run', false));
|
|
}
|
|
/**
|
|
* Fast comparison for collection snapshots that are represented by PK arrays.
|
|
* Compares scalars via `===` and fallbacks to Utils.equals()` for more complex types like Buffer.
|
|
* Always expects the same length of the arrays, since we only compare PKs of the same entity type.
|
|
*/
|
|
comparePrimaryKeyArrays(a, b) {
|
|
for (let i = a.length; i-- !== 0; ) {
|
|
if (['number', 'string', 'bigint', 'boolean'].includes(typeof a[i])) {
|
|
if (a[i] !== b[i]) {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!Utils.equals(a[i], b[i])) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async syncCollections(collections, options) {
|
|
const groups = {};
|
|
for (const coll of collections) {
|
|
const wrapped = helper(coll.owner);
|
|
const meta = wrapped.__meta;
|
|
const pks = wrapped.getPrimaryKeys(true);
|
|
const snap = coll.getSnapshot();
|
|
const includes = (arr, item) => !!arr.find(i => this.comparePrimaryKeyArrays(i, item));
|
|
const snapshot = snap ? snap.map(item => helper(item).getPrimaryKeys(true)) : [];
|
|
const current = coll.getItems(false).map(item => helper(item).getPrimaryKeys(true));
|
|
const deleteDiff = snap ? snapshot.filter(item => !includes(current, item)) : true;
|
|
const insertDiff = current.filter(item => !includes(snapshot, item));
|
|
const target = snapshot.filter(item => includes(current, item)).concat(...insertDiff);
|
|
const equals = Utils.equals(current, target);
|
|
// wrong order if we just delete and insert to the end (only owning sides can have fixed order)
|
|
if (coll.property.owner && coll.property.fixedOrder && !equals && Array.isArray(deleteDiff)) {
|
|
deleteDiff.length = insertDiff.length = 0;
|
|
for (const item of snapshot) {
|
|
deleteDiff.push(item);
|
|
}
|
|
for (const item of current) {
|
|
insertDiff.push(item);
|
|
}
|
|
}
|
|
if (coll.property.kind === ReferenceKind.ONE_TO_MANY) {
|
|
const cols = coll.property.referencedColumnNames;
|
|
const qb = this.createQueryBuilder(coll.property.targetMeta.class, options?.ctx, 'write').withSchema(
|
|
this.getSchemaName(meta, options),
|
|
);
|
|
if (coll.getSnapshot() === undefined) {
|
|
if (coll.property.orphanRemoval) {
|
|
const query = qb
|
|
.delete({ [coll.property.mappedBy]: pks })
|
|
.andWhere({ [cols.join(Utils.PK_SEPARATOR)]: { $nin: insertDiff } });
|
|
await this.rethrow(query.execute());
|
|
continue;
|
|
}
|
|
const query = qb
|
|
.update({ [coll.property.mappedBy]: null })
|
|
.where({ [coll.property.mappedBy]: pks })
|
|
.andWhere({ [cols.join(Utils.PK_SEPARATOR)]: { $nin: insertDiff } });
|
|
await this.rethrow(query.execute());
|
|
continue;
|
|
}
|
|
/* v8 ignore next */
|
|
const query = qb
|
|
.update({ [coll.property.mappedBy]: pks })
|
|
.where({ [cols.join(Utils.PK_SEPARATOR)]: { $in: insertDiff } });
|
|
await this.rethrow(query.execute());
|
|
continue;
|
|
}
|
|
const pivotMeta = this.metadata.find(coll.property.pivotEntity);
|
|
let schema = pivotMeta.schema;
|
|
if (schema === '*') {
|
|
if (coll.property.owner) {
|
|
schema = wrapped.getSchema() === '*' ? (options?.schema ?? this.config.get('schema')) : wrapped.getSchema();
|
|
} else {
|
|
const targetMeta = coll.property.targetMeta;
|
|
const targetSchema = (coll[0] ?? snap?.[0]) && helper(coll[0] ?? snap?.[0]).getSchema();
|
|
schema =
|
|
targetMeta.schema === '*'
|
|
? (options?.schema ?? targetSchema ?? this.config.get('schema'))
|
|
: targetMeta.schema;
|
|
}
|
|
} else if (schema == null) {
|
|
schema = this.config.get('schema');
|
|
}
|
|
const tableName = `${schema ?? '_'}.${pivotMeta.tableName}`;
|
|
const persister = (groups[tableName] ??= new PivotCollectionPersister(
|
|
pivotMeta,
|
|
this,
|
|
options?.ctx,
|
|
schema,
|
|
options?.loggerContext,
|
|
));
|
|
persister.enqueueUpdate(coll.property, insertDiff, deleteDiff, pks, coll.isInitialized());
|
|
}
|
|
for (const persister of Utils.values(groups)) {
|
|
await this.rethrow(persister.execute());
|
|
}
|
|
}
|
|
async loadFromPivotTable(prop, owners, where = {}, orderBy, ctx, options, pivotJoin) {
|
|
/* v8 ignore next */
|
|
if (owners.length === 0) {
|
|
return {};
|
|
}
|
|
const pivotMeta = this.metadata.get(prop.pivotEntity);
|
|
if (prop.polymorphic && prop.discriminatorColumn && prop.discriminatorValue) {
|
|
return this.loadFromPolymorphicPivotTable(prop, owners, where, orderBy, ctx, options, pivotJoin);
|
|
}
|
|
const pivotProp1 = pivotMeta.relations[prop.owner ? 1 : 0];
|
|
const pivotProp2 = pivotMeta.relations[prop.owner ? 0 : 1];
|
|
const ownerMeta = pivotProp2.targetMeta;
|
|
// The pivot query builder doesn't convert custom types, so we need to manually
|
|
// convert owner PKs to DB format for the query and convert result FKs back to
|
|
// JS format for consistent key hashing in buildPivotResultMap.
|
|
const pkProp = ownerMeta.properties[ownerMeta.primaryKeys[0]];
|
|
const needsConversion = pkProp?.customType?.ensureComparable(ownerMeta, pkProp) && !ownerMeta.compositePK;
|
|
let ownerPks = ownerMeta.compositePK ? owners : owners.map(o => o[0]);
|
|
if (needsConversion) {
|
|
ownerPks = ownerPks.map(v => pkProp.customType.convertToDatabaseValue(v, this.platform, { mode: 'query' }));
|
|
}
|
|
const cond = {
|
|
[pivotProp2.name]: { $in: ownerPks },
|
|
};
|
|
if (!Utils.isEmpty(where)) {
|
|
cond[pivotProp1.name] = { ...where };
|
|
}
|
|
where = cond;
|
|
const populateField = pivotJoin ? `${pivotProp1.name}:ref` : pivotProp1.name;
|
|
const populate = this.autoJoinOneToOneOwner(prop.targetMeta, options?.populate ?? [], options?.fields);
|
|
const childFields = !Utils.isEmpty(options?.fields) ? options.fields.map(f => `${pivotProp1.name}.${f}`) : [];
|
|
const childExclude = !Utils.isEmpty(options?.exclude) ? options.exclude.map(f => `${pivotProp1.name}.${f}`) : [];
|
|
const fields = pivotJoin ? [pivotProp1.name, pivotProp2.name] : [pivotProp1.name, pivotProp2.name, ...childFields];
|
|
const res = await this.find(pivotMeta.class, where, {
|
|
ctx,
|
|
...options,
|
|
fields,
|
|
exclude: childExclude,
|
|
orderBy: this.getPivotOrderBy(prop, pivotProp1, orderBy, options?.orderBy),
|
|
populate: [
|
|
{
|
|
field: populateField,
|
|
strategy: LoadStrategy.JOINED,
|
|
joinType: JoinType.innerJoin,
|
|
children: populate,
|
|
dataOnly: pivotProp1.mapToPk && !pivotJoin,
|
|
},
|
|
],
|
|
populateWhere: undefined,
|
|
// @ts-ignore
|
|
_populateWhere: 'infer',
|
|
populateFilter: this.wrapPopulateFilter(options, pivotProp2.name),
|
|
});
|
|
// Convert result FK values back to JS format so key hashing
|
|
// in buildPivotResultMap is consistent with the owner keys.
|
|
if (needsConversion) {
|
|
for (const item of res) {
|
|
const fk = item[pivotProp2.name];
|
|
if (fk != null) {
|
|
item[pivotProp2.name] = pkProp.customType.convertToJSValue(fk, this.platform);
|
|
}
|
|
}
|
|
}
|
|
return this.buildPivotResultMap(owners, res, pivotProp2.name, pivotProp1.name);
|
|
}
|
|
/**
|
|
* Load from a polymorphic M:N pivot table.
|
|
*/
|
|
async loadFromPolymorphicPivotTable(prop, owners, where = {}, orderBy, ctx, options, pivotJoin) {
|
|
const pivotMeta = this.metadata.get(prop.pivotEntity);
|
|
// Find the M:1 relation on the pivot pointing to the target entity.
|
|
// We exclude virtual polymorphic owner relations (persist: false) and non-M:1 relations.
|
|
const inverseProp = pivotMeta.relations.find(
|
|
r => r.kind === ReferenceKind.MANY_TO_ONE && r.persist !== false && r.targetMeta === prop.targetMeta,
|
|
);
|
|
if (inverseProp) {
|
|
return this.loadPolymorphicPivotOwnerSide(prop, owners, where, orderBy, ctx, options, pivotJoin, inverseProp);
|
|
}
|
|
return this.loadPolymorphicPivotInverseSide(prop, owners, where, orderBy, ctx, options);
|
|
}
|
|
/**
|
|
* Load from owner side of polymorphic M:N (e.g., Post -> Tags)
|
|
*/
|
|
async loadPolymorphicPivotOwnerSide(prop, owners, where, orderBy, ctx, options, pivotJoin, inverseProp) {
|
|
const pivotMeta = this.metadata.get(prop.pivotEntity);
|
|
const targetMeta = prop.targetMeta;
|
|
// Build condition: discriminator = 'post' AND {discriminator} IN (...)
|
|
const cond = {
|
|
[prop.discriminatorColumn]: prop.discriminatorValue,
|
|
[prop.discriminator]: { $in: owners.length === 1 && owners[0].length === 1 ? owners.map(o => o[0]) : owners },
|
|
};
|
|
if (!Utils.isEmpty(where)) {
|
|
cond[inverseProp.name] = { ...where };
|
|
}
|
|
const populateField = pivotJoin ? `${inverseProp.name}:ref` : inverseProp.name;
|
|
const populate = this.autoJoinOneToOneOwner(targetMeta, options?.populate ?? [], options?.fields);
|
|
const childFields = !Utils.isEmpty(options?.fields) ? options.fields.map(f => `${inverseProp.name}.${f}`) : [];
|
|
const childExclude = !Utils.isEmpty(options?.exclude) ? options.exclude.map(f => `${inverseProp.name}.${f}`) : [];
|
|
const fields = pivotJoin
|
|
? [inverseProp.name, prop.discriminator, prop.discriminatorColumn]
|
|
: [inverseProp.name, prop.discriminator, prop.discriminatorColumn, ...childFields];
|
|
const res = await this.find(pivotMeta.class, cond, {
|
|
ctx,
|
|
...options,
|
|
fields,
|
|
exclude: childExclude,
|
|
orderBy: this.getPivotOrderBy(prop, inverseProp, orderBy, options?.orderBy),
|
|
populate: [
|
|
{
|
|
field: populateField,
|
|
strategy: LoadStrategy.JOINED,
|
|
joinType: JoinType.innerJoin,
|
|
children: populate,
|
|
dataOnly: inverseProp.mapToPk && !pivotJoin,
|
|
},
|
|
],
|
|
populateWhere: undefined,
|
|
// @ts-ignore
|
|
_populateWhere: 'infer',
|
|
populateFilter: this.wrapPopulateFilter(options, inverseProp.name),
|
|
});
|
|
return this.buildPivotResultMap(owners, res, prop.discriminator, inverseProp.name);
|
|
}
|
|
/**
|
|
* Load from inverse side of polymorphic M:N (e.g., Tag -> Posts)
|
|
* Uses single query with join via virtual relation on pivot.
|
|
*/
|
|
async loadPolymorphicPivotInverseSide(prop, owners, where, orderBy, ctx, options) {
|
|
const pivotMeta = this.metadata.get(prop.pivotEntity);
|
|
const targetMeta = prop.targetMeta;
|
|
// Find the relation to the entity we're starting from (e.g., Tag_inverse -> Tag)
|
|
// Exclude virtual polymorphic owner relations (persist: false) - we want the actual M:N inverse relation
|
|
const tagProp = pivotMeta.relations.find(r => r.persist !== false && r.targetMeta !== targetMeta);
|
|
// Find the virtual relation to the polymorphic owner (e.g., taggable_Post -> Post)
|
|
const ownerRelationName = `${prop.discriminator}_${targetMeta.tableName}`;
|
|
const ownerProp = pivotMeta.properties[ownerRelationName];
|
|
// Build condition: discriminator = 'post' AND Tag_inverse IN (tagIds)
|
|
const cond = {
|
|
[prop.discriminatorColumn]: prop.discriminatorValue,
|
|
[tagProp.name]: { $in: owners.length === 1 && owners[0].length === 1 ? owners.map(o => o[0]) : owners },
|
|
};
|
|
if (!Utils.isEmpty(where)) {
|
|
cond[ownerRelationName] = { ...where };
|
|
}
|
|
const populateField = ownerRelationName;
|
|
const populate = this.autoJoinOneToOneOwner(targetMeta, options?.populate ?? [], options?.fields);
|
|
const childFields = !Utils.isEmpty(options?.fields) ? options.fields.map(f => `${ownerRelationName}.${f}`) : [];
|
|
const childExclude = !Utils.isEmpty(options?.exclude) ? options.exclude.map(f => `${ownerRelationName}.${f}`) : [];
|
|
const fields = [ownerRelationName, tagProp.name, prop.discriminatorColumn, ...childFields];
|
|
const res = await this.find(pivotMeta.class, cond, {
|
|
ctx,
|
|
...options,
|
|
fields,
|
|
exclude: childExclude,
|
|
orderBy: this.getPivotOrderBy(prop, ownerProp, orderBy, options?.orderBy),
|
|
populate: [
|
|
{
|
|
field: populateField,
|
|
strategy: LoadStrategy.JOINED,
|
|
joinType: JoinType.innerJoin,
|
|
children: populate,
|
|
},
|
|
],
|
|
populateWhere: undefined,
|
|
// @ts-ignore
|
|
_populateWhere: 'infer',
|
|
populateFilter: this.wrapPopulateFilter(options, ownerRelationName),
|
|
});
|
|
return this.buildPivotResultMap(owners, res, tagProp.name, ownerRelationName);
|
|
}
|
|
/**
|
|
* Build a map from owner PKs to their related entities from pivot table results.
|
|
*/
|
|
buildPivotResultMap(owners, results, keyProp, valueProp) {
|
|
const map = {};
|
|
for (const owner of owners) {
|
|
const key = Utils.getPrimaryKeyHash(owner);
|
|
map[key] = [];
|
|
}
|
|
for (const item of results) {
|
|
const key = Utils.getPrimaryKeyHash(Utils.asArray(item[keyProp]));
|
|
const entity = item[valueProp];
|
|
if (map[key]) {
|
|
map[key].push(entity);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
wrapPopulateFilter(options, propName) {
|
|
if (!Utils.isEmpty(options?.populateFilter) || RawQueryFragment.hasObjectFragments(options?.populateFilter)) {
|
|
return { [propName]: options?.populateFilter };
|
|
}
|
|
return undefined;
|
|
}
|
|
getPivotOrderBy(prop, pivotProp, orderBy, parentOrderBy) {
|
|
if (!Utils.isEmpty(orderBy) || RawQueryFragment.hasObjectFragments(orderBy)) {
|
|
return Utils.asArray(orderBy).map(o => ({ [pivotProp.name]: o }));
|
|
}
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && Utils.asArray(parentOrderBy).some(o => o[prop.name])) {
|
|
return Utils.asArray(parentOrderBy)
|
|
.filter(o => o[prop.name])
|
|
.map(o => ({ [pivotProp.name]: o[prop.name] }));
|
|
}
|
|
if (!Utils.isEmpty(prop.orderBy) || RawQueryFragment.hasObjectFragments(prop.orderBy)) {
|
|
return Utils.asArray(prop.orderBy).map(o => ({ [pivotProp.name]: o }));
|
|
}
|
|
if (prop.fixedOrder) {
|
|
return [{ [prop.fixedOrderColumn]: QueryOrder.ASC }];
|
|
}
|
|
return [];
|
|
}
|
|
async execute(query, params = [], method = 'all', ctx, loggerContext) {
|
|
return this.rethrow(this.connection.execute(query, params, method, ctx, loggerContext));
|
|
}
|
|
async *stream(entityName, where, options) {
|
|
options = { populate: [], orderBy: [], ...options };
|
|
const meta = this.metadata.get(entityName);
|
|
if (meta.virtual) {
|
|
yield* this.streamFromVirtual(entityName, where, options);
|
|
return;
|
|
}
|
|
const qb = await this.createQueryBuilderFromOptions(meta, where, options);
|
|
try {
|
|
const result = qb.stream(options);
|
|
for await (const item of result) {
|
|
yield item;
|
|
}
|
|
} catch (e) {
|
|
throw this.convertException(e);
|
|
}
|
|
}
|
|
/**
|
|
* 1:1 owner side needs to be marked for population so QB auto-joins the owner id
|
|
*/
|
|
autoJoinOneToOneOwner(meta, populate, fields = []) {
|
|
if (!this.config.get('autoJoinOneToOneOwner')) {
|
|
return populate;
|
|
}
|
|
const relationsToPopulate = populate.map(({ field }) => field.split(':')[0]);
|
|
const toPopulate = meta.relations
|
|
.filter(
|
|
prop =>
|
|
prop.kind === ReferenceKind.ONE_TO_ONE &&
|
|
!prop.owner &&
|
|
!prop.lazy &&
|
|
!relationsToPopulate.includes(prop.name),
|
|
)
|
|
.filter(prop => fields.length === 0 || fields.some(f => prop.name === f || prop.name.startsWith(`${String(f)}.`)))
|
|
.map(prop => ({ field: `${prop.name}:ref`, strategy: LoadStrategy.JOINED }));
|
|
return [...populate, ...toPopulate];
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
joinedProps(meta, populate, options) {
|
|
return populate.filter(hint => {
|
|
const [propName, ref] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName] || {};
|
|
const strategy = getLoadingStrategy(
|
|
hint.strategy || prop.strategy || options?.strategy || this.config.get('loadStrategy'),
|
|
prop.kind,
|
|
);
|
|
if (ref && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
|
|
return true;
|
|
}
|
|
// skip redundant joins for 1:1 owner population hints when using `mapToPk`
|
|
if (prop.kind === ReferenceKind.ONE_TO_ONE && prop.mapToPk && prop.owner) {
|
|
return false;
|
|
}
|
|
if (strategy !== LoadStrategy.JOINED) {
|
|
// force joined strategy for explicit 1:1 owner populate hint as it would require a join anyway
|
|
return prop.kind === ReferenceKind.ONE_TO_ONE && !prop.owner;
|
|
}
|
|
return ![ReferenceKind.SCALAR, ReferenceKind.EMBEDDED].includes(prop.kind);
|
|
});
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
mergeJoinedResult(rawResults, meta, joinedProps) {
|
|
if (rawResults.length <= 1) {
|
|
return rawResults;
|
|
}
|
|
const res = [];
|
|
const map = {};
|
|
const collectionsToMerge = {};
|
|
const hints = joinedProps.map(hint => {
|
|
const [propName, ref] = hint.field.split(':', 2);
|
|
return { propName, ref, children: hint.children };
|
|
});
|
|
for (const item of rawResults) {
|
|
const pk = Utils.getCompositeKeyHash(item, meta);
|
|
if (map[pk]) {
|
|
for (const { propName } of hints) {
|
|
if (!item[propName]) {
|
|
continue;
|
|
}
|
|
collectionsToMerge[pk] ??= {};
|
|
collectionsToMerge[pk][propName] ??= [map[pk][propName]];
|
|
collectionsToMerge[pk][propName].push(item[propName]);
|
|
}
|
|
} else {
|
|
map[pk] = item;
|
|
res.push(item);
|
|
}
|
|
}
|
|
for (const pk in collectionsToMerge) {
|
|
const entity = map[pk];
|
|
const collections = collectionsToMerge[pk];
|
|
for (const { propName, ref, children } of hints) {
|
|
if (!collections[propName]) {
|
|
continue;
|
|
}
|
|
const prop = meta.properties[propName];
|
|
const items = collections[propName].flat();
|
|
if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind) && ref) {
|
|
entity[propName] = items;
|
|
continue;
|
|
}
|
|
switch (prop.kind) {
|
|
case ReferenceKind.ONE_TO_MANY:
|
|
case ReferenceKind.MANY_TO_MANY:
|
|
entity[propName] = this.mergeJoinedResult(items, prop.targetMeta, children ?? []);
|
|
break;
|
|
case ReferenceKind.MANY_TO_ONE:
|
|
case ReferenceKind.ONE_TO_ONE:
|
|
entity[propName] = this.mergeJoinedResult(items, prop.targetMeta, children ?? [])[0];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
shouldHaveColumn(meta, prop, populate, fields, exclude) {
|
|
if (!this.platform.shouldHaveColumn(prop, populate, exclude)) {
|
|
return false;
|
|
}
|
|
if (!fields || fields.includes('*') || prop.primary || meta.root.discriminatorColumn === prop.name) {
|
|
return true;
|
|
}
|
|
return fields.some(f => f === prop.name || f.toString().startsWith(prop.name + '.'));
|
|
}
|
|
getFieldsForJoinedLoad(qb, meta, options) {
|
|
const fields = [];
|
|
const populate = options.populate ?? [];
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
const populateWhereAll = options?._populateWhere === 'all' || Utils.isEmpty(options?._populateWhere);
|
|
// Ensure TPT joins are applied early so that _tptAlias is available for join resolution
|
|
// This is needed when populating relations that are inherited from TPT parent entities
|
|
if (!options.parentJoinPath) {
|
|
qb.ensureTPTJoins();
|
|
}
|
|
// root entity is already handled, skip that
|
|
if (options.parentJoinPath) {
|
|
// alias all fields in the primary table
|
|
meta.props
|
|
.filter(prop => this.shouldHaveColumn(meta, prop, populate, options.explicitFields, options.exclude))
|
|
.forEach(prop =>
|
|
fields.push(
|
|
...this.mapPropToFieldNames(
|
|
qb,
|
|
prop,
|
|
options.parentTableAlias,
|
|
meta,
|
|
options.schema,
|
|
options.explicitFields,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
for (const hint of joinedProps) {
|
|
const [propName, ref] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName];
|
|
// Polymorphic to-one: create a LEFT JOIN per target type
|
|
// Skip :ref hints — polymorphic to-one already has FK + discriminator in the row
|
|
if (
|
|
prop.polymorphic &&
|
|
prop.polymorphTargets?.length &&
|
|
!ref &&
|
|
[ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)
|
|
) {
|
|
const basePath = options.parentJoinPath
|
|
? `${options.parentJoinPath}.${prop.name}`
|
|
: `${meta.name}.${prop.name}`;
|
|
const pathPrefix =
|
|
!options.parentJoinPath && populateWhereAll && !basePath.startsWith('[populate]') ? '[populate]' : '';
|
|
for (const targetMeta of prop.polymorphTargets) {
|
|
const tableAlias = qb.getNextAlias(targetMeta.className);
|
|
const targetPath = `${pathPrefix}${basePath}[${targetMeta.className}]`;
|
|
const schema = targetMeta.schema === '*' ? (options?.schema ?? this.config.get('schema')) : targetMeta.schema;
|
|
qb.addPolymorphicJoin(
|
|
prop,
|
|
targetMeta,
|
|
options.parentTableAlias,
|
|
tableAlias,
|
|
JoinType.leftJoin,
|
|
targetPath,
|
|
schema,
|
|
);
|
|
// Select fields from each target table
|
|
fields.push(
|
|
...this.getFieldsForJoinedLoad(qb, targetMeta, {
|
|
...options,
|
|
populate: hint.children,
|
|
parentTableAlias: tableAlias,
|
|
parentJoinPath: targetPath,
|
|
}),
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
// ignore ref joins of known FKs unless it's a filter hint
|
|
if (
|
|
ref &&
|
|
!hint.filter &&
|
|
(prop.kind === ReferenceKind.MANY_TO_ONE || (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner))
|
|
) {
|
|
continue;
|
|
}
|
|
const meta2 = prop.targetMeta;
|
|
const pivotRefJoin = prop.kind === ReferenceKind.MANY_TO_MANY && ref;
|
|
const tableAlias = qb.getNextAlias(prop.name);
|
|
const field = `${options.parentTableAlias}.${prop.name}`;
|
|
let path = options.parentJoinPath ? `${options.parentJoinPath}.${prop.name}` : `${meta.name}.${prop.name}`;
|
|
if (!options.parentJoinPath && populateWhereAll && !hint.filter && !path.startsWith('[populate]')) {
|
|
path = '[populate]' + path;
|
|
}
|
|
const mandatoryToOneProperty =
|
|
[ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && !prop.nullable;
|
|
const joinType = pivotRefJoin
|
|
? JoinType.pivotJoin
|
|
: hint.joinType
|
|
? hint.joinType
|
|
: (hint.filter && !prop.nullable) || mandatoryToOneProperty
|
|
? JoinType.innerJoin
|
|
: JoinType.leftJoin;
|
|
const schema =
|
|
prop.targetMeta.schema === '*' ? (options?.schema ?? this.config.get('schema')) : prop.targetMeta.schema;
|
|
qb.join(field, tableAlias, {}, joinType, path, schema);
|
|
// For relations to TPT base classes, add LEFT JOINs for all child tables (polymorphic loading)
|
|
if (meta2.inheritanceType === 'tpt' && meta2.tptChildren?.length && !ref) {
|
|
// Use the registry metadata to ensure allTPTDescendants is available
|
|
const tptMeta = this.metadata.get(meta2.class);
|
|
this.addTPTPolymorphicJoinsForRelation(qb, tptMeta, tableAlias, fields);
|
|
}
|
|
if (pivotRefJoin) {
|
|
fields.push(
|
|
...prop.joinColumns.map(col =>
|
|
qb.helper.mapper(`${tableAlias}.${col}`, qb.type, undefined, `${tableAlias}__${col}`),
|
|
),
|
|
...prop.inverseJoinColumns.map(col =>
|
|
qb.helper.mapper(`${tableAlias}.${col}`, qb.type, undefined, `${tableAlias}__${col}`),
|
|
),
|
|
);
|
|
}
|
|
if (prop.kind === ReferenceKind.ONE_TO_MANY && ref) {
|
|
fields.push(
|
|
...this.getFieldsForJoinedLoad(qb, meta2, {
|
|
...options,
|
|
explicitFields: prop.referencedColumnNames,
|
|
exclude: undefined,
|
|
populate: hint.children,
|
|
parentTableAlias: tableAlias,
|
|
parentJoinPath: path,
|
|
}),
|
|
);
|
|
}
|
|
const childExplicitFields =
|
|
options.explicitFields?.filter(f => Utils.isPlainObject(f)).map(o => o[prop.name])[0] || [];
|
|
options.explicitFields?.forEach(f => {
|
|
if (typeof f === 'string' && f.startsWith(`${prop.name}.`)) {
|
|
childExplicitFields.push(f.substring(prop.name.length + 1));
|
|
}
|
|
});
|
|
const childExclude = options.exclude ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
|
|
if (!ref && (!prop.mapToPk || hint.dataOnly)) {
|
|
fields.push(
|
|
...this.getFieldsForJoinedLoad(qb, meta2, {
|
|
...options,
|
|
explicitFields: childExplicitFields.length === 0 ? undefined : childExplicitFields,
|
|
exclude: childExclude,
|
|
populate: hint.children,
|
|
parentTableAlias: tableAlias,
|
|
parentJoinPath: path,
|
|
}),
|
|
);
|
|
} else if (
|
|
hint.filter ||
|
|
(prop.mapToPk && !hint.dataOnly) ||
|
|
(ref && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind))
|
|
) {
|
|
fields.push(
|
|
...prop.referencedColumnNames.map(col =>
|
|
qb.helper.mapper(`${tableAlias}.${col}`, qb.type, undefined, `${tableAlias}__${col}`),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return fields;
|
|
}
|
|
/**
|
|
* Adds LEFT JOINs and fields for TPT polymorphic loading when populating a relation to a TPT base class.
|
|
* @internal
|
|
*/
|
|
addTPTPolymorphicJoinsForRelation(qb, meta, baseAlias, fields) {
|
|
// allTPTDescendants is pre-computed during discovery, sorted by depth (deepest first)
|
|
const descendants = meta.allTPTDescendants;
|
|
const childAliases = {};
|
|
// LEFT JOIN each descendant table
|
|
for (const childMeta of descendants) {
|
|
const childAlias = qb.getNextAlias(childMeta.className);
|
|
qb.createAlias(childMeta.class, childAlias);
|
|
childAliases[childMeta.className] = childAlias;
|
|
qb.addPropertyJoin(childMeta.tptInverseProp, baseAlias, childAlias, JoinType.leftJoin, `[tpt]${meta.className}`);
|
|
// Add fields from this child (only ownProps, skip PKs)
|
|
for (const prop of childMeta.ownProps.filter(p => !p.primary && this.platform.shouldHaveColumn(p, []))) {
|
|
for (const fieldName of prop.fieldNames) {
|
|
const field = `${childAlias}.${fieldName}`;
|
|
const fieldAlias = `${childAlias}__${fieldName}`;
|
|
fields.push(raw(`${this.platform.quoteIdentifier(field)} as ${this.platform.quoteIdentifier(fieldAlias)}`));
|
|
}
|
|
}
|
|
}
|
|
// Add computed discriminator (descendants already sorted by depth)
|
|
if (meta.root.tptDiscriminatorColumn) {
|
|
fields.push(this.buildTPTDiscriminatorExpression(meta, descendants, childAliases, baseAlias));
|
|
}
|
|
}
|
|
/**
|
|
* Find the alias for a TPT child table in the query builder.
|
|
* @internal
|
|
*/
|
|
findTPTChildAlias(qb, childMeta) {
|
|
const joins = qb.state.joins;
|
|
for (const key of Object.keys(joins)) {
|
|
if (joins[key].table === childMeta.tableName && key.includes('[tpt]')) {
|
|
return joins[key].alias;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Builds a CASE WHEN expression for TPT discriminator.
|
|
* Determines concrete entity type based on which child table has a non-null PK.
|
|
* @internal
|
|
*/
|
|
buildTPTDiscriminatorExpression(meta, descendants, aliasMap, baseAlias) {
|
|
const cases = descendants.map(child => {
|
|
const childAlias = aliasMap[child.className];
|
|
const pkFieldName = child.properties[child.primaryKeys[0]].fieldNames[0];
|
|
return `when ${this.platform.quoteIdentifier(`${childAlias}.${pkFieldName}`)} is not null then '${child.discriminatorValue}'`;
|
|
});
|
|
const defaultValue = meta.abstract ? 'null' : `'${meta.discriminatorValue}'`;
|
|
const caseExpr = `case ${cases.join(' ')} else ${defaultValue} end`;
|
|
const aliased = this.platform.quoteIdentifier(`${baseAlias}__${meta.root.tptDiscriminatorColumn}`);
|
|
return raw(`${caseExpr} as ${aliased}`);
|
|
}
|
|
/**
|
|
* Maps TPT child-specific fields during hydration.
|
|
* When a relation points to a TPT base class, the actual entity might be a child class.
|
|
* This method reads the discriminator to determine the concrete type and maps child-specific fields.
|
|
* @internal
|
|
*/
|
|
mapTPTChildFields(relationPojo, meta, relationAlias, qb, root) {
|
|
// Check if this is a TPT base with polymorphic children
|
|
if (meta.inheritanceType !== 'tpt' || !meta.root.tptDiscriminatorColumn) {
|
|
return;
|
|
}
|
|
// Read the discriminator value
|
|
const discriminatorAlias = `${relationAlias}__${meta.root.tptDiscriminatorColumn}`;
|
|
const discriminatorValue = root[discriminatorAlias];
|
|
if (!discriminatorValue) {
|
|
return;
|
|
}
|
|
// Set the discriminator in the pojo for EntityFactory
|
|
relationPojo[meta.root.tptDiscriminatorColumn] = discriminatorValue;
|
|
// Find the concrete metadata from discriminator map
|
|
const concreteClass = meta.root.discriminatorMap?.[discriminatorValue];
|
|
/* v8 ignore next 3 - defensive check for invalid discriminator values */
|
|
if (!concreteClass) {
|
|
return;
|
|
}
|
|
const concreteMeta = this.metadata.get(concreteClass);
|
|
if (concreteMeta === meta) {
|
|
// Already the concrete type, no child fields to map
|
|
delete root[discriminatorAlias];
|
|
return;
|
|
}
|
|
// Traverse up from concrete type and map fields from each level's table
|
|
const tz = this.platform.getTimezone();
|
|
let currentMeta = concreteMeta;
|
|
while (currentMeta && currentMeta !== meta) {
|
|
const childAlias = this.findTPTChildAlias(qb, currentMeta);
|
|
if (childAlias) {
|
|
// Map fields using same filtering as joined loading, plus skip PKs
|
|
for (const prop of currentMeta.ownProps.filter(p => !p.primary && this.platform.shouldHaveColumn(p, []))) {
|
|
this.mapJoinedProp(relationPojo, prop, childAlias, root, tz, currentMeta, {
|
|
deleteFromRoot: true,
|
|
});
|
|
}
|
|
}
|
|
currentMeta = currentMeta.tptParent;
|
|
}
|
|
// Clean up the discriminator alias
|
|
delete root[discriminatorAlias];
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
mapPropToFieldNames(qb, prop, tableAlias, meta, schema, explicitFields) {
|
|
if (prop.kind === ReferenceKind.EMBEDDED && !prop.object) {
|
|
return Object.entries(prop.embeddedProps).flatMap(([name, childProp]) => {
|
|
const childFields = explicitFields ? Utils.extractChildElements(explicitFields, prop.name) : [];
|
|
if (
|
|
!this.shouldHaveColumn(
|
|
prop.targetMeta,
|
|
{ ...childProp, name },
|
|
[],
|
|
childFields.length > 0 ? childFields : undefined,
|
|
)
|
|
) {
|
|
return [];
|
|
}
|
|
return this.mapPropToFieldNames(qb, childProp, tableAlias, meta, schema, childFields);
|
|
});
|
|
}
|
|
const aliased = this.platform.quoteIdentifier(`${tableAlias}__${prop.fieldNames[0]}`);
|
|
if (prop.customTypes?.some(type => !!type?.convertToJSValueSQL)) {
|
|
return prop.fieldNames.map((col, idx) => {
|
|
if (!prop.customTypes[idx]?.convertToJSValueSQL) {
|
|
return col;
|
|
}
|
|
const prefixed = this.platform.quoteIdentifier(`${tableAlias}.${col}`);
|
|
const aliased = this.platform.quoteIdentifier(`${tableAlias}__${col}`);
|
|
return raw(`${prop.customTypes[idx].convertToJSValueSQL(prefixed, this.platform)} as ${aliased}`);
|
|
});
|
|
}
|
|
if (prop.customType?.convertToJSValueSQL) {
|
|
const prefixed = this.platform.quoteIdentifier(`${tableAlias}.${prop.fieldNames[0]}`);
|
|
return [raw(`${prop.customType.convertToJSValueSQL(prefixed, this.platform)} as ${aliased}`)];
|
|
}
|
|
if (prop.formula) {
|
|
const quotedAlias = this.platform.quoteIdentifier(tableAlias).toString();
|
|
const table = this.createFormulaTable(quotedAlias, meta, schema);
|
|
const columns = meta.createColumnMappingObject(tableAlias);
|
|
return [raw(`${this.evaluateFormula(prop.formula, columns, table)} as ${aliased}`)];
|
|
}
|
|
return prop.fieldNames.map(fieldName => {
|
|
return raw('?? as ??', [`${tableAlias}.${fieldName}`, `${tableAlias}__${fieldName}`]);
|
|
});
|
|
}
|
|
/** @internal */
|
|
createQueryBuilder(entityName, ctx, preferredConnectionType, convertCustomTypes, loggerContext, alias, em) {
|
|
// do not compute the connectionType if EM is provided as it will be computed from it in the QB later on
|
|
const connectionType = em
|
|
? preferredConnectionType
|
|
: this.resolveConnectionType({ ctx, connectionType: preferredConnectionType });
|
|
const qb = new QueryBuilder(entityName, this.metadata, this, ctx, alias, connectionType, em, loggerContext);
|
|
if (!convertCustomTypes) {
|
|
qb.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES);
|
|
}
|
|
return qb;
|
|
}
|
|
resolveConnectionType(args) {
|
|
if (args.ctx) {
|
|
return 'write';
|
|
}
|
|
if (args.connectionType) {
|
|
return args.connectionType;
|
|
}
|
|
if (this.config.get('preferReadReplicas')) {
|
|
return 'read';
|
|
}
|
|
return 'write';
|
|
}
|
|
extractManyToMany(meta, data) {
|
|
const ret = {};
|
|
for (const prop of meta.relations) {
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && data[prop.name]) {
|
|
ret[prop.name] = data[prop.name].map(item => Utils.asArray(item));
|
|
delete data[prop.name];
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
async processManyToMany(meta, pks, collections, clear, options) {
|
|
for (const prop of meta.relations) {
|
|
if (collections[prop.name]) {
|
|
const pivotMeta = this.metadata.get(prop.pivotEntity);
|
|
const persister = new PivotCollectionPersister(
|
|
pivotMeta,
|
|
this,
|
|
options?.ctx,
|
|
options?.schema,
|
|
options?.loggerContext,
|
|
);
|
|
persister.enqueueUpdate(prop, collections[prop.name], clear, pks);
|
|
await this.rethrow(persister.execute());
|
|
}
|
|
}
|
|
}
|
|
async lockPessimistic(entity, options) {
|
|
const meta = helper(entity).__meta;
|
|
const qb = this.createQueryBuilder(meta.class, options.ctx, undefined, undefined, options.logging).withSchema(
|
|
options.schema ?? meta.schema,
|
|
);
|
|
const cond = Utils.getPrimaryKeyCond(entity, meta.primaryKeys);
|
|
qb.select(raw('1')).where(cond).setLockMode(options.lockMode, options.lockTableAliases);
|
|
await this.rethrow(qb.execute());
|
|
}
|
|
buildPopulateWhere(meta, joinedProps, options) {
|
|
const where = {};
|
|
for (const hint of joinedProps) {
|
|
const [propName] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName];
|
|
if (!Utils.isEmpty(prop.where) || RawQueryFragment.hasObjectFragments(prop.where)) {
|
|
where[prop.name] = Utils.copy(prop.where);
|
|
}
|
|
if (hint.children) {
|
|
const targetMeta = prop.targetMeta;
|
|
if (targetMeta) {
|
|
const inner = this.buildPopulateWhere(targetMeta, hint.children, {});
|
|
if (!Utils.isEmpty(inner) || RawQueryFragment.hasObjectFragments(inner)) {
|
|
where[prop.name] ??= {};
|
|
Object.assign(where[prop.name], inner);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (Utils.isEmpty(options.populateWhere) && !RawQueryFragment.hasObjectFragments(options.populateWhere)) {
|
|
return where;
|
|
}
|
|
if (Utils.isEmpty(where) && !RawQueryFragment.hasObjectFragments(where)) {
|
|
return options.populateWhere;
|
|
}
|
|
/* v8 ignore next */
|
|
return { $and: [options.populateWhere, where] };
|
|
}
|
|
/**
|
|
* Builds a UNION ALL (or UNION) subquery from `unionWhere` branches and merges it
|
|
* into the main WHERE as `pk IN (branch_1 UNION ALL branch_2 ...)`.
|
|
* Each branch is planned independently by the database, enabling per-table index usage.
|
|
*/
|
|
async applyUnionWhere(meta, where, options, forDml = false) {
|
|
const unionWhere = options.unionWhere;
|
|
const strategy = options.unionWhereStrategy ?? 'union-all';
|
|
const schema = this.getSchemaName(meta, options);
|
|
const connectionType = this.resolveConnectionType({
|
|
ctx: options.ctx,
|
|
connectionType: options.connectionType,
|
|
});
|
|
const branchQbs = [];
|
|
for (const branch of unionWhere) {
|
|
const qb = this.createQueryBuilder(meta.class, options.ctx, connectionType, false, options.logging).withSchema(
|
|
schema,
|
|
);
|
|
const pkFields = meta.primaryKeys.map(pk => {
|
|
const prop = meta.properties[pk];
|
|
return `${qb.alias}.${prop.fieldNames[0]}`;
|
|
});
|
|
qb.select(pkFields).where(branch);
|
|
if (options.em) {
|
|
await qb.applyJoinedFilters(options.em, options.filters);
|
|
}
|
|
branchQbs.push(qb);
|
|
}
|
|
const [first, ...rest] = branchQbs;
|
|
const unionQb = strategy === 'union' ? first.union(...rest) : first.unionAll(...rest);
|
|
const pkHash = Utils.getPrimaryKeyHash(meta.primaryKeys);
|
|
// MySQL does not allow referencing the target table in a subquery
|
|
// for UPDATE/DELETE, so we wrap the union in a derived table.
|
|
if (forDml) {
|
|
const { sql, params } = unionQb.toQuery();
|
|
return {
|
|
$and: [where, { [pkHash]: { $in: raw(`select * from (${sql}) as __u`, params) } }],
|
|
};
|
|
}
|
|
return {
|
|
$and: [where, { [pkHash]: { $in: unionQb.toRaw() } }],
|
|
};
|
|
}
|
|
buildOrderBy(qb, meta, populate, options) {
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
// `options._populateWhere` is a copy of the value provided by user with a fallback to the global config option
|
|
// as `options.populateWhere` will be always recomputed to respect filters
|
|
const populateWhereAll = options._populateWhere !== 'infer' && !Utils.isEmpty(options._populateWhere);
|
|
const path = (populateWhereAll ? '[populate]' : '') + meta.className;
|
|
const optionsOrderBy = Utils.asArray(options.orderBy);
|
|
const populateOrderBy = this.buildPopulateOrderBy(
|
|
qb,
|
|
meta,
|
|
Utils.asArray(options.populateOrderBy ?? options.orderBy),
|
|
path,
|
|
!!options.populateOrderBy,
|
|
);
|
|
const joinedPropsOrderBy = this.buildJoinedPropsOrderBy(qb, meta, joinedProps, options, path);
|
|
return [...optionsOrderBy, ...populateOrderBy, ...joinedPropsOrderBy];
|
|
}
|
|
buildPopulateOrderBy(qb, meta, populateOrderBy, parentPath, explicit, parentAlias = qb.alias) {
|
|
const orderBy = [];
|
|
for (let i = 0; i < populateOrderBy.length; i++) {
|
|
const orderHint = populateOrderBy[i];
|
|
for (const field of Utils.getObjectQueryKeys(orderHint)) {
|
|
const childOrder = orderHint[field];
|
|
if (RawQueryFragment.isKnownFragmentSymbol(field)) {
|
|
const { sql, params } = RawQueryFragment.getKnownFragment(field);
|
|
const key = raw(sql.replace(new RegExp(ALIAS_REPLACEMENT_RE, 'g'), parentAlias), params);
|
|
orderBy.push({ [key]: childOrder });
|
|
continue;
|
|
}
|
|
const prop = meta.properties[field];
|
|
if (!prop) {
|
|
throw new Error(`Trying to order by not existing property ${meta.className}.${field}`);
|
|
}
|
|
let path = parentPath;
|
|
const meta2 = prop.targetMeta;
|
|
if (
|
|
prop.kind !== ReferenceKind.SCALAR &&
|
|
(![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) ||
|
|
!prop.owner ||
|
|
Utils.isPlainObject(childOrder))
|
|
) {
|
|
path += `.${field}`;
|
|
}
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && typeof childOrder !== 'object') {
|
|
path += '[pivot]';
|
|
}
|
|
const join = qb.getJoinForPath(path, { matchPopulateJoins: true });
|
|
const propAlias = qb.getAliasForJoinPath(join ?? path, { matchPopulateJoins: true }) ?? parentAlias;
|
|
if (!join) {
|
|
continue;
|
|
}
|
|
if (
|
|
join &&
|
|
![ReferenceKind.SCALAR, ReferenceKind.EMBEDDED].includes(prop.kind) &&
|
|
typeof childOrder === 'object'
|
|
) {
|
|
const children = this.buildPopulateOrderBy(qb, meta2, Utils.asArray(childOrder), path, explicit, propAlias);
|
|
orderBy.push(...children);
|
|
continue;
|
|
}
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && join) {
|
|
if (prop.fixedOrderColumn) {
|
|
orderBy.push({ [`${join.alias}.${prop.fixedOrderColumn}`]: childOrder });
|
|
} else {
|
|
for (const col of prop.inverseJoinColumns) {
|
|
orderBy.push({ [`${join.ownerAlias}.${col}`]: childOrder });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const order = typeof childOrder === 'object' ? childOrder[field] : childOrder;
|
|
if (order) {
|
|
orderBy.push({ [`${propAlias}.${field}`]: order });
|
|
}
|
|
}
|
|
}
|
|
return orderBy;
|
|
}
|
|
buildJoinedPropsOrderBy(qb, meta, populate, options, parentPath) {
|
|
const orderBy = [];
|
|
const joinedProps = this.joinedProps(meta, populate, options);
|
|
for (const hint of joinedProps) {
|
|
const [propName, ref] = hint.field.split(':', 2);
|
|
const prop = meta.properties[propName];
|
|
let path = `${parentPath}.${propName}`;
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && ref) {
|
|
path += '[pivot]';
|
|
}
|
|
if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind)) {
|
|
this.buildToManyOrderBy(qb, prop, path, ref, orderBy);
|
|
}
|
|
if (hint.children) {
|
|
orderBy.push(...this.buildJoinedPropsOrderBy(qb, prop.targetMeta, hint.children, options, path));
|
|
}
|
|
}
|
|
return orderBy;
|
|
}
|
|
buildToManyOrderBy(qb, prop, path, ref, orderBy) {
|
|
const join = qb.getJoinForPath(path, { matchPopulateJoins: true });
|
|
const propAlias = qb.getAliasForJoinPath(join ?? path, { matchPopulateJoins: true });
|
|
if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.fixedOrder && join) {
|
|
const alias = ref ? propAlias : join.ownerAlias;
|
|
orderBy.push({ [`${alias}.${prop.fixedOrderColumn}`]: QueryOrder.ASC });
|
|
}
|
|
const effectiveOrderBy = QueryHelper.mergeOrderBy(prop.orderBy, prop.targetMeta?.orderBy);
|
|
for (const item of effectiveOrderBy) {
|
|
for (const field of Utils.getObjectQueryKeys(item)) {
|
|
const order = item[field];
|
|
if (RawQueryFragment.isKnownFragmentSymbol(field)) {
|
|
const { sql, params } = RawQueryFragment.getKnownFragment(field);
|
|
const sql2 = propAlias ? sql.replace(new RegExp(ALIAS_REPLACEMENT_RE, 'g'), propAlias) : sql;
|
|
const key = raw(sql2, params);
|
|
orderBy.push({ [key]: order });
|
|
continue;
|
|
}
|
|
orderBy.push({ [`${propAlias}.${field}`]: order });
|
|
}
|
|
}
|
|
}
|
|
normalizeFields(fields, prefix = '') {
|
|
const ret = [];
|
|
for (const field of fields) {
|
|
if (typeof field === 'string') {
|
|
ret.push(prefix + field);
|
|
continue;
|
|
}
|
|
if (Utils.isPlainObject(field)) {
|
|
for (const key of Object.keys(field)) {
|
|
ret.push(...this.normalizeFields(field[key], key + '.'));
|
|
}
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
processField(meta, prop, field, ret) {
|
|
if (!prop || (prop.kind === ReferenceKind.ONE_TO_ONE && !prop.owner)) {
|
|
return;
|
|
}
|
|
if (prop.kind === ReferenceKind.EMBEDDED) {
|
|
if (prop.object) {
|
|
ret.push(prop.name);
|
|
return;
|
|
}
|
|
const parts = field.split('.');
|
|
const top = parts.shift();
|
|
for (const key of Object.keys(prop.embeddedProps)) {
|
|
if (!top || key === top) {
|
|
this.processField(meta, prop.embeddedProps[key], parts.join('.'), ret);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (prop.persist === false && !prop.embedded && !prop.formula) {
|
|
return;
|
|
}
|
|
ret.push(prop.name);
|
|
}
|
|
buildFields(meta, populate, joinedProps, qb, alias, options, schema) {
|
|
const lazyProps = meta.props.filter(prop => prop.lazy && !populate.some(p => this.isPopulated(meta, prop, p)));
|
|
const hasLazyFormulas = meta.props.some(p => p.lazy && p.formula);
|
|
const requiresSQLConversion = meta.props.some(p => p.customType?.convertToJSValueSQL && p.persist !== false);
|
|
const hasExplicitFields = !!options.fields;
|
|
const ret = [];
|
|
let addFormulas = false;
|
|
// handle root entity properties first, this is used for both strategies in the same way
|
|
if (options.fields) {
|
|
for (const field of this.normalizeFields(options.fields)) {
|
|
if (field === '*') {
|
|
ret.push('*');
|
|
continue;
|
|
}
|
|
const parts = field.split('.');
|
|
const rootPropName = parts.shift(); // first one is the `prop`
|
|
const prop = QueryHelper.findProperty(rootPropName, {
|
|
metadata: this.metadata,
|
|
platform: this.platform,
|
|
entityName: meta.class,
|
|
where: {},
|
|
aliasMap: qb.getAliasMap(),
|
|
});
|
|
this.processField(meta, prop, parts.join('.'), ret);
|
|
}
|
|
if (!options.fields.includes('*') && !options.fields.includes(`${qb.alias}.*`)) {
|
|
ret.unshift(...meta.primaryKeys.filter(pk => !options.fields.includes(pk)));
|
|
}
|
|
if (
|
|
meta.root.inheritanceType === 'sti' &&
|
|
!options.fields.includes(`${qb.alias}.${meta.root.discriminatorColumn}`)
|
|
) {
|
|
ret.push(meta.root.discriminatorColumn);
|
|
}
|
|
} else if (!Utils.isEmpty(options.exclude) || lazyProps.some(p => !p.formula && (p.kind !== '1:1' || p.owner))) {
|
|
const props = meta.props.filter(prop =>
|
|
this.platform.shouldHaveColumn(prop, populate, options.exclude, false, false),
|
|
);
|
|
ret.push(...props.filter(p => !lazyProps.includes(p)).map(p => p.name));
|
|
addFormulas = true;
|
|
} else if (hasLazyFormulas || requiresSQLConversion) {
|
|
ret.push('*');
|
|
addFormulas = true;
|
|
} else {
|
|
ret.push('*');
|
|
}
|
|
if (ret.length > 0 && !hasExplicitFields && addFormulas) {
|
|
// Create formula column mapping with unquoted aliases - quoting should be handled by the user via `quote` helper
|
|
const quotedAlias = this.platform.quoteIdentifier(alias);
|
|
const columns = meta.createColumnMappingObject(alias);
|
|
const effectiveSchema = schema ?? (meta.schema !== '*' ? meta.schema : undefined);
|
|
for (const prop of meta.props) {
|
|
if (lazyProps.includes(prop)) {
|
|
continue;
|
|
}
|
|
if (prop.formula) {
|
|
const aliased = this.platform.quoteIdentifier(prop.fieldNames[0]);
|
|
const table = this.createFormulaTable(quotedAlias.toString(), meta, effectiveSchema);
|
|
ret.push(raw(`${this.evaluateFormula(prop.formula, columns, table)} as ${aliased}`));
|
|
}
|
|
if (!prop.object && (prop.hasConvertToDatabaseValueSQL || prop.hasConvertToJSValueSQL)) {
|
|
ret.push(prop.name);
|
|
}
|
|
}
|
|
}
|
|
// add joined relations after the root entity fields
|
|
if (joinedProps.length > 0) {
|
|
ret.push(
|
|
...this.getFieldsForJoinedLoad(qb, meta, {
|
|
explicitFields: options.fields,
|
|
exclude: options.exclude,
|
|
populate,
|
|
parentTableAlias: alias,
|
|
...options,
|
|
}),
|
|
);
|
|
}
|
|
return Utils.unique(ret);
|
|
}
|
|
}
|