902 lines
30 KiB
JavaScript
902 lines
30 KiB
JavaScript
import { ReferenceKind, isRaw } from '@mikro-orm/core';
|
|
import {
|
|
AliasNode,
|
|
ColumnNode,
|
|
ColumnUpdateNode,
|
|
OperationNodeTransformer,
|
|
PrimitiveValueListNode,
|
|
ReferenceNode,
|
|
SchemableIdentifierNode,
|
|
TableNode,
|
|
ValueListNode,
|
|
ValueNode,
|
|
ValuesNode,
|
|
} from 'kysely';
|
|
export class MikroTransformer extends OperationNodeTransformer {
|
|
/**
|
|
* Context stack to support nested queries (subqueries, CTEs)
|
|
* Each level of query scope has its own Map of table aliases/names to EntityMetadata
|
|
* Top of stack (highest index) is the current scope
|
|
*/
|
|
#contextStack = [];
|
|
/**
|
|
* Subquery alias map: maps subquery/CTE alias to its source table metadata
|
|
* Used to resolve columns from subqueries/CTEs to their original table definitions
|
|
*/
|
|
#subqueryAliasMap = new Map();
|
|
#metadata;
|
|
#platform;
|
|
/**
|
|
* Global map of all entities involved in the query.
|
|
* Populated during AST transformation and used for result transformation.
|
|
*/
|
|
#entityMap = new Map();
|
|
#em;
|
|
#options;
|
|
constructor(em, options = {}) {
|
|
super();
|
|
this.#em = em;
|
|
this.#options = options;
|
|
this.#metadata = em.getMetadata();
|
|
this.#platform = em.getDriver().getPlatform();
|
|
}
|
|
reset() {
|
|
this.#subqueryAliasMap.clear();
|
|
this.#entityMap.clear();
|
|
}
|
|
getOutputEntityMap() {
|
|
return this.#entityMap;
|
|
}
|
|
/** @internal */
|
|
getContextStack() {
|
|
return this.#contextStack;
|
|
}
|
|
/** @internal */
|
|
getSubqueryAliasMap() {
|
|
return this.#subqueryAliasMap;
|
|
}
|
|
transformSelectQuery(node, queryId) {
|
|
// Push a new context for this query scope (starts with inherited parent context)
|
|
const currentContext = new Map();
|
|
this.#contextStack.push(currentContext);
|
|
try {
|
|
// Process WITH clause (CTEs) first - they define names available in this scope
|
|
if (node.with) {
|
|
this.processWithNode(node.with, currentContext);
|
|
}
|
|
// Process FROM clause - main tables in this scope
|
|
if (node.from?.froms) {
|
|
for (const from of node.from.froms) {
|
|
this.processFromItem(from, currentContext);
|
|
}
|
|
}
|
|
// Process JOINs - additional tables joined into this scope
|
|
if (node.joins) {
|
|
for (const join of node.joins) {
|
|
this.processJoinNode(join, currentContext);
|
|
}
|
|
}
|
|
return super.transformSelectQuery(node, queryId);
|
|
} finally {
|
|
// Pop the context when exiting this query scope
|
|
this.#contextStack.pop();
|
|
}
|
|
}
|
|
transformInsertQuery(node, queryId) {
|
|
const currentContext = new Map();
|
|
this.#contextStack.push(currentContext);
|
|
try {
|
|
let entityMeta;
|
|
if (node.into) {
|
|
const tableName = this.getTableName(node.into);
|
|
if (tableName) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
if (meta) {
|
|
entityMeta = meta;
|
|
currentContext.set(meta.tableName, meta);
|
|
this.#entityMap.set(meta.tableName, meta);
|
|
}
|
|
}
|
|
}
|
|
const nodeWithHooks =
|
|
this.#options.processOnCreateHooks && entityMeta ? this.processOnCreateHooks(node, entityMeta) : node;
|
|
const nodeWithConvertedValues =
|
|
this.#options.convertValues && entityMeta ? this.processInsertValues(nodeWithHooks, entityMeta) : nodeWithHooks;
|
|
// Handle ON CONFLICT clause
|
|
let finalNode = nodeWithConvertedValues;
|
|
if (node.onConflict?.updates && entityMeta) {
|
|
// Create a temporary UpdateQueryNode to reuse processOnUpdateHooks and processUpdateValues
|
|
// We only care about the updates part
|
|
const tempUpdateNode = {
|
|
kind: 'UpdateQueryNode',
|
|
table: node.into, // Dummy table
|
|
updates: node.onConflict.updates,
|
|
};
|
|
const updatesWithHooks = this.#options.processOnUpdateHooks
|
|
? this.processOnUpdateHooks(tempUpdateNode, entityMeta).updates
|
|
: node.onConflict.updates;
|
|
const tempUpdateNodeWithHooks = {
|
|
...tempUpdateNode,
|
|
updates: updatesWithHooks,
|
|
};
|
|
const updatesWithConvertedValues = this.#options.convertValues
|
|
? this.processUpdateValues(tempUpdateNodeWithHooks, entityMeta).updates
|
|
: updatesWithHooks;
|
|
if (updatesWithConvertedValues && updatesWithConvertedValues !== node.onConflict.updates) {
|
|
// Construct the new OnConflictNode with updated values
|
|
finalNode = {
|
|
...finalNode,
|
|
onConflict: {
|
|
...node.onConflict,
|
|
updates: updatesWithConvertedValues,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return super.transformInsertQuery(finalNode, queryId);
|
|
} finally {
|
|
this.#contextStack.pop();
|
|
}
|
|
}
|
|
transformUpdateQuery(node, queryId) {
|
|
const currentContext = new Map();
|
|
this.#contextStack.push(currentContext);
|
|
try {
|
|
let entityMeta;
|
|
if (node.table && TableNode.is(node.table)) {
|
|
const tableName = this.getTableName(node.table);
|
|
if (tableName) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
if (meta) {
|
|
entityMeta = meta;
|
|
currentContext.set(meta.tableName, meta);
|
|
this.#entityMap.set(meta.tableName, meta);
|
|
}
|
|
}
|
|
}
|
|
// Process FROM clause in UPDATE queries (for UPDATE with JOIN)
|
|
if (node.from) {
|
|
for (const fromItem of node.from.froms) {
|
|
this.processFromItem(fromItem, currentContext);
|
|
}
|
|
}
|
|
// Also process JOINs in UPDATE queries
|
|
if (node.joins) {
|
|
for (const join of node.joins) {
|
|
this.processJoinNode(join, currentContext);
|
|
}
|
|
}
|
|
const nodeWithHooks =
|
|
this.#options.processOnUpdateHooks && entityMeta ? this.processOnUpdateHooks(node, entityMeta) : node;
|
|
const nodeWithConvertedValues =
|
|
this.#options.convertValues && entityMeta ? this.processUpdateValues(nodeWithHooks, entityMeta) : nodeWithHooks;
|
|
return super.transformUpdateQuery(nodeWithConvertedValues, queryId);
|
|
} finally {
|
|
this.#contextStack.pop();
|
|
}
|
|
}
|
|
transformDeleteQuery(node, queryId) {
|
|
const currentContext = new Map();
|
|
this.#contextStack.push(currentContext);
|
|
try {
|
|
const froms = node.from?.froms;
|
|
if (froms && froms.length > 0) {
|
|
const firstFrom = froms[0];
|
|
if (TableNode.is(firstFrom)) {
|
|
const tableName = this.getTableName(firstFrom);
|
|
if (tableName) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
if (meta) {
|
|
currentContext.set(meta.tableName, meta);
|
|
this.#entityMap.set(meta.tableName, meta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Also process JOINs in DELETE queries
|
|
if (node.joins) {
|
|
for (const join of node.joins) {
|
|
this.processJoinNode(join, currentContext);
|
|
}
|
|
}
|
|
return super.transformDeleteQuery(node, queryId);
|
|
} finally {
|
|
this.#contextStack.pop();
|
|
}
|
|
}
|
|
transformMergeQuery(node, queryId) {
|
|
const currentContext = new Map();
|
|
this.#contextStack.push(currentContext);
|
|
try {
|
|
return super.transformMergeQuery(node, queryId);
|
|
} finally {
|
|
this.#contextStack.pop();
|
|
}
|
|
}
|
|
transformIdentifier(node, queryId) {
|
|
node = super.transformIdentifier(node, queryId);
|
|
const parent = this.nodeStack[this.nodeStack.length - 2];
|
|
// Transform table names when tableNamingStrategy is 'entity'
|
|
if (this.#options.tableNamingStrategy === 'entity' && parent && SchemableIdentifierNode.is(parent)) {
|
|
const meta = this.findEntityMetadata(node.name);
|
|
if (meta) {
|
|
return {
|
|
...node,
|
|
name: meta.tableName,
|
|
};
|
|
}
|
|
}
|
|
// Transform column names when columnNamingStrategy is 'property'
|
|
// Support ColumnNode, ColumnUpdateNode, and ReferenceNode (for JOIN conditions)
|
|
if (
|
|
this.#options.columnNamingStrategy === 'property' &&
|
|
parent &&
|
|
(ColumnNode.is(parent) || ColumnUpdateNode.is(parent) || ReferenceNode.is(parent))
|
|
) {
|
|
const ownerMeta = this.findOwnerEntityInContext();
|
|
if (ownerMeta) {
|
|
const prop = ownerMeta.properties[node.name];
|
|
const fieldName = prop?.fieldNames?.[0];
|
|
if (fieldName) {
|
|
return {
|
|
...node,
|
|
name: fieldName,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return node;
|
|
}
|
|
/**
|
|
* Find owner entity metadata for the current identifier in the context stack.
|
|
* Supports both aliased and non-aliased table references.
|
|
* Searches up the context stack to support correlated subqueries.
|
|
* Also checks subquery/CTE aliases to resolve to their source tables.
|
|
*/
|
|
findOwnerEntityInContext() {
|
|
// Check if current column has a table reference (e.g., u.firstName)
|
|
const reference = this.nodeStack.find(it => ReferenceNode.is(it));
|
|
if (reference?.table && TableNode.is(reference.table)) {
|
|
const tableName = this.getTableName(reference.table);
|
|
if (tableName) {
|
|
// First, check in subquery alias map (for CTE/subquery columns)
|
|
if (this.#subqueryAliasMap.has(tableName)) {
|
|
return this.#subqueryAliasMap.get(tableName);
|
|
}
|
|
// Find entity metadata to get the actual table name
|
|
// Context uses table names (meta.tableName) as keys, not entity names
|
|
const entityMeta = this.findEntityMetadata(tableName);
|
|
if (entityMeta) {
|
|
// Search in context stack using the actual table name
|
|
const meta = this.lookupInContextStack(entityMeta.tableName);
|
|
if (meta) {
|
|
return meta;
|
|
}
|
|
// Also try with the entity name (for cases where context uses entity name)
|
|
const metaByEntityName = this.lookupInContextStack(tableName);
|
|
if (metaByEntityName) {
|
|
return metaByEntityName;
|
|
}
|
|
} else {
|
|
// If entity metadata not found, try direct lookup (for CTE/subquery cases)
|
|
const meta = this.lookupInContextStack(tableName);
|
|
if (meta) {
|
|
return meta;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// If no explicit table reference, use the first entity in current context
|
|
if (this.#contextStack.length > 0) {
|
|
const currentContext = this.#contextStack[this.#contextStack.length - 1];
|
|
for (const [alias, meta] of currentContext.entries()) {
|
|
if (meta) {
|
|
return meta;
|
|
}
|
|
// If the context value is undefined but the alias is in subqueryAliasMap,
|
|
// use the mapped metadata (for CTE/subquery cases)
|
|
if (!meta && this.#subqueryAliasMap.has(alias)) {
|
|
const mappedMeta = this.#subqueryAliasMap.get(alias);
|
|
if (mappedMeta) {
|
|
return mappedMeta;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
processOnCreateHooks(node, meta) {
|
|
if (!node.columns || !node.values || !ValuesNode.is(node.values)) {
|
|
return node;
|
|
}
|
|
const existingProps = new Set();
|
|
for (const col of node.columns) {
|
|
const prop = this.findProperty(meta, this.normalizeColumnName(col.column));
|
|
if (prop) {
|
|
existingProps.add(prop.name);
|
|
}
|
|
}
|
|
const missingProps = meta.props.filter(prop => prop.onCreate && !existingProps.has(prop.name));
|
|
if (missingProps.length === 0) {
|
|
return node;
|
|
}
|
|
const newColumns = [...node.columns];
|
|
for (const prop of missingProps) {
|
|
newColumns.push(ColumnNode.create(prop.name));
|
|
}
|
|
const newRows = node.values.values.map(row => {
|
|
const valuesToAdd = missingProps.map(prop => {
|
|
const val = prop.onCreate(undefined, this.#em);
|
|
return val;
|
|
});
|
|
if (ValueListNode.is(row)) {
|
|
const newValues = [...row.values, ...valuesToAdd.map(v => ValueNode.create(v))];
|
|
return ValueListNode.create(newValues);
|
|
}
|
|
if (PrimitiveValueListNode.is(row)) {
|
|
const newValues = [...row.values, ...valuesToAdd];
|
|
return PrimitiveValueListNode.create(newValues);
|
|
}
|
|
return row;
|
|
});
|
|
return {
|
|
...node,
|
|
columns: Object.freeze(newColumns),
|
|
values: ValuesNode.create(newRows),
|
|
};
|
|
}
|
|
processOnUpdateHooks(node, meta) {
|
|
if (!node.updates) {
|
|
return node;
|
|
}
|
|
const existingProps = new Set();
|
|
for (const update of node.updates) {
|
|
if (ColumnNode.is(update.column)) {
|
|
const prop = this.findProperty(meta, this.normalizeColumnName(update.column.column));
|
|
if (prop) {
|
|
existingProps.add(prop.name);
|
|
}
|
|
}
|
|
}
|
|
const missingProps = meta.props.filter(prop => prop.onUpdate && !existingProps.has(prop.name));
|
|
if (missingProps.length === 0) {
|
|
return node;
|
|
}
|
|
const newUpdates = [...node.updates];
|
|
for (const prop of missingProps) {
|
|
const val = prop.onUpdate(undefined, this.#em);
|
|
newUpdates.push(ColumnUpdateNode.create(ColumnNode.create(prop.name), ValueNode.create(val)));
|
|
}
|
|
return {
|
|
...node,
|
|
updates: Object.freeze(newUpdates),
|
|
};
|
|
}
|
|
processInsertValues(node, meta) {
|
|
if (!node.columns?.length || !node.values || !ValuesNode.is(node.values)) {
|
|
return node;
|
|
}
|
|
const columnProps = this.mapColumnsToProperties(node.columns, meta);
|
|
const shouldConvert = this.shouldConvertValues();
|
|
let changed = false;
|
|
const convertedRows = node.values.values.map(row => {
|
|
if (ValueListNode.is(row)) {
|
|
if (row.values.length !== columnProps.length) {
|
|
return row;
|
|
}
|
|
const values = row.values.map((valueNode, idx) => {
|
|
if (!ValueNode.is(valueNode)) {
|
|
return valueNode;
|
|
}
|
|
const converted = this.prepareInputValue(columnProps[idx], valueNode.value, shouldConvert);
|
|
if (converted === valueNode.value) {
|
|
return valueNode;
|
|
}
|
|
changed = true;
|
|
return valueNode.immediate ? ValueNode.createImmediate(converted) : ValueNode.create(converted);
|
|
});
|
|
return ValueListNode.create(values);
|
|
}
|
|
if (PrimitiveValueListNode.is(row)) {
|
|
if (row.values.length !== columnProps.length) {
|
|
return row;
|
|
}
|
|
const values = row.values.map((value, idx) => {
|
|
const converted = this.prepareInputValue(columnProps[idx], value, shouldConvert);
|
|
if (converted !== value) {
|
|
changed = true;
|
|
}
|
|
return converted;
|
|
});
|
|
return PrimitiveValueListNode.create(values);
|
|
}
|
|
return row;
|
|
});
|
|
if (!changed) {
|
|
return node;
|
|
}
|
|
return {
|
|
...node,
|
|
values: ValuesNode.create(convertedRows),
|
|
};
|
|
}
|
|
processUpdateValues(node, meta) {
|
|
if (!node.updates?.length) {
|
|
return node;
|
|
}
|
|
const shouldConvert = this.shouldConvertValues();
|
|
let changed = false;
|
|
const updates = node.updates.map(updateNode => {
|
|
if (!ValueNode.is(updateNode.value)) {
|
|
return updateNode;
|
|
}
|
|
const columnName = ColumnNode.is(updateNode.column)
|
|
? this.normalizeColumnName(updateNode.column.column)
|
|
: undefined;
|
|
const property = this.findProperty(meta, columnName);
|
|
const converted = this.prepareInputValue(property, updateNode.value.value, shouldConvert);
|
|
if (converted === updateNode.value.value) {
|
|
return updateNode;
|
|
}
|
|
changed = true;
|
|
const newValueNode = updateNode.value.immediate
|
|
? ValueNode.createImmediate(converted)
|
|
: ValueNode.create(converted);
|
|
return {
|
|
...updateNode,
|
|
value: newValueNode,
|
|
};
|
|
});
|
|
if (!changed) {
|
|
return node;
|
|
}
|
|
return {
|
|
...node,
|
|
updates,
|
|
};
|
|
}
|
|
mapColumnsToProperties(columns, meta) {
|
|
return columns.map(column => {
|
|
const columnName = this.normalizeColumnName(column.column);
|
|
return this.findProperty(meta, columnName);
|
|
});
|
|
}
|
|
normalizeColumnName(identifier) {
|
|
const name = identifier.name;
|
|
if (!name.includes('.')) {
|
|
return name;
|
|
}
|
|
const parts = name.split('.');
|
|
return parts[parts.length - 1] ?? name;
|
|
}
|
|
findProperty(meta, columnName) {
|
|
if (!meta || !columnName) {
|
|
return undefined;
|
|
}
|
|
if (meta.properties[columnName]) {
|
|
return meta.properties[columnName];
|
|
}
|
|
return meta.props.find(prop => prop.fieldNames?.includes(columnName));
|
|
}
|
|
shouldConvertValues() {
|
|
return !!this.#options.convertValues;
|
|
}
|
|
prepareInputValue(prop, value, enabled) {
|
|
if (!enabled || !prop || value == null) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
if (isRaw(value)) {
|
|
return value;
|
|
}
|
|
if ('kind' in value) {
|
|
return value;
|
|
}
|
|
}
|
|
if (prop.customType && !isRaw(value)) {
|
|
return prop.customType.convertToDatabaseValue(value, this.#platform, {
|
|
fromQuery: true,
|
|
key: prop.name,
|
|
mode: 'query-data',
|
|
});
|
|
}
|
|
if (value instanceof Date) {
|
|
return this.#platform.processDateProperty(value);
|
|
}
|
|
return value;
|
|
}
|
|
/**
|
|
* Look up a table name/alias in the context stack.
|
|
* Searches from current scope (top of stack) to parent scopes (bottom).
|
|
* This supports correlated subqueries and references to outer query tables.
|
|
*/
|
|
lookupInContextStack(tableNameOrAlias) {
|
|
// Search from top of stack (current scope) to bottom (parent scopes)
|
|
for (let i = this.#contextStack.length - 1; i >= 0; i--) {
|
|
const context = this.#contextStack[i];
|
|
if (context.has(tableNameOrAlias)) {
|
|
return context.get(tableNameOrAlias);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Process WITH node (CTE definitions)
|
|
*/
|
|
processWithNode(withNode, context) {
|
|
for (const cte of withNode.expressions) {
|
|
const cteName = this.getCTEName(cte.name);
|
|
if (cteName) {
|
|
// CTEs are not entities, so map to undefined
|
|
// They will be transformed recursively by transformSelectQuery
|
|
context.set(cteName, undefined);
|
|
// Also try to extract the source table from the CTE's expression
|
|
// This helps resolve columns in subsequent queries that use the CTE
|
|
if (cte.expression?.kind === 'SelectQueryNode') {
|
|
const sourceMeta = this.extractSourceTableFromSelectQuery(cte.expression);
|
|
if (sourceMeta) {
|
|
this.#subqueryAliasMap.set(cteName, sourceMeta);
|
|
// Add CTE to entityMap so it can be used for result transformation if needed
|
|
// (though CTEs usually don't appear in result rows directly, but their columns might)
|
|
this.#entityMap.set(cteName, sourceMeta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Extract CTE name from CommonTableExpressionNameNode
|
|
*/
|
|
getCTEName(nameNode) {
|
|
if (TableNode.is(nameNode.table)) {
|
|
return this.getTableName(nameNode.table);
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Process a FROM item (can be TableNode or AliasNode)
|
|
*/
|
|
processFromItem(
|
|
from, // OperationNode type - can be TableNode, AliasNode, or SelectQueryNode
|
|
context,
|
|
) {
|
|
if (AliasNode.is(from)) {
|
|
if (TableNode.is(from.node)) {
|
|
// Regular table with alias
|
|
const tableName = this.getTableName(from.node);
|
|
if (tableName && from.alias) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
const aliasName = this.extractAliasName(from.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, meta);
|
|
if (meta) {
|
|
this.#entityMap.set(aliasName, meta);
|
|
}
|
|
// Also map the alias in subqueryAliasMap if the table name is a CTE
|
|
if (this.#subqueryAliasMap.has(tableName)) {
|
|
this.#subqueryAliasMap.set(aliasName, this.#subqueryAliasMap.get(tableName));
|
|
}
|
|
}
|
|
}
|
|
} else if (from.node?.kind === 'SelectQueryNode') {
|
|
// Subquery with alias
|
|
const aliasName = this.extractAliasName(from.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, undefined);
|
|
// Try to extract the source table from the subquery
|
|
const sourceMeta = this.extractSourceTableFromSelectQuery(from.node);
|
|
if (sourceMeta) {
|
|
this.#subqueryAliasMap.set(aliasName, sourceMeta);
|
|
}
|
|
}
|
|
} else {
|
|
// Other types with alias
|
|
const aliasName = this.extractAliasName(from.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, undefined);
|
|
}
|
|
}
|
|
} else if (TableNode.is(from)) {
|
|
// Table without alias
|
|
const tableName = this.getTableName(from);
|
|
if (tableName) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
context.set(tableName, meta);
|
|
if (meta) {
|
|
this.#entityMap.set(tableName, meta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Process a JOIN node
|
|
*/
|
|
processJoinNode(join, context) {
|
|
const joinTable = join.table;
|
|
if (AliasNode.is(joinTable)) {
|
|
if (TableNode.is(joinTable.node)) {
|
|
// Regular table with alias in JOIN
|
|
const tableName = this.getTableName(joinTable.node);
|
|
if (tableName && joinTable.alias) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
const aliasName = this.extractAliasName(joinTable.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, meta);
|
|
if (meta) {
|
|
this.#entityMap.set(aliasName, meta);
|
|
}
|
|
// Also map the alias in subqueryAliasMap if the table name is a CTE
|
|
if (this.#subqueryAliasMap.has(tableName)) {
|
|
this.#subqueryAliasMap.set(aliasName, this.#subqueryAliasMap.get(tableName));
|
|
}
|
|
}
|
|
}
|
|
} else if (joinTable.node?.kind === 'SelectQueryNode') {
|
|
// Subquery with alias in JOIN
|
|
const aliasName = this.extractAliasName(joinTable.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, undefined);
|
|
// Try to extract the source table from the subquery
|
|
const sourceMeta = this.extractSourceTableFromSelectQuery(joinTable.node);
|
|
if (sourceMeta) {
|
|
this.#subqueryAliasMap.set(aliasName, sourceMeta);
|
|
}
|
|
}
|
|
} else {
|
|
// Other types with alias
|
|
const aliasName = this.extractAliasName(joinTable.alias);
|
|
if (aliasName) {
|
|
context.set(aliasName, undefined);
|
|
}
|
|
}
|
|
} else if (TableNode.is(joinTable)) {
|
|
// Table without alias in JOIN
|
|
const tableName = this.getTableName(joinTable);
|
|
if (tableName) {
|
|
const meta = this.findEntityMetadata(tableName);
|
|
// Use table name (meta.tableName) as key to match transformUpdateQuery behavior
|
|
if (meta) {
|
|
context.set(meta.tableName, meta);
|
|
this.#entityMap.set(meta.tableName, meta);
|
|
// Also set with entity name for backward compatibility
|
|
context.set(tableName, meta);
|
|
} else {
|
|
context.set(tableName, undefined);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Extract the primary source table from a SELECT query
|
|
* This helps resolve columns from subqueries to their original entity tables
|
|
*/
|
|
extractSourceTableFromSelectQuery(selectQuery) {
|
|
if (!selectQuery.from?.froms || selectQuery.from.froms.length === 0) {
|
|
return undefined;
|
|
}
|
|
// Get the first FROM table
|
|
const firstFrom = selectQuery.from.froms[0];
|
|
let sourceTable;
|
|
if (AliasNode.is(firstFrom) && TableNode.is(firstFrom.node)) {
|
|
sourceTable = firstFrom.node;
|
|
} else if (TableNode.is(firstFrom)) {
|
|
sourceTable = firstFrom;
|
|
}
|
|
if (sourceTable) {
|
|
const tableName = this.getTableName(sourceTable);
|
|
if (tableName) {
|
|
return this.findEntityMetadata(tableName);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Extract alias name from an alias node
|
|
*/
|
|
extractAliasName(alias) {
|
|
if (typeof alias === 'object' && 'name' in alias) {
|
|
return alias.name;
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Extract table name from a TableNode
|
|
*/
|
|
getTableName(node) {
|
|
if (!node) {
|
|
return undefined;
|
|
}
|
|
if (TableNode.is(node) && SchemableIdentifierNode.is(node.table)) {
|
|
const identifier = node.table.identifier;
|
|
if (typeof identifier === 'object' && 'name' in identifier) {
|
|
return identifier.name;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Find entity metadata by table name or entity name
|
|
*/
|
|
findEntityMetadata(name) {
|
|
const byEntity = this.#metadata.getByClassName(name, false);
|
|
if (byEntity) {
|
|
return byEntity;
|
|
}
|
|
const allMetadata = Array.from(this.#metadata);
|
|
const byTable = allMetadata.find(m => m.tableName === name);
|
|
if (byTable) {
|
|
return byTable;
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Transform result rows by mapping database column names to property names
|
|
* This is called for SELECT queries when columnNamingStrategy is 'property'
|
|
*/
|
|
transformResult(rows, entityMap) {
|
|
// Only transform if columnNamingStrategy is 'property' or convertValues is true, and we have data
|
|
if (
|
|
(this.#options.columnNamingStrategy !== 'property' && !this.#options.convertValues) ||
|
|
!rows ||
|
|
rows.length === 0
|
|
) {
|
|
return rows;
|
|
}
|
|
// If no entities found (e.g. raw query without known tables), return rows as is
|
|
if (entityMap.size === 0) {
|
|
return rows;
|
|
}
|
|
// Build a global mapping from database field names to property objects
|
|
const fieldToPropertyMap = this.buildGlobalFieldMap(entityMap);
|
|
const relationFieldMap = this.buildGlobalRelationFieldMap(entityMap);
|
|
// Transform each row
|
|
return rows.map(row => this.transformRow(row, fieldToPropertyMap, relationFieldMap));
|
|
}
|
|
buildGlobalFieldMap(entityMap) {
|
|
const map = {};
|
|
for (const [alias, meta] of entityMap.entries()) {
|
|
Object.assign(map, this.buildFieldToPropertyMap(meta, alias));
|
|
}
|
|
return map;
|
|
}
|
|
buildGlobalRelationFieldMap(entityMap) {
|
|
const map = {};
|
|
for (const [alias, meta] of entityMap.entries()) {
|
|
Object.assign(map, this.buildRelationFieldMap(meta, alias));
|
|
}
|
|
return map;
|
|
}
|
|
/**
|
|
* Build a mapping from database field names to property objects
|
|
* Format: { 'field_name': EntityProperty }
|
|
*/
|
|
buildFieldToPropertyMap(meta, alias) {
|
|
const map = {};
|
|
for (const prop of meta.props) {
|
|
if (prop.fieldNames && prop.fieldNames.length > 0) {
|
|
for (const fieldName of prop.fieldNames) {
|
|
if (!(fieldName in map)) {
|
|
map[fieldName] = prop;
|
|
}
|
|
if (alias) {
|
|
const dotted = `${alias}.${fieldName}`;
|
|
if (!(dotted in map)) {
|
|
map[dotted] = prop;
|
|
}
|
|
const underscored = `${alias}_${fieldName}`;
|
|
if (!(underscored in map)) {
|
|
map[underscored] = prop;
|
|
}
|
|
const doubleUnderscored = `${alias}__${fieldName}`;
|
|
if (!(doubleUnderscored in map)) {
|
|
map[doubleUnderscored] = prop;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!(prop.name in map)) {
|
|
map[prop.name] = prop;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
/**
|
|
* Build a mapping for relation fields
|
|
* For ManyToOne relations, we need to map from the foreign key field to the relation property
|
|
* Format: { 'foreign_key_field': 'relationPropertyName' }
|
|
*/
|
|
buildRelationFieldMap(meta, alias) {
|
|
const map = {};
|
|
for (const prop of meta.props) {
|
|
// For ManyToOne/OneToOne relations, find the foreign key field
|
|
if (prop.kind === ReferenceKind.MANY_TO_ONE || prop.kind === ReferenceKind.ONE_TO_ONE) {
|
|
if (prop.fieldNames && prop.fieldNames.length > 0) {
|
|
const fieldName = prop.fieldNames[0];
|
|
map[fieldName] = prop.name;
|
|
if (alias) {
|
|
map[`${alias}.${fieldName}`] = prop.name;
|
|
map[`${alias}_${fieldName}`] = prop.name;
|
|
map[`${alias}__${fieldName}`] = prop.name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
/**
|
|
* Transform a single row by mapping column names to property names
|
|
*/
|
|
transformRow(row, fieldToPropertyMap, relationFieldMap) {
|
|
const transformed = { ...row };
|
|
// First pass: map regular fields from fieldName to propertyName and convert values
|
|
for (const [fieldName, prop] of Object.entries(fieldToPropertyMap)) {
|
|
if (!(fieldName in transformed)) {
|
|
continue;
|
|
}
|
|
const converted = this.prepareOutputValue(prop, transformed[fieldName]);
|
|
if (this.#options.columnNamingStrategy === 'property' && prop.name !== fieldName) {
|
|
if (!(prop.name in transformed)) {
|
|
transformed[prop.name] = converted;
|
|
} else {
|
|
transformed[prop.name] = converted;
|
|
}
|
|
delete transformed[fieldName];
|
|
continue;
|
|
}
|
|
if (this.#options.convertValues) {
|
|
transformed[fieldName] = converted;
|
|
}
|
|
}
|
|
// Second pass: handle relation fields
|
|
// Only run if columnNamingStrategy is 'property', as we don't want to rename FKs otherwise
|
|
if (this.#options.columnNamingStrategy === 'property') {
|
|
for (const [fieldName, relationPropertyName] of Object.entries(relationFieldMap)) {
|
|
if (fieldName in transformed && !(relationPropertyName in transformed)) {
|
|
// Move the foreign key value to the relation property name
|
|
transformed[relationPropertyName] = transformed[fieldName];
|
|
delete transformed[fieldName];
|
|
}
|
|
}
|
|
}
|
|
return transformed;
|
|
}
|
|
prepareOutputValue(prop, value) {
|
|
if (!this.#options.convertValues || !prop || value == null) {
|
|
return value;
|
|
}
|
|
if (prop.customType) {
|
|
return prop.customType.convertToJSValue(value, this.#platform);
|
|
}
|
|
// Aligned with EntityComparator.getResultMapper logic
|
|
if (prop.runtimeType === 'boolean') {
|
|
// Use !! conversion like EntityComparator: value == null ? value : !!value
|
|
return value == null ? value : !!value;
|
|
}
|
|
if (prop.runtimeType === 'Date' && !this.#platform.isNumericProperty(prop)) {
|
|
// Aligned with EntityComparator: exclude numeric timestamp properties
|
|
// If already Date instance or null, return as is
|
|
if (value == null || value instanceof Date) {
|
|
return value;
|
|
}
|
|
// Handle timezone like EntityComparator.parseDate
|
|
const tz = this.#platform.getTimezone();
|
|
if (!tz || tz === 'local') {
|
|
return this.#platform.parseDate(value);
|
|
}
|
|
// For non-local timezone, check if value already has timezone info
|
|
// Number (timestamp) doesn't need timezone handling, string needs check
|
|
if (
|
|
typeof value === 'number' ||
|
|
(typeof value === 'string' && (value.includes('+') || value.lastIndexOf('-') > 10 || value.endsWith('Z')))
|
|
) {
|
|
return this.#platform.parseDate(value);
|
|
}
|
|
// Append timezone if not present (only for string values)
|
|
return this.#platform.parseDate(value + tz);
|
|
}
|
|
// For all other runtimeTypes (number, string, bigint, Buffer, object, any, etc.)
|
|
// EntityComparator just assigns directly without conversion
|
|
return value;
|
|
}
|
|
}
|