Skip to content

Commit

Permalink
feat: relations: Orphaned row action (#7105)
Browse files Browse the repository at this point in the history
Co-authored-by: adenhertog <andrew.denhertog@gmail.com>

Co-authored-by: adenhertog <andrew.denhertog@gmail.com>
  • Loading branch information
nebkat and adenhertog committed Jan 12, 2021
1 parent a3faf49 commit efc2837
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 5 deletions.
5 changes: 5 additions & 0 deletions src/decorator/options/RelationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,9 @@ 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.
*/
orphanedRowAction?: "nullify" | "delete";

}
6 changes: 6 additions & 0 deletions src/metadata/RelationMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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.
*/
orphanedRowAction?: "nullify" | "delete";

/**
* If set to true then related objects are allowed to be inserted to the database.
*/
Expand Down Expand Up @@ -298,6 +303,7 @@ export class RelationMetadata {
this.deferrable = args.options.deferrable;
this.isEager = args.options.eager || false;
this.persistenceEnabled = args.options.persistence === false ? false : true;
this.orphanedRowAction = args.options.orphanedRowAction || "nullify";
this.isTreeParent = args.isTreeParent || false;
this.isTreeChildren = args.isTreeChildren || false;
this.type = args.type instanceof Function ? (args.type as () => any)() : args.type;
Expand Down
16 changes: 11 additions & 5 deletions src/persistence/subject-builder/OneToManySubjectBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,21 @@ export class OneToManySubjectBuilder {
const removedRelatedEntitySubject = new Subject({
metadata: relation.inverseEntityMetadata,
parentSubject: subject,
canBeUpdated: true,
identifier: removedRelatedEntityRelationId,
changeMaps: [{
});

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

this.subjects.push(removedRelatedEntitySubject);
});
}

}
}
73 changes: 73 additions & 0 deletions test/functional/persistence/delete-orphans/delete-orphans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 delete the orphaned Post from the database", async () => {
const postCount = await postRepository.count();
expect(postCount).to.equal(1);
});

it("should retain foreign keys on remaining Posts", async () => {
const postsWithoutForeignKeys = (await postRepository.find())
.filter(p => !p.categoryId);
expect(postsWithoutForeignKeys).to.have.lengthOf(0);
});
});

});
18 changes: 18 additions & 0 deletions test/functional/persistence/delete-orphans/entity/Category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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/delete-orphans/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;

}

0 comments on commit efc2837

Please sign in to comment.