mirror of
https://github.com/typeorm/typeorm.git
synced 2025-12-08 21:26:23 +00:00
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:
parent
749809a42a
commit
de15df14ed
@ -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
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
- `onDelete: "RESTRICT"|"CASCADE"|"SET NULL"` - 指定删除引用对象时外键的行为方式
|
||||
- `primary: boolean` - 指示此关系的列是否为主列。
|
||||
- `nullable: boolean` -指示此关系的列是否可为空。 默认情况下是可空。
|
||||
- `orphanedRowAction: "nullify" | "delete"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。
|
||||
- `orphanedRowAction: "nullify" | "delete" | "soft-delete" | "disable"` - 将子行从其父行中删除后,确定该子行是孤立的(默认值)还是删除的。
|
||||
|
||||
## 级联
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
@ -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
test/functional/persistence/orphanage/delete/entity/Post.ts
Normal file
21
test/functional/persistence/orphanage/delete/entity/Post.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
test/functional/persistence/orphanage/disable/entity/User.ts
Normal file
16
test/functional/persistence/orphanage/disable/entity/User.ts
Normal 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[]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user