Eager relation is fetched twice when the query already joins and selects it (`relationLoadStrategy: 'query'`)
Issue description
Under relationLoadStrategy: 'query', every eager relation of the queried entity is fetched by a separate query after the main one, and the result is assigned over whatever the main query hydrated. Nothing checks whether the main query already joined and selected that relation, so a query which did is charged for the relation twice.
This is easy to hit in 1.x. Since #11326 the query strategy no longer joins eager relations into the main query, so any query that has to name the relation in SQL — an ORDER BY or a WHERE on one of its columns — must create the join itself, and creating it is enough to cause the double fetch.
Steps to reproduce
import 'reflect-metadata';
import { Column, DataSource, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
let transformerCalls = 0;
@Entity()
class Variant {
@PrimaryGeneratedColumn() id: number;
@Column() sku: string;
@OneToMany(() => Price, price => price.variant, { eager: true }) prices: Price[];
}
@Entity()
class Price {
@PrimaryGeneratedColumn() id: number;
@Column({
transformer: {
to: (v: number) => v,
from: (v: number) => {
transformerCalls++;
return v;
},
},
})
amount: number;
@ManyToOne(() => Variant, variant => variant.prices) variant: Variant;
}
async function main() {
const selects: string[] = [];
const dataSource = new DataSource({
type: 'better-sqlite3',
database: ':memory:',
entities: [Price, Variant],
synchronize: true,
logging: ['query'],
logger: {
logQuery: (query: string) => {
if (query.startsWith('SELECT') && query.includes('price')) {
selects.push(query);
}
},
logQueryError: () => undefined,
logQuerySlow: () => undefined,
logSchemaBuild: () => undefined,
logMigration: () => undefined,
log: () => undefined,
} as any,
});
await dataSource.initialize();
const variant = await dataSource.getRepository(Variant).save({ sku: 'SKU1' });
await dataSource.getRepository(Price).save({ amount: 500, variant });
transformerCalls = 0;
selects.length = 0;
const rows = await dataSource
.getRepository(Variant)
.createQueryBuilder('variant')
.setFindOptions({ relationLoadStrategy: 'query' })
.leftJoinAndSelect('variant.prices', 'variant__prices')
.orderBy('variant__prices.amount', 'ASC')
.getMany();
console.log('rows', rows.length, 'prices', rows[0].prices.length);
console.log('SELECTs touching price:', selects.length);
console.log('column transformer calls:', transformerCalls);
selects.forEach((q, i) => console.log(` [${i + 1}] ${q}`));
await dataSource.destroy();
}
main().catch(e => {
console.error(`${e.constructor.name}: ${e.message}`);
process.exit(1);
});Output on 1.1.0, for one variant with one price:
rows 1 prices 1
SELECTs touching price: 3
column transformer calls: 2
[1] SELECT "variant"."id" AS "variant_id", "variant"."sku" AS "variant_sku", "variant__prices"."id" AS "variant__prices_id", "variant__prices"."amount" AS "variant__prices_amount", "variant__prices"."variantId" AS "variant__prices_variantId" FROM "variant" "variant" LEFT JOIN "price" "variant__prices" ON "variant__prices"."variantId"="variant"."id" ORDER BY "variant__prices"."amount" ASC
[2] SELECT "Price"."id" AS "Price_id", "Price"."amount" AS "Price_amount", "Price"."variantId" AS "Price_variantId" FROM "price" "Price" WHERE "Price"."variantId" IN (?)
[3] SELECT "Price"."id" AS "Price_prices_id", "Price"."variantId" AS "Variant_id" FROM "price" "Price" WHERE "Price"."variantId" IN (1)Query [1] has already selected the price row and hydrated it. Queries [2] and [3] fetch it again and the result replaces what [1] produced. The column transformer running twice per row is what makes the repeated work visible; a transformer that is expensive, or that has a side effect, does double the work it should.
Cause
setFindOptions() registers every eager relation of the main alias for a separate load:
else if (this.expressionMap.relationLoadStrategy === "query") {
this.concatRelationMetadata(...this.expressionMap.mainAlias.metadata.eagerRelations)
}executeEntitiesAndRawResults() then loads each of this.relationMetadatas, with no test for whether the query already covers it.
The join strategy does have such a test. FindOptionsUtils.joinEagerRelations() searches expressionMap.joinAttributes for an existing join of the same relation and reuses its alias rather than adding a second one, and #11991 widened that check so it matches joins carrying an ON condition too. The query strategy has no equivalent.
Suggested fix
Skip the separate query when a join already covers the relation: a LEFT join of that relation from the queried entity, with no extra ON condition, and selected.
The ON condition matters more here than it does for the join strategy. A join which restricts its rows, such as INNER JOIN ... ON channel.id = :channelId, hydrates a subset of the relation, and the separate query is what currently replaces that subset with the whole of it. Treating such a join as covering the relation would change what callers get back.
Two further conditions are needed to keep the results identical. The separate query loads the related entity's own eager relations, and honours any select, order or relations the find options give for that relation. A join of the relation reproduces neither. So the skip should apply only when the relation's target entity has no eager relations of its own, and the find options say nothing about the relation's contents.
Related
#11991 is the same problem under the join strategy, where it shows up as duplicate JOINs rather than a duplicate query. #10426 reports the cost of those duplicate joins, and #11267 notes that the duplicated work itself was left unaddressed there.
Which of the two hydrations survives is decided by RelationMetadata.setEntityValue(), whose merge branch is a silent no-op for entity instances. That is already reported as #12683, with #12715 open against it. A fix there changes which value wins, and this issue is about the second fetch happening at all.
Environment
typeorm 1.1.0, better-sqlite3 12.11.1, Node 24.14.1.
Source: typeorm/typeorm