Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support nullable embedded entities #10829

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
37 changes: 37 additions & 0 deletions docs/embedded-entities.md
Expand Up @@ -165,3 +165,40 @@ 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 an embedded entity is stored as `null`, and the column is `nullable`, it will be returned as `null` when read from the database.

```typescript
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"
import { Name } from "./Name"

export class Name {
@Column()
first: string

@Column()
last: string
}

@Entity()
export class Student {
@PrimaryGeneratedColumn()
id: string

@Column(() => Name, { nullable: true })
name: Name | null

@Column()
faculty: string
}

const student = new Student()
student.faculty = 'Faculty'
student.name = null
await dataSource.manager.save(student)

// this will return the student name as `null`
await dataSource.getRepository(Student).findOne()
```
4 changes: 4 additions & 0 deletions src/decorator/columns/Column.ts
Expand Up @@ -182,6 +182,10 @@ export function Column(
reflectMetadataType === Array || options.array === true,
prefix:
options.prefix !== undefined ? options.prefix : undefined,
nullable:
options.nullable !== undefined
? options.nullable
: undefined,
type: typeOrOptions as (type?: any) => Function,
} as EmbeddedMetadataArgs)
} else {
Expand Down
5 changes: 5 additions & 0 deletions src/decorator/options/ColumnEmbeddedOptions.ts
Expand Up @@ -14,4 +14,9 @@ export interface ColumnEmbeddedOptions {
* This option works only in mongodb.
*/
array?: boolean

/**
* Indicates if this embedded is nullable.
*/
nullable?: boolean
}
5 changes: 5 additions & 0 deletions src/entity-schema/EntitySchemaEmbeddedColumnOptions.ts
Expand Up @@ -18,4 +18,9 @@ export class EntitySchemaEmbeddedColumnOptions {
* This option works only in mongodb.
*/
array?: boolean

/**
* Indicates if this embedded is nullable.
*/
nullable?: boolean
}
1 change: 1 addition & 0 deletions src/entity-schema/EntitySchemaTransformer.ts
Expand Up @@ -348,6 +348,7 @@ export class EntitySchemaTransformer {
target: options.target || options.name,
propertyName: columnName,
isArray: embeddedOptions.array === true,
nullable: embeddedOptions.nullable === true,
prefix:
embeddedOptions.prefix !== undefined
? embeddedOptions.prefix
Expand Down
5 changes: 5 additions & 0 deletions src/metadata-args/EmbeddedMetadataArgs.ts
Expand Up @@ -23,6 +23,11 @@ export interface EmbeddedMetadataArgs {
*/
prefix?: string | boolean

/**
* Indicates if this embedded is nullable.
*/
nullable: boolean

/**
* Type of the class to be embedded.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/metadata/EmbeddedMetadata.ts
Expand Up @@ -98,6 +98,11 @@ export class EmbeddedMetadata {
*/
isArray: boolean = false

/**
* Indicates if this embedded is nullable.
*/
nullable: boolean = false

/**
* Prefix of the embedded, used instead of propertyName.
* If set to empty string or false, then prefix is not set at all.
Expand Down Expand Up @@ -185,6 +190,7 @@ export class EmbeddedMetadata {
this.propertyName = options.args.propertyName
this.customPrefix = options.args.prefix
this.isArray = options.args.isArray
this.nullable = options.args.nullable
}

// ---------------------------------------------------------------------
Expand Down
12 changes: 9 additions & 3 deletions src/query-builder/transformer/DocumentToEntityTransformer.ts
Expand Up @@ -107,9 +107,15 @@ export class DocumentToEntityTransformer {
embeddeds: EmbeddedMetadata[],
) => {
embeddeds.forEach((embedded) => {
if (!document[embedded.prefix]) return

if (embedded.isArray) {
if (document[embedded.prefix] === undefined) return
if (document[embedded.prefix] === null && !embedded.nullable)
return

if (embedded.nullable && document[embedded.prefix] === null) {
// We allow this to be set to null in the case it's null in the database response
// It's processed first to ensure other embedded options can still remain nullable, like a nullable array
entity[embedded.prefix] = document[embedded.prefix]
} else if (embedded.isArray) {
entity[embedded.propertyName] = (
document[embedded.prefix] as any[]
).map((subValue: any, index: number) => {
Expand Down
13 changes: 13 additions & 0 deletions src/query-builder/transformer/RawSqlResultsToEntityTransformer.ts
Expand Up @@ -250,6 +250,19 @@ export class RawSqlResultsToEntityTransformer {
if (value !== null)
// 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 embedded column values to null if they are both null and nullable
if (entity) {
metadata.embeddeds.forEach((embedded) => {
if (entity[embedded.propertyName] === undefined) return
if (
entity[embedded.propertyName] === null &&
!embedded.nullable
)
return
entity[embedded.propertyName] = null
})
}
})
return hasData
}
Expand Down
15 changes: 15 additions & 0 deletions test/github-issues/3913/entity/TestMongo.ts
@@ -0,0 +1,15 @@
import { ObjectId } from "mongodb"
import { BaseEntity, Column, Entity, ObjectIdColumn } from "../../../../src"

export class Embedded {
a: string
}

@Entity()
export class TestMongo extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId

@Column(() => Embedded, { nullable: true })
embedded: Embedded | null
}
19 changes: 19 additions & 0 deletions test/github-issues/3913/entity/TestSQL.ts
@@ -0,0 +1,19 @@
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
} from "../../../../src"

export class Embedded {
a: string
}

@Entity()
export class TestSQL extends BaseEntity {
@PrimaryGeneratedColumn()
id: number

@Column(() => Embedded, { nullable: true })
embedded: Embedded | null
}
81 changes: 81 additions & 0 deletions test/github-issues/3913/issue-3913.ts
@@ -0,0 +1,81 @@
import "reflect-metadata"
import { expect } from "chai"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../utils/test-utils"
import { DataSource } from "../../../src/data-source/DataSource"
import { TestMongo } from "./entity/TestMongo"
import { TestSQL } from "./entity/TestSQL"

// Test for MongoDB is split out due to the ObjectId database model that isn't supported in other providers
describe("github issues > #3913 Cannnot set embedded entity to null | MongoDB", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/TestMongo{.js,.ts}"],
cache: {
alwaysEnabled: true,
},
enabledDrivers: ["mongodb"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))

it("should set the embedded entity to null in the database for mongodb", () =>
Promise.all(
connections.map(async (connection) => {
if (connection.options.type !== "mongodb") return // Only run this test for mongodb
const test = new TestMongo()
test.embedded = null

await connection.manager.save(test)

const loadedTest = await connection.manager.findOne(TestMongo, {
where: { _id: test._id },
})
expect(loadedTest).to.be.eql({
_id: test._id,
embedded: null,
})
}),
))
})

describe("github issues > #3913 Cannnot set embedded entity to null", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/TestSQL{.js,.ts}"],
cache: {
alwaysEnabled: true,
},
enabledDrivers: ["mysql", "mariadb", "postgres", "sqlite"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))

it("should set the embedded entity to null in the database for non mongodb", () =>
Promise.all(
connections.map(async (connection) => {
if (connection.options.type === "mongodb") return // Don't run this test for mongodb
const test = new TestSQL()
test.embedded = null

await connection.manager.save(test)

const loadedTest = await connection.manager.findOne(TestSQL, {
where: { id: test.id },
})
expect(loadedTest).to.be.eql({
id: 1,
embedded: null,
})
}),
))
})