feat: nullable embedded entities (#10289)

* feat: nullable embedded entities

* fix: ignore embedded columns if not selected

* test: update tests with changed behaviour

* test: replace integer with varchar for cockroachdb

* docs: add documentation

* fix: nested embedded entities

* test: update nested embedded entities behaviour
This commit is contained in:
Andrea Mouraud 2024-01-02 08:29:41 +01:00 committed by GitHub
parent 5c28154cbe
commit e67d704138
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 160 additions and 18 deletions

View File

@ -165,3 +165,26 @@ All columns defined in the `Name` entity will be merged into `user`, `employee`
This way code duplication in the entity classes is reduced.
You can use as many columns (or relations) in embedded classes as you need.
You even can have nested embedded columns inside embedded classes.
## Nullable embedded entities
When all its columns values are `null`, the embedded entity itself is considered `null`.
Saving the embedded entity as `null` will set all its columns to `null`
```typescript
export class Name {
@Column({ nullable: true })
first: string | null
@Column({ nullable: true })
last: string | null
}
const student = new Student()
student.faculty = 'Faculty'
student.name = { first: null, last: null } // same as student.name = null
await dataSource.manager.save(student)
// this will return the student name as `null`
await dataSource.getRepository(Student).findOne()
```

View File

@ -800,6 +800,9 @@ export class ColumnMetadata {
} else {
value = embeddedObject[this.propertyName]
}
} else if (embeddedObject === null) {
// when embedded object is null, set all its properties to null
value = null
}
} else {
// no embeds - no problems. Simply return column name by property name of the entity

View File

@ -757,6 +757,10 @@ export class EntityMetadata {
const relation = this.findRelationWithPropertyPath(propertyPath)
if (relation && relation.joinColumns) return relation.joinColumns
// try to find a relation with a property path being an embedded entity
const embedded = this.findEmbeddedWithPropertyPath(propertyPath)
if (embedded) return embedded.columns
return []
}

View File

@ -251,9 +251,41 @@ export class RawSqlResultsToEntityTransformer {
// we don't mark it as has data because if we will have all nulls in our object - we don't need such object
hasData = true
})
// Set all embedded column values to null if all their child columns are null
if (entity) {
metadata.embeddeds.forEach((embedded) => {
if (embedded.propertyName in entity) {
entity[embedded.propertyName] = this.deeplyNullify(
entity[embedded.propertyName],
)
}
})
}
return hasData
}
/**
* Returns whether an object is an iterrable
* This is useful when trying to avoid objects such as Dates
*/
private isIterrableObject(obj: any): obj is object {
const prototype = Object.prototype.toString.call(obj)
return prototype === "[object Object]" || prototype === "[object Array]"
}
/**
* Deeply nullify an object if all its properties values are null or undefined
*/
private deeplyNullify<T>(obj: T): T | null {
if (!this.isIterrableObject(obj)) return obj
for (const key in obj) {
obj[key] = this.deeplyNullify(obj[key] as any)
}
const nullify = Object.values(obj).every((value) => value == null)
return nullify ? null : obj
}
/**
* Transforms joined entities in the given raw results by a given alias and stores to the given (parent) entity
*/

View File

@ -33,10 +33,7 @@ describe("github issues > #300 support of embeddeds that are not set", () => {
expect(loadedRace).to.exist
expect(loadedRace!.id).to.exist
loadedRace!.name.should.be.equal("National Race")
expect(loadedRace!.duration).to.exist
expect(loadedRace!.duration.minutes).to.be.null
expect(loadedRace!.duration.hours).to.be.null
expect(loadedRace!.duration.days).to.be.null
expect(loadedRace!.duration).to.be.null
}),
))
})

View File

@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
BaseEntity,
} from "../../../../src"
export class NestedEmbedded {
@Column({ type: "varchar", nullable: true })
c: string | null
}
export class Embedded {
@Column({ type: "varchar", nullable: true })
a: string | null
@Column({ type: "varchar", nullable: true })
b: string | null
@Column(() => NestedEmbedded)
nested: NestedEmbedded | null
}
@Entity()
export class Test extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column(() => Embedded)
embedded: Embedded | null
}

View File

@ -0,0 +1,64 @@
import "reflect-metadata"
import { expect } from "chai"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../utils/test-utils"
import { DataSource } from "../../../src/data-source/DataSource"
import { Test } from "./entity/Test"
describe("github issues > #3913 Cannot set embedded entity to null", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
cache: {
alwaysEnabled: true,
},
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))
it("should set all embedded columns to null when entity is set to null", () =>
Promise.all(
connections.map(async (connection) => {
const qb = connection.createQueryBuilder(Test, "s")
const t = new Test()
t.embedded = { a: "a", b: "b", nested: { c: "c" } }
const { id } = await connection.manager.save(t)
const t1 = await qb.getOne()
expect(t1!.embedded).to.deep.equal({
a: "a",
b: "b",
nested: { c: "c" },
})
t!.embedded = null
t!.id = id
await connection.manager.save(t)
const t2 = await connection
.createQueryBuilder(Test, "s")
.getOne()
expect(t2!.embedded).to.equal(null)
await qb
.update()
.set({ embedded: { a: "a", b: null } })
.execute()
const t3 = await qb.getOne()
expect(t3!.embedded).to.deep.equal({
a: "a",
b: null,
nested: null,
})
await qb.update().set({ embedded: null }).execute()
const t4 = await qb.getOne()
expect(t4!.embedded).to.equal(null)
}),
))
})

View File

@ -108,13 +108,7 @@ describe("github issues > #762 Nullable @Embedded inside @Embedded", () => {
loadedFoo3!.should.be.eql({
id: 3,
name: "Apple3",
metadata: {
bar: null,
child: {
something: null,
somethingElse: null,
},
},
metadata: null,
})
}),
))
@ -132,13 +126,7 @@ describe("github issues > #762 Nullable @Embedded inside @Embedded", () => {
loadedFoo!.should.be.eql({
id: 1,
name: "Orange",
metadata: {
bar: null,
child: {
something: null,
somethingElse: null,
},
},
metadata: null,
})
}),
))