fix: empty objects being hydrated when eager loading relations that have a @VirtualColumn (#10927)

This commit is contained in:
Lucian Mocanu 2025-03-14 10:05:19 +01:00 committed by GitHub
parent 8429e8f9cc
commit ae96f87923
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 242 additions and 204 deletions

View File

@ -381,12 +381,12 @@ Special column that is never saved to the database and thus acts as a readonly p
Each time you call `find` or `findOne` from the entity manager, the value is recalculated based on the query function that was provided in the VirtualColumn Decorator. The alias argument passed to the query references the exact entity alias of the generated query behind the scenes.
```typescript
@Entity({ name: "companies", alias: "COMP" })
export class Company extends BaseEntity {
@Entity({ name: "companies" })
export class Company {
@PrimaryColumn("varchar", { length: 50 })
name: string;
@VirtualColumn({ query: (alias) => `SELECT COUNT("name") FROM "employees" WHERE "companyName" = ${alias}.name` })
@VirtualColumn({ query: (alias) => `SELECT COUNT("name") FROM "employees" WHERE "companyName" = ${alias}."name"` })
totalEmployeesCount: number;
@OneToMany((type) => Employee, (employee) => employee.company)
@ -394,7 +394,7 @@ export class Company extends BaseEntity {
}
@Entity({ name: "employees" })
export class Employee extends BaseEntity {
export class Employee {
@PrimaryColumn("varchar", { length: 50 })
name: string;

View File

@ -5,7 +5,8 @@ import { SelectQueryBuilder } from "../../query-builder/SelectQueryBuilder"
/**
* Holds a number of children in the closure table of the column.
*
* @deprecated Do not use this decorator, it may be removed in the future versions
* @deprecated This decorator will removed in the future versions.
* Use {@link VirtualColumn} to calculate the count instead.
*/
export function RelationCount<T>(
relation: string | ((object: T) => any),

View File

@ -2882,11 +2882,6 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
})
})
} else {
if (column.isVirtualProperty) {
// Do not add unselected virtual properties to final select
return
}
finalSelects.push({
selection: selectionPath,
aliasName: DriverUtils.buildAlias(
@ -4240,8 +4235,8 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
.join(" OR ")
}
} else {
let andConditions: string[] = []
for (let key in where) {
const andConditions: string[] = []
for (const key in where) {
if (where[key] === undefined || where[key] === null) continue
const propertyPath = embedPrefix ? embedPrefix + "." + key : key
@ -4261,7 +4256,7 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
if (column) {
let aliasPath = `${alias}.${propertyPath}`
if (column.isVirtualProperty && column.query) {
aliasPath = `(${column.query(alias)})`
aliasPath = `(${column.query(this.escape(alias))})`
}
// const parameterName = alias + "_" + propertyPath.split(".").join("_") + "_" + parameterIndex;
@ -4271,13 +4266,14 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
parameterValue = where[key].value
}
if (column.transformer) {
parameterValue instanceof FindOperator
? parameterValue.transformValue(column.transformer)
: (parameterValue =
ApplyValueTransformers.transformTo(
column.transformer,
parameterValue,
))
if (parameterValue instanceof FindOperator) {
parameterValue.transformValue(column.transformer)
} else {
parameterValue = ApplyValueTransformers.transformTo(
column.transformer,
parameterValue,
)
}
}
// if (parameterValue === null) {

View File

@ -239,7 +239,7 @@ export class RawSqlResultsToEntityTransformer {
if (value === undefined) continue
// we don't mark it as has data because if we will have all nulls in our object - we don't need such object
else if (value !== null) hasData = true
else if (value !== null && !column.isVirtualProperty) hasData = true
column.setEntityValue(
entity,

View File

@ -1,20 +1,19 @@
import {
ManyToOne,
Entity,
BaseEntity,
PrimaryGeneratedColumn,
Column,
} from "../../../../src"
import TimeSheet from "./TimeSheet"
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "../../../../../src"
import { TimeSheet } from "./TimeSheet"
@Entity({ name: "activities" })
export default class Activity extends BaseEntity {
export class Activity {
@PrimaryGeneratedColumn()
id: number
@Column("int")
hours: number
@ManyToOne((type) => TimeSheet, (timesheet) => timesheet.activities)
@ManyToOne(() => TimeSheet, (timesheet) => timesheet.activities)
timesheet: TimeSheet
}

View File

@ -3,21 +3,20 @@ import {
OneToMany,
PrimaryColumn,
VirtualColumn,
BaseEntity,
} from "../../../../src"
import Employee from "./Employee"
} from "../../../../../src"
import { Employee } from "./Employee"
@Entity({ name: "companies" })
export default class Company extends BaseEntity {
export class Company {
@PrimaryColumn("varchar", { length: 50 })
name: string
@VirtualColumn({
query: (alias) =>
`SELECT COUNT("name") FROM "employees" WHERE "companyName" = ${alias}.name`,
`SELECT COUNT("name") FROM "employees" WHERE "companyName" = ${alias}."name"`,
})
totalEmployeesCount: number
@OneToMany((type) => Employee, (employee) => employee.company)
@OneToMany(() => Employee, (employee) => employee.company)
employees: Employee[]
}

View File

@ -0,0 +1,20 @@
import {
Entity,
ManyToOne,
OneToMany,
PrimaryColumn
} from "../../../../../src"
import { Company } from "./Company"
import { TimeSheet } from "./TimeSheet"
@Entity({ name: "employees" })
export class Employee {
@PrimaryColumn("varchar", { length: 50 })
name: string
@ManyToOne(() => Company, (company) => company.employees)
company: Company
@OneToMany(() => TimeSheet, (timesheet) => timesheet.employee)
timesheets: TimeSheet[]
}

View File

@ -0,0 +1,26 @@
import {
Entity,
ManyToOne,
PrimaryGeneratedColumn,
VirtualColumn,
} from "../../../../../src"
import { Activity } from "./Activity"
import { Employee } from "./Employee"
@Entity({ name: "timesheets" })
export class TimeSheet {
@PrimaryGeneratedColumn()
id: number
@VirtualColumn({
query: (alias) =>
`SELECT SUM("hours") FROM "activities" WHERE "timesheetId" = ${alias}."id"`,
})
totalActivityHours: number
@ManyToOne(() => Activity, (activity) => activity.timesheet)
activities: Activity[]
@ManyToOne(() => Employee, (employee) => employee.timesheets)
employee: Employee
}

View File

@ -4,35 +4,57 @@ import {
DataSource,
FindManyOptions,
FindOneOptions,
FindOptionsUtils,
MoreThan,
} from "../../../src"
} from "../../../../src"
import { DriverUtils } from "../../../../src/driver/DriverUtils"
import {
closeTestingConnections,
createTestingConnections,
} from "../../utils/test-utils"
import Activity from "./entity/Activity"
import Company from "./entity/Company"
import Employee from "./entity/Employee"
import TimeSheet from "./entity/TimeSheet"
} from "../../../utils/test-utils"
import { Activity } from "./entity/Activity"
import { Company } from "./entity/Company"
import { Employee } from "./entity/Employee"
import { TimeSheet } from "./entity/TimeSheet"
describe("github issues > #9323 Add new VirtualColumn decorator feature", () => {
describe("column > virtual columns", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
enabledDrivers: ["postgres"],
schemaCreate: true,
dropSchema: true,
entities: [Company, Employee, TimeSheet, Activity],
})),
)
before(async () => {
connections = await createTestingConnections({
schemaCreate: true,
dropSchema: true,
entities: [Company, Employee, TimeSheet, Activity],
})
for (const connection of connections) {
// By default, MySQL uses backticks instead of quotes for identifiers
if (DriverUtils.isMySQLFamily(connection.driver)) {
const totalEmployeesCountMetadata = connection
.getMetadata(Company)
.columns.find(
(columnMetadata) =>
columnMetadata.propertyName ===
"totalEmployeesCount",
)!
totalEmployeesCountMetadata.query = (alias) =>
`SELECT COUNT(\`name\`) FROM \`employees\` WHERE \`companyName\` = ${alias}.\`name\``
const totalActivityHoursMetadata = connection
.getMetadata(TimeSheet)
.columns.find(
(columnMetadata) =>
columnMetadata.propertyName ===
"totalActivityHours",
)!
totalActivityHoursMetadata.query = (alias) =>
`SELECT SUM(\`hours\`) FROM \`activities\` WHERE \`timesheetId\` = ${alias}.\`id\``
}
}
})
after(() => closeTestingConnections(connections))
it("should generate expected sub-select & select statement", () =>
Promise.all(
connections.map((connection) => {
const metadata = connection.getMetadata(Company)
const options1: FindManyOptions<Company> = {
select: {
name: true,
@ -41,32 +63,28 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
}
const query1 = connection
.createQueryBuilder(
Company,
FindOptionsUtils.extractFindManyOptionsAlias(
options1,
) || metadata.name,
)
.setFindOptions(options1 || {})
.createQueryBuilder(Company, "Company")
.setFindOptions(options1)
.getSql()
expect(query1).to.eq(
`SELECT "Company"."name" AS "Company_name", (SELECT COUNT("name") FROM "employees" WHERE "companyName" = "Company".name) AS "Company_totalEmployeesCount" FROM "companies" "Company"`,
)
let expectedQuery = `SELECT "Company"."name" AS "Company_name", (SELECT COUNT("name") FROM "employees" WHERE "companyName" = "Company"."name") AS "Company_totalEmployeesCount" FROM "companies" "Company"`
if (DriverUtils.isMySQLFamily(connection.driver)) {
expectedQuery = expectedQuery.replaceAll('"', "`")
}
expect(query1).to.eq(expectedQuery)
}),
))
it("should generate expected sub-select & nested-subselect statement", () =>
Promise.all(
connections.map((connection) => {
const metadata = connection.getMetadata(Company)
const options1: FindManyOptions<Company> = {
const findOptions: FindManyOptions<Company> = {
select: {
name: true,
totalEmployeesCount: true,
employees: {
timesheets: {
totalActvityHours: true,
totalActivityHours: true,
},
},
},
@ -77,29 +95,25 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
},
}
const query1 = connection
.createQueryBuilder(
Company,
FindOptionsUtils.extractFindManyOptionsAlias(
options1,
) || metadata.name,
)
.setFindOptions(options1 || {})
const query = connection
.createQueryBuilder(Company, "Company")
.setFindOptions(findOptions)
.getSql()
expect(query1).to.include(
`SELECT "Company"."name" AS "Company_name"`,
)
expect(query1).to.include(
`(SELECT COUNT("name") FROM "employees" WHERE "companyName" = "Company".name) AS "Company_totalEmployeesCount", (SELECT SUM("hours") FROM "activities" WHERE "timesheetId" =`,
)
let expectedQuery1 = `SELECT "Company"."name" AS "Company_name"`
let expectedQuery2 = `(SELECT COUNT("name") FROM "employees" WHERE "companyName" = "Company"."name") AS "Company_totalEmployeesCount", (SELECT SUM("hours") FROM "activities" WHERE "timesheetId" =`
if (DriverUtils.isMySQLFamily(connection.driver)) {
expectedQuery1 = expectedQuery1.replaceAll('"', "`")
expectedQuery2 = expectedQuery2.replaceAll('"', "`")
}
expect(query).to.include(expectedQuery1)
expect(query).to.include(expectedQuery2)
}),
))
it("should not generate sub-select if column is not selected", () =>
Promise.all(
connections.map((connection) => {
const metadata = connection.getMetadata(Company)
const options: FindManyOptions<Company> = {
select: {
name: true,
@ -107,69 +121,73 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
},
}
const query = connection
.createQueryBuilder(
Company,
FindOptionsUtils.extractFindManyOptionsAlias(options) ||
metadata.name,
)
.setFindOptions(options || {})
.createQueryBuilder(Company, "Company")
.setFindOptions(options)
.getSql()
expect(query).to.eq(
`SELECT "Company"."name" AS "Company_name" FROM "companies" "Company"`,
)
let expectedQuery = `SELECT "Company"."name" AS "Company_name" FROM "companies" "Company"`
if (DriverUtils.isMySQLFamily(connection.driver)) {
expectedQuery = expectedQuery.replaceAll('"', "`")
}
expect(query).to.eq(expectedQuery)
}),
))
it("should be able to save and find sub-select data in the database", () =>
Promise.all(
connections.map(async (connection) => {
const companyName = "My Company 1"
const company = Company.create({ name: companyName } as Company)
await company.save()
const activityRepository = connection.getRepository(Activity)
const companyRepository = connection.getRepository(Company)
const employeeRepository = connection.getRepository(Employee)
const timesheetRepository = connection.getRepository(TimeSheet)
const employee1 = Employee.create({
const companyName = "My Company 1"
const company = companyRepository.create({ name: companyName })
await companyRepository.save(company)
const employee1 = employeeRepository.create({
name: "Collin 1",
company: company,
})
const employee2 = Employee.create({
const employee2 = employeeRepository.create({
name: "John 1",
company: company,
})
const employee3 = Employee.create({
const employee3 = employeeRepository.create({
name: "Cory 1",
company: company,
})
const employee4 = Employee.create({
const employee4 = employeeRepository.create({
name: "Kevin 1",
company: company,
})
await Employee.save([
await employeeRepository.save([
employee1,
employee2,
employee3,
employee4,
])
const employee1TimeSheet = TimeSheet.create({
const employee1TimeSheet = timesheetRepository.create({
employee: employee1,
})
await employee1TimeSheet.save()
const employee1Activities: Activity[] = [
Activity.create({
await timesheetRepository.save(employee1TimeSheet)
const employee1Activities = activityRepository.create([
{
hours: 2,
timesheet: employee1TimeSheet,
}),
Activity.create({
},
{
hours: 2,
timesheet: employee1TimeSheet,
}),
Activity.create({
},
{
hours: 2,
timesheet: employee1TimeSheet,
}),
]
await Activity.save(employee1Activities)
},
])
await activityRepository.save(employee1Activities)
const findOneOptions: FindOneOptions<Company> = {
select: {
@ -179,7 +197,7 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
name: true,
timesheets: {
id: true,
totalActvityHours: true,
totalActivityHours: true,
},
},
},
@ -193,7 +211,7 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
totalEmployeesCount: MoreThan(2),
employees: {
timesheets: {
totalActvityHours: MoreThan(0),
totalActivityHours: MoreThan(0),
},
},
},
@ -201,20 +219,24 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
employees: {
timesheets: {
id: "DESC",
totalActvityHours: "ASC",
totalActivityHours: "ASC",
},
},
},
}
const usersUnderCompany = await Company.findOne(findOneOptions)
const usersUnderCompany = await companyRepository.findOne(
findOneOptions,
)
expect(usersUnderCompany?.totalEmployeesCount).to.eq(4)
const employee1TimesheetFound = usersUnderCompany?.employees
.find((e) => e.name === employee1.name)
?.timesheets.find((ts) => ts.id === employee1TimeSheet.id)
expect(employee1TimesheetFound?.totalActvityHours).to.eq(6)
expect(employee1TimesheetFound?.totalActivityHours).to.eq(6)
const usersUnderCompanyList = await Company.find(findOneOptions)
const usersUnderCompanyList = await companyRepository.find(
findOneOptions,
)
const usersUnderCompanyListOne = usersUnderCompanyList[0]
expect(usersUnderCompanyListOne?.totalEmployeesCount).to.eq(4)
const employee1TimesheetListOneFound =
@ -223,52 +245,57 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
?.timesheets.find(
(ts) => ts.id === employee1TimeSheet.id,
)
expect(employee1TimesheetListOneFound?.totalActvityHours).to.eq(
6,
)
expect(
employee1TimesheetListOneFound?.totalActivityHours,
).to.eq(6)
}),
))
it("should be able to save and find sub-select data in the database (with query builder)", () =>
Promise.all(
connections.map(async (connection) => {
const companyName = "My Company 2"
const company = Company.create({ name: companyName } as Company)
await company.save()
const activityRepository = connection.getRepository(Activity)
const companyRepository = connection.getRepository(Company)
const employeeRepository = connection.getRepository(Employee)
const timesheetRepository = connection.getRepository(TimeSheet)
const employee1 = Employee.create({
const companyName = "My Company 2"
const company = companyRepository.create({ name: companyName })
await companyRepository.save(company)
const employee1 = employeeRepository.create({
name: "Collin 2",
company: company,
})
const employee2 = Employee.create({
const employee2 = employeeRepository.create({
name: "John 2",
company: company,
})
const employee3 = Employee.create({
const employee3 = employeeRepository.create({
name: "Cory 2",
company: company,
})
await Employee.save([employee1, employee2, employee3])
await employeeRepository.save([employee1, employee2, employee3])
const employee1TimeSheet = TimeSheet.create({
const employee1TimeSheet = timesheetRepository.create({
employee: employee1,
})
await employee1TimeSheet.save()
const employee1Activities: Activity[] = [
Activity.create({
await timesheetRepository.save(employee1TimeSheet)
const employee1Activities = activityRepository.create([
{
hours: 2,
timesheet: employee1TimeSheet,
}),
Activity.create({
},
{
hours: 2,
timesheet: employee1TimeSheet,
}),
Activity.create({
},
{
hours: 2,
timesheet: employee1TimeSheet,
}),
]
await Activity.save(employee1Activities)
},
])
await activityRepository.save(employee1Activities)
const companyQueryData = await connection
.createQueryBuilder(Company, "company")
@ -277,7 +304,7 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
"company.totalEmployeesCount",
"employee.name",
"timesheet.id",
"timesheet.totalActvityHours",
"timesheet.totalActivityHours",
])
.leftJoin("company.employees", "employee")
.leftJoin("employee.timesheets", "timesheet")
@ -286,7 +313,7 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
//.andWhere("company.totalEmployeesCount > 2")
//.orderBy({
// "employees.timesheets.id": "DESC",
// //"employees.timesheets.totalActvityHours": "ASC",
// //"employees.timesheets.totalActivityHours": "ASC",
//})
.getOne()
@ -297,7 +324,7 @@ describe("github issues > #9323 Add new VirtualColumn decorator feature", () =>
(t) => t.id === employee1TimeSheet.id,
)
expect(foundEmployeeTimeSheet?.totalActvityHours).to.eq(6)
expect(foundEmployeeTimeSheet?.totalActivityHours).to.eq(6)
}),
))
})

View File

@ -14,7 +14,7 @@ export class Category {
@VirtualColumn({
query: (alias) =>
`SELECT COUNT(*) FROM category WHERE id = ${alias}.id`,
`SELECT COUNT(*) FROM "category" WHERE "id" = ${alias}."id"`,
})
randomVirtualColumn: number

View File

@ -1,25 +1,40 @@
import "reflect-metadata"
import {
createTestingConnections,
closeTestingConnections,
reloadTestingDatabases,
} from "../../utils/test-utils"
import { DataSource } from "../../../src"
import { expect } from "chai"
import "reflect-metadata"
import { DataSource } from "../../../src"
import {
closeTestingConnections,
createTestingConnections,
} from "../../utils/test-utils"
import { DriverUtils } from "../../../src/driver/DriverUtils"
import { Category, Product } from "./entity"
describe("github issues > #10431 When requesting nested relations on foreign key primary entities, relation becomes empty entity rather than null", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [Category, Product],
schemaCreate: true,
dropSchema: true,
})),
)
beforeEach(() => reloadTestingDatabases(connections))
before(async () => {
connections = await createTestingConnections({
entities: [Category, Product],
schemaCreate: true,
dropSchema: true,
})
for (const connection of connections) {
// By default, MySQL uses backticks instead of quotes for identifiers
if (DriverUtils.isMySQLFamily(connection.driver)) {
const randomVirtualColumnMetadata = connection
.getMetadata(Category)
.columns.find(
(columnMetadata) =>
columnMetadata.propertyName ===
"randomVirtualColumn",
)!
randomVirtualColumnMetadata.query = (alias) =>
`SELECT COUNT(*) FROM \`category\` WHERE \`id\` = ${alias}.\`id\``
}
}
})
after(() => closeTestingConnections(connections))
it("should return [] when requested nested relations are empty on ManyToMany relation with @VirtualColumn definitions", () =>
@ -28,13 +43,16 @@ describe("github issues > #10431 When requesting nested relations on foreign key
const productRepo = connection.getRepository(Product)
const testProduct = new Product()
testProduct.name = "foo"
await productRepo.save(testProduct)
const foundProduct = await productRepo.findOne({
where: {
id: testProduct.id,
},
relations: { categories: true },
})
expect(foundProduct?.name).eq("foo")
expect(foundProduct?.categories).eql([])
}),

View File

@ -1,21 +0,0 @@
import {
ManyToOne,
Entity,
PrimaryColumn,
BaseEntity,
OneToMany,
} from "../../../../src"
import TimeSheet from "./TimeSheet"
import Company from "./Company"
@Entity({ name: "employees" })
export default class Employee extends BaseEntity {
@PrimaryColumn("varchar", { length: 50 })
name: string
@ManyToOne((type) => Company, (company) => company.employees)
company: Company
@OneToMany((type) => TimeSheet, (timesheet) => timesheet.employee)
timesheets: TimeSheet[]
}

View File

@ -1,27 +0,0 @@
import {
ManyToOne,
Entity,
BaseEntity,
PrimaryGeneratedColumn,
VirtualColumn,
} from "../../../../src"
import Activity from "./Activity"
import Employee from "./Employee"
@Entity({ name: "timesheets" })
export default class TimeSheet extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@VirtualColumn({
query: (alias) =>
`SELECT SUM("hours") FROM "activities" WHERE "timesheetId" = ${alias}.id`,
})
totalActvityHours: number
@ManyToOne((type) => Activity, (activity) => activity.timesheet)
activities: Activity[]
@ManyToOne((type) => Employee, (employee) => employee.timesheets)
employee: Employee
}