feat: allow soft-deletion of orphaned relation rows using orphanedRow… (#8414)

* feat: Allow soft-deletion of orphaned relation rows using orphanedRowAction

* add deletedate column

* fix: create docs

* improve the tests

* remove .only in the tests file

Co-authored-by: oxeye-yuvalk <oxeye-yuvalk@oxeye.io>
This commit is contained in:
oxeye-yuvalk 2021-12-11 12:17:46 +02:00 committed by GitHub
parent 2834729e80
commit cefddd95c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 128 additions and 5 deletions

View File

@ -25,7 +25,7 @@ There are several options you can specify for relations:
* `onDelete: "RESTRICT"|"CASCADE"|"SET NULL"` - specifies how foreign key should behave when referenced object is deleted
* `primary: boolean` - Indicates whether this relation's column will be a primary column or not.
* `nullable: boolean` - Indicates whether this relation's column is nullable or not. By default it is nullable.
* `orphanedRowAction: "nullify" | "delete"` - When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
* `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).
## Cascades

View File

@ -74,6 +74,6 @@ export interface RelationOptions {
/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
*/
orphanedRowAction?: "nullify" | "delete";
orphanedRowAction?: "nullify" | "delete" | "soft-delete";
}

View File

@ -102,5 +102,5 @@ export interface EntitySchemaRelationOptions {
/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
*/
orphanedRowAction?: "nullify" | "delete";
orphanedRowAction?: "nullify" | "delete" | "soft-delete";
}

View File

@ -113,7 +113,7 @@ export class RelationMetadata {
/**
* When a child row is removed from its parent, determines if the child row should be orphaned (default) or deleted.
*/
orphanedRowAction?: "nullify" | "delete";
orphanedRowAction?: "nullify" | "delete" | "soft-delete";
/**
* If set to true then related objects are allowed to be inserted to the database.

View File

@ -184,7 +184,10 @@ export class OneToManySubjectBuilder {
} 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

@ -0,0 +1,75 @@
import "reflect-metadata";
import { Connection, Repository } from "../../../src/index";
import { reloadTestingDatabases, createTestingConnections, closeTestingConnections } from "../../utils/test-utils";
import { expect } from "chai";
import { Category } from "./entity/Category";
import { Post } from "./entity/Post";
describe("persistence > delete orphans", () => {
// -------------------------------------------------------------------------
// 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 Post is removed from a Category", () => {
let categoryRepository: Repository<Category>;
let postRepository: Repository<Post>;
let categoryId: number;
beforeEach(async () => {
await Promise.all(connections.map(async connection => {
categoryRepository = connection.getRepository(Category);
postRepository = connection.getRepository(Post);
}));
const categoryToInsert = await categoryRepository.save(new Category());
categoryToInsert.posts = [
new Post(),
new Post()
];
await categoryRepository.save(categoryToInsert);
categoryId = categoryToInsert.id;
const categoryToUpdate = (await categoryRepository.findOne(categoryId))!;
categoryToUpdate.posts = categoryToInsert.posts.filter(p => p.id === 1); // Keep the first post
await categoryRepository.save(categoryToUpdate);
});
it("should retain a Post on the Category", async () => {
const category = await categoryRepository.findOne(categoryId);
expect(category).not.to.be.undefined;
expect(category!.posts).to.have.lengthOf(1);
expect(category!.posts[0].id).to.equal(1);
});
it("should mark orphaned Post as soft-deleted", async () => {
const postCount = await postRepository.count();
expect(postCount).to.equal(1);
const postCountIncludeDeleted = await postRepository.count({withDeleted: true});
expect(postCountIncludeDeleted).to.equal(2);
});
it("should retain foreign keys on remaining Posts", async () => {
const postsWithoutForeignKeys = (await postRepository.find())
.filter(p => !p.categoryId);
expect(postsWithoutForeignKeys).to.have.lengthOf(0);
});
});
});

View File

@ -0,0 +1,21 @@
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";
import {DeleteDateColumn} from "../../../../src";
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => Post, post => post.category, {
cascade: true,
eager: true
})
posts: Post[];
@DeleteDateColumn()
deletedAt?: Date;
}

View File

@ -0,0 +1,24 @@
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";
import {DeleteDateColumn} from "../../../../src";
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
categoryId: string;
@ManyToOne(() => Category, category => category.posts, { orphanedRowAction: "soft-delete" })
@JoinColumn({ name: "categoryId" })
category: Category;
@DeleteDateColumn()
deletedAt?: Date;
}