Skip to content

Commit

Permalink
feat: allow soft-deletion of orphaned relation rows using orphanedRow… (
Browse files Browse the repository at this point in the history
#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>
  • Loading branch information
oxeye-yuvalk and oxeye-yuvalk committed Dec 11, 2021
1 parent 2834729 commit cefddd9
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 5 deletions.
2 changes: 1 addition & 1 deletion docs/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/decorator/options/RelationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

}
2 changes: 1 addition & 1 deletion src/entity-schema/EntitySchemaRelationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
2 changes: 1 addition & 1 deletion src/metadata/RelationMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/persistence/subject-builder/OneToManySubjectBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
75 changes: 75 additions & 0 deletions test/github-issues/8408/delete-orphans.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

});
21 changes: 21 additions & 0 deletions test/github-issues/8408/entity/Category.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions test/github-issues/8408/entity/Post.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit cefddd9

Please sign in to comment.