feat: orphanedRowAction=disabled (rebase of PR 8285) (#8678)

* 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>
This commit is contained in:
Jannik Mewes 2022-09-19 18:22:57 +02:00 committed by GitHub
parent 749809a42a
commit de15df14ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 228 additions and 89 deletions

View File

@ -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
@ -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

View File

@ -25,7 +25,7 @@
- `onDelete: "RESTRICT"|"CASCADE"|"SET NULL"` - 指定删除引用对象时外键的行为方式
- `primary: boolean` - 指示此关系的列是否为主列。
- `nullable: boolean` -指示此关系的列是否可为空。 默认情况下是可空。
- `orphanedRowAction: "nullify" | "delete"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。
- `orphanedRowAction: "nullify" | "delete" | "soft-delete" | "disable"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。
## 级联

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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.

View File

@ -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
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,
// 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
}
this.subjects.push(removedRelatedEntitySubject)
})
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)
})
}
}
}

View File

@ -1,16 +0,0 @@
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[]
}

View File

@ -1,21 +0,0 @@
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
}

View File

@ -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
// -------------------------------------------------------------------------

View File

@ -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[]
}

View File

@ -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
}

View File

@ -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)
})
})
})

View File

@ -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
}
}

View File

@ -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[]
}