Skip to content

Commit

Permalink
feat: orphanedRowAction=disabled (rebase of PR 8285) (#8678)
Browse files Browse the repository at this point in the history
* updated implementation, using "disable" keyword

* rebase test restructure

* rebase orphanedRowAction tests with keyword "disabled"

* rename test suite files to reflect changed naming: skip -> disable
Simplify test suite to comply with postgres12

* Update tests to reflect 0.3 breaking changes

* prettied

Co-authored-by: Jannik <jannik@jannikmewes.de>
  • Loading branch information
jbjhjm and Jannik committed Sep 19, 2022
1 parent 749809a commit de15df1
Show file tree
Hide file tree
Showing 14 changed files with 228 additions and 89 deletions.
14 changes: 8 additions & 6 deletions docs/relations.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Relations

- [What are relations](#what-are-relations)
- [Relation options](#relation-options)
- [Cascades](#cascades)
- [`@JoinColumn` options](#joincolumn-options)
- [`@JoinTable` options](#jointable-options)
* [What are relations](#what-are-relations)
* [Relation options](#relation-options)
* [Cascades](#cascades)
* [Cascade Options](#cascade-options)
* [`@JoinColumn` options](#joincolumn-options)
* [`@JoinTable` options](#jointable-options)

## What are relations

Expand All @@ -24,7 +25,8 @@ There are several options you can specify for relations:
- `cascade: boolean | ("insert" | "update")[]` - If set to true, the related object will be inserted and updated in the database. You can also specify an array of [cascade options](#cascade-options).
- `onDelete: "RESTRICT"|"CASCADE"|"SET NULL"` - specifies how foreign key should behave when referenced object is deleted
- `nullable: boolean` - Indicates whether this relation's column is nullable or not. By default it is nullable.
- `orphanedRowAction: "nullify" | "delete" | "soft-delete"` - When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted (delete or soft delete).
- `orphanedRowAction: "nullify" | "delete" | "soft-delete" | disable` - When a parent is saved (cascading enabled) without a child/children that still exists in database, this will control what shall happen to them.
_delete_ will remove these children from database. _soft-delete_ will mark children as soft-deleted. _nullify_ will remove the relation key. _disable_ will keep the relation intact. To delete, one has to use their own repository.

## Cascades

Expand Down
2 changes: 1 addition & 1 deletion docs/zh_CN/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
- `onDelete: "RESTRICT"|"CASCADE"|"SET NULL"` - 指定删除引用对象时外键的行为方式
- `primary: boolean` - 指示此关系的列是否为主列。
- `nullable: boolean` -指示此关系的列是否可为空。 默认情况下是可空。
- `orphanedRowAction: "nullify" | "delete"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。
- `orphanedRowAction: "nullify" | "delete" | "soft-delete" | "disable"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。

## 级联

Expand Down
7 changes: 5 additions & 2 deletions src/decorator/options/RelationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export interface RelationOptions {
persistence?: boolean

/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
* When a parent is saved (with cascading but) without a child row that still exists in database, this will control what shall happen to them.
* delete will remove these rows from database.
* nullify will remove the relation key.
* disable will keep the relation intact. Removal of related item is only possible through its own repo.
*/
orphanedRowAction?: "nullify" | "delete" | "soft-delete"
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable"
}
6 changes: 4 additions & 2 deletions src/entity-schema/EntitySchemaRelationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ export interface EntitySchemaRelationOptions {
deferrable?: DeferrableType

/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
* When a parent is saved (with cascading but) without a child row that still exists in database, this will control what shall happen to them.
* delete will remove these rows from database. nullify will remove the relation key.
* skip will keep the relation intact. Removal of related item is only possible through its own repo.
*/
orphanedRowAction?: "nullify" | "delete" | "soft-delete"
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable"
}
6 changes: 4 additions & 2 deletions src/metadata/RelationMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ export class RelationMetadata {
persistenceEnabled: boolean = true

/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
* When a parent is saved (with cascading but) without a child row that still exists in database, this will control what shall happen to them.
* delete will remove these rows from database. nullify will remove the relation key.
* skip will keep the relation intact. Removal of related item is only possible through its own repo.
*/
orphanedRowAction?: "nullify" | "delete" | "soft-delete"
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable"

/**
* If set to true then related objects are allowed to be inserted to the database.
Expand Down
74 changes: 38 additions & 36 deletions src/persistence/subject-builder/OneToManySubjectBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,43 +178,45 @@ export class OneToManySubjectBuilder {
})

// find what related entities were added and what were removed based on difference between what we save and what database has
EntityMetadata.difference(
relatedEntityDatabaseRelationIds,
relatedPersistedEntityRelationIds,
).forEach((removedRelatedEntityRelationId) => {
// by example: removedRelatedEntityRelationId is category that was bind in the database before, but now its unbind

// todo: probably we can improve this in the future by finding entity with column those values,
// todo: maybe it was already in persistence process. This is possible due to unique requirements of join columns
// we create a new subject which operations will be executed in subject operation executor
const removedRelatedEntitySubject = new Subject({
metadata: relation.inverseEntityMetadata,
parentSubject: subject,
identifier: removedRelatedEntityRelationId,
})
if (relation.inverseRelation?.orphanedRowAction !== "disable") {
EntityMetadata.difference(
relatedEntityDatabaseRelationIds,
relatedPersistedEntityRelationIds,
).forEach((removedRelatedEntityRelationId) => {
// by example: removedRelatedEntityRelationId is category that was bind in the database before, but now its unbind

// todo: probably we can improve this in the future by finding entity with column those values,
// todo: maybe it was already in persistence process. This is possible due to unique requirements of join columns
// we create a new subject which operations will be executed in subject operation executor
const removedRelatedEntitySubject = new Subject({
metadata: relation.inverseEntityMetadata,
parentSubject: subject,
identifier: removedRelatedEntityRelationId,
})

if (
!relation.inverseRelation ||
relation.inverseRelation.orphanedRowAction === "nullify"
) {
removedRelatedEntitySubject.canBeUpdated = true
removedRelatedEntitySubject.changeMaps = [
{
relation: relation.inverseRelation!,
value: null,
},
]
} else if (
relation.inverseRelation.orphanedRowAction === "delete"
) {
removedRelatedEntitySubject.mustBeRemoved = true
} else if (
relation.inverseRelation.orphanedRowAction === "soft-delete"
) {
removedRelatedEntitySubject.canBeSoftRemoved = true
}
if (
!relation.inverseRelation ||
relation.inverseRelation.orphanedRowAction === "nullify"
) {
removedRelatedEntitySubject.canBeUpdated = true
removedRelatedEntitySubject.changeMaps = [
{
relation: relation.inverseRelation!,
value: null,
},
]
} else if (
relation.inverseRelation.orphanedRowAction === "delete"
) {
removedRelatedEntitySubject.mustBeRemoved = true
} else if (
relation.inverseRelation.orphanedRowAction === "soft-delete"
) {
removedRelatedEntitySubject.canBeSoftRemoved = true
}

this.subjects.push(removedRelatedEntitySubject)
})
this.subjects.push(removedRelatedEntitySubject)
})
}
}
}
16 changes: 0 additions & 16 deletions test/functional/persistence/delete-orphans/entity/Category.ts

This file was deleted.

21 changes: 0 additions & 21 deletions test/functional/persistence/delete-orphans/entity/Post.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import "reflect-metadata"
import { DataSource, Repository } from "../../../../src/index"
import { DataSource, Repository } from "../../../../../src/index"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
} from "../../../../utils/test-utils"
import { expect } from "chai"
import { Category } from "./entity/Category"
import { Post } from "./entity/Post"

describe("persistence > delete orphans", () => {
describe("persistence > orphanage > delete", () => {
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions test/functional/persistence/orphanage/delete/entity/Category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Entity } from "../../../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Post } from "./Post"
import { OneToMany } from "../../../../../../src/decorator/relations/OneToMany"

@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number

@OneToMany(() => Post, (post) => post.category, {
cascade: ["insert"],
eager: true,
})
posts: Post[]
}
21 changes: 21 additions & 0 deletions test/functional/persistence/orphanage/delete/entity/Post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Category } from "./Category"
import { Entity } from "../../../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Column } from "../../../../../../src/decorator/columns/Column"
import { ManyToOne } from "../../../../../../src/decorator/relations/ManyToOne"
import { JoinColumn } from "../../../../../../src/decorator/relations/JoinColumn"

@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number

@Column()
categoryId: string

@ManyToOne(() => Category, (category) => category.posts, {
orphanedRowAction: "delete",
})
@JoinColumn({ name: "categoryId" })
category: Category
}
84 changes: 84 additions & 0 deletions test/functional/persistence/orphanage/disable/disable-orphanage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import "reflect-metadata"
import { Connection, Repository } from "../../../../../src/index"
import {
reloadTestingDatabases,
createTestingConnections,
closeTestingConnections,
} from "../../../../utils/test-utils"
import { expect } from "chai"
import { User } from "./entity/User"
import { Setting } from "./entity/Setting"

describe("persistence > orphanage > disable", () => {
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------

// connect to db
let connections: Connection[] = []

before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))

// -------------------------------------------------------------------------
// Specifications
// -------------------------------------------------------------------------

describe("when a User is updated without all settings being loaded...", () => {
let userRepo: Repository<User>
let settingRepo: Repository<Setting>
let userId: number

beforeEach(async () => {
await Promise.all(
connections.map(async (connection) => {
userRepo = connection.getRepository(User)
settingRepo = connection.getRepository(Setting)
}),
)

const user = await userRepo.save(new User())
user.settings = [
new Setting("foo"),
new Setting("bar"),
new Setting("moo"),
]

await userRepo.save(user)
userId = user.id

const userToUpdate = (await userRepo.findOneBy({ id: userId }))!
userToUpdate.settings = [
// untouched setting
userToUpdate.settings[0],
// updated setting
{ ...userToUpdate.settings[1], data: "bar_updated" },
// skipped setting
// new Setting("moo"),
// new setting
new Setting("cow"),
]

await userRepo.save(userToUpdate)
})

it("should not delete setting with orphanedRowAction=disabed", async () => {
const user = await userRepo.findOneBy({ id: userId })
expect(user).not.to.be.undefined
expect(user!.settings).to.have.lengthOf(4)
})

it("should not orphane any Settings", async () => {
const itemsWithoutForeignKeys = (await settingRepo.find()).filter(
(p) => !p.userId,
)
expect(itemsWithoutForeignKeys).to.have.lengthOf(0)
})
})
})
28 changes: 28 additions & 0 deletions test/functional/persistence/orphanage/disable/entity/Setting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { User } from "./User"
import { Entity } from "../../../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Column } from "../../../../../../src/decorator/columns/Column"
import { ManyToOne } from "../../../../../../src/decorator/relations/ManyToOne"
import { JoinColumn } from "../../../../../../src/decorator/relations/JoinColumn"

@Entity()
export class Setting {
@PrimaryGeneratedColumn()
id: number

@Column()
data: string

@Column()
userId: string

@ManyToOne(() => User, (user) => user.settings, {
orphanedRowAction: "disable",
})
@JoinColumn({ name: "userId" })
user: User

constructor(data: string) {
this.data = data
}
}
16 changes: 16 additions & 0 deletions test/functional/persistence/orphanage/disable/entity/User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Entity } from "../../../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Setting } from "./Setting"
import { OneToMany } from "../../../../../../src/decorator/relations/OneToMany"

@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number

@OneToMany(() => Setting, (setting) => setting.user, {
cascade: true,
eager: true,
})
settings: Setting[]
}

0 comments on commit de15df1

Please sign in to comment.