diff --git a/package-lock.json b/package-lock.json index 7ef2d9b64c..70ec9f4b7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "dotenv": "^16.0.3", "glob": "^10.3.10", "mkdirp": "^2.1.3", - "reflect-metadata": "^0.2.1", "sha.js": "^2.4.11", "tslib": "^2.5.0", "uuid": "^9.0.0", @@ -105,6 +104,7 @@ "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", "redis": "^3.1.1 || ^4.0.0", + "reflect-metadata": "^0.1.14 || ^0.2.0", "sql.js": "^1.4.0", "sqlite3": "^5.0.3", "ts-node": "^10.7.0", @@ -11751,7 +11751,8 @@ "node_modules/reflect-metadata": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", - "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==" + "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==", + "peer": true }, "node_modules/regex-not": { "version": "1.0.2", diff --git a/src/decorator/options/JoinTableOptions.ts b/src/decorator/options/JoinTableOptions.ts index d2b8aa5c36..d4e9984bc8 100644 --- a/src/decorator/options/JoinTableOptions.ts +++ b/src/decorator/options/JoinTableOptions.ts @@ -10,6 +10,11 @@ export interface JoinTableOptions { */ name?: string + /** + * Specifies the entity (if any) that defines the join table. + */ + junctionEntity?: Function + /** * First column of the join table. */ diff --git a/src/decorator/relations/JoinTable.ts b/src/decorator/relations/JoinTable.ts index b609bce768..e8875e9627 100644 --- a/src/decorator/relations/JoinTable.ts +++ b/src/decorator/relations/JoinTable.ts @@ -36,6 +36,7 @@ export function JoinTable( ({} as JoinTableOptions | JoinTableMultipleColumnsOptions) getMetadataArgsStorage().joinTables.push({ target: object.constructor, + junctionEntity: (options as JoinTableOptions).junctionEntity, propertyName: propertyName, name: options.name, joinColumns: (options && (options as JoinTableOptions).joinColumn diff --git a/src/metadata-args/JoinTableMetadataArgs.ts b/src/metadata-args/JoinTableMetadataArgs.ts index 10d62cd74a..f98580e97f 100644 --- a/src/metadata-args/JoinTableMetadataArgs.ts +++ b/src/metadata-args/JoinTableMetadataArgs.ts @@ -9,6 +9,11 @@ export interface JoinTableMetadataArgs { */ readonly target: Function | string + /** + * Class that defines the junction table. + */ + readonly junctionEntity?: Function + /** * Class's property name to which this column is applied. */ diff --git a/src/metadata-builder/EntityMetadataBuilder.ts b/src/metadata-builder/EntityMetadataBuilder.ts index 4b76e4970c..3455cbb5cf 100644 --- a/src/metadata-builder/EntityMetadataBuilder.ts +++ b/src/metadata-builder/EntityMetadataBuilder.ts @@ -296,6 +296,7 @@ export class EntityMetadataBuilder { relation, joinTable, ) + relation.registerForeignKeys( ...junctionEntityMetadata.foreignKeys, ) @@ -313,6 +314,16 @@ export class EntityMetadataBuilder { junctionEntityMetadata, entityMetadatas, ) + + // Add deleteDateColumn to junctionEntityMetadata to support filtering soft-deleted join records + const entityMetadataForJunction = entityMetadatas.find( + (en) => + en.target === joinTable.junctionEntity && + en.isJunction === false, + ) + junctionEntityMetadata.deleteDateColumn = + entityMetadataForJunction?.deleteDateColumn + entityMetadatas.push(junctionEntityMetadata) }) }) diff --git a/src/query-builder/SelectQueryBuilder.ts b/src/query-builder/SelectQueryBuilder.ts index 1bb808addc..28af817285 100644 --- a/src/query-builder/SelectQueryBuilder.ts +++ b/src/query-builder/SelectQueryBuilder.ts @@ -2373,6 +2373,10 @@ export class SelectQueryBuilder let junctionCondition = "", destinationCondition = "" + const deleteDateColumn = + relation.junctionEntityMetadata?.deleteDateColumn + ?.propertyPath + if (relation.isOwning) { junctionCondition = relation.joinColumns .map((joinColumn) => { @@ -2387,6 +2391,11 @@ export class SelectQueryBuilder joinColumn.referencedColumn!.propertyPath ) }) + .concat( + deleteDateColumn + ? ` "${junctionAlias}"."${deleteDateColumn}" IS NULL` + : [], + ) .join(" AND ") destinationCondition = relation.inverseJoinColumns @@ -2419,6 +2428,11 @@ export class SelectQueryBuilder ) }, ) + .concat( + deleteDateColumn + ? ` "${junctionAlias}"."${deleteDateColumn}" IS NULL` + : [], + ) .join(" AND ") destinationCondition = relation diff --git a/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Category.ts b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Category.ts new file mode 100644 index 0000000000..27f5a09b81 --- /dev/null +++ b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Category.ts @@ -0,0 +1,28 @@ +import { ManyToMany } from "../../../../../../../src/decorator/relations/ManyToMany" +import { Entity } from "../../../../../../../src/decorator/entity/Entity" +import { PrimaryGeneratedColumn } from "../../../../../../../src/decorator/columns/PrimaryGeneratedColumn" +import { Column } from "../../../../../../../src/decorator/columns/Column" +import { JoinTable } from "../../../../../../../src/decorator/relations/JoinTable" +import { Post } from "./Post" +import { PostCategory } from "./PostCategory" + +@Entity() +export class Category { + @PrimaryGeneratedColumn() + id: number + + @Column() + name: string + + @ManyToMany((type) => Post, (post) => post.categories) + @JoinTable({ + name: "post_category", + synchronize: false, + junctionEntity: PostCategory, + joinColumn: { + name: "cid", + referencedColumnName: "id", + }, + }) + posts: Post[] +} diff --git a/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Post.ts b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Post.ts new file mode 100644 index 0000000000..22dbd12b91 --- /dev/null +++ b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/Post.ts @@ -0,0 +1,21 @@ +import { ManyToMany } from "../../../../../../../src/decorator/relations/ManyToMany" +import { Entity } from "../../../../../../../src/decorator/entity/Entity" +import { PrimaryGeneratedColumn } from "../../../../../../../src/decorator/columns/PrimaryGeneratedColumn" +import { Column } from "../../../../../../../src/decorator/columns/Column" +import { Category } from "./Category" +import { DeleteDateColumn } from "../../../../../../../src" + +@Entity() +export class Post { + @PrimaryGeneratedColumn() + id: number + + @Column() + title: string + + @ManyToMany((type) => Category, (category) => category.posts) + categories: Category[] + + @DeleteDateColumn() + deletedAt: Date | null +} diff --git a/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/PostCategory.ts b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/PostCategory.ts new file mode 100644 index 0000000000..89cb9f9584 --- /dev/null +++ b/test/functional/query-builder/relation-id/many-to-many/join-conditions/entity/PostCategory.ts @@ -0,0 +1,22 @@ +import { Entity } from "../../../../../../../src/decorator/entity/Entity" +import { PrimaryGeneratedColumn } from "../../../../../../../src/decorator/columns/PrimaryGeneratedColumn" +import { Column } from "../../../../../../../src/decorator/columns/Column" +import { DeleteDateColumn } from "../../../../../../../src" + +@Entity() +export class PostCategory { + @PrimaryGeneratedColumn() + id: number + + @Column({ nullable: true }) + name: string + + @Column() + postId: number + + @Column() + cid: number + + @DeleteDateColumn() + deletedAt: Date | null +} diff --git a/test/functional/query-builder/relation-id/many-to-many/join-conditions/join-conditions.ts b/test/functional/query-builder/relation-id/many-to-many/join-conditions/join-conditions.ts new file mode 100644 index 0000000000..1130c86e9d --- /dev/null +++ b/test/functional/query-builder/relation-id/many-to-many/join-conditions/join-conditions.ts @@ -0,0 +1,60 @@ +import "reflect-metadata" +import { expect } from "chai" +import { DataSource } from "../../../../../../src/data-source/DataSource" +import { + closeTestingConnections, + createTestingConnections, + reloadTestingDatabases, +} from "../../../../../utils/test-utils" +import { Post } from "./entity/Post" +import { Category } from "./entity/Category" +import { PostCategory } from "./entity/PostCategory" + +describe("query builder > relation-id > many-to-many > join-conditions", () => { + let connections: DataSource[] + before( + async () => + (connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })), + ) + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + it("should filter deleted join table rows when join condition is specified", () => + Promise.all( + connections.map(async (connection) => { + const post1 = new Post() + post1.title = "Post 1" + await connection.manager.save(post1) + + const category1 = new Category() + category1.name = "Category 1" + await connection.manager.save(category1) + + const category2 = new Category() + category2.name = "Category 2" + await connection.manager.save(category2) + + const pc1 = new PostCategory() + pc1.cid = category1.id + pc1.postId = post1.id + await connection.manager.save(pc1) + + const pc2 = new PostCategory() + pc2.cid = category2.id + pc2.postId = post1.id + pc2.deletedAt = new Date() + await connection.manager.save(pc2) + + const loadedPost = await connection.manager.find(Post, { + relations: { + categories: true, + }, + }) + + // Since 1 postCategory is deleted, this should only return 1 category + expect(loadedPost[0].categories.length).to.be.equal(1) + }), + )) +})