diff --git a/docs/tree-entities.md b/docs/tree-entities.md index a4127ff4c7..a674723f27 100644 --- a/docs/tree-entities.md +++ b/docs/tree-entities.md @@ -11,8 +11,8 @@ To learn more about hierarchy table take a look at [this awesome presentation by ## Adjacency list -Adjacency list is a simple model with self-referencing. -The benefit of this approach is simplicity, +Adjacency list is a simple model with self-referencing. +The benefit of this approach is simplicity, drawback is that you can't load big trees in all at once because of join limitations. To learn more about the benefits and use of Adjacency Lists look at [this article by Matthew Schinckel](http://schinckel.net/2014/09/13/long-live-adjacency-lists/). Example: @@ -38,7 +38,7 @@ export class Category { @OneToMany(type => Category, category => category.parent) children: Category[]; } - + ``` ## Nested set @@ -98,7 +98,7 @@ export class Category { ## Closure table -Closure table stores relations between parent and child in a separate table in a special way. +Closure table stores relations between parent and child in a separate table in a special way. It's efficient in both reads and writes. Example: @@ -123,6 +123,16 @@ export class Category { } ``` +You can specify closure table name and / or closure table columns names by setting optional parameter `options` into `@Tree("closure-table", options)`. `ancestorColumnName` and `descandantColumnName` are callback functions, which receive primary column's metadata and return column's name. + +```ts +@Tree("closure-table", { + closureTableName: "category_closure", + ancestorColumnName: (column) => "ancestor_" + column.propertyName, + descendantColumnName: (column) => "descendant_" + column.propertyName, +}) +``` + ### Note: Updating or removing a component's parent has not been implemented yet ([see this issue](https://github.com/typeorm/typeorm/issues/2032)). The closure table will need to be explicitly updated to do either of these operations. diff --git a/src/decorator/tree/Tree.ts b/src/decorator/tree/Tree.ts index 79da2fc96b..8e39a04cfd 100644 --- a/src/decorator/tree/Tree.ts +++ b/src/decorator/tree/Tree.ts @@ -1,6 +1,7 @@ import {getMetadataArgsStorage} from "../../"; import {TreeMetadataArgs} from "../../metadata-args/TreeMetadataArgs"; import {TreeType} from "../../metadata/types/TreeTypes"; +import {ClosureTreeOptions} from "../../metadata/types/ClosureTreeOptions"; /** * Marks entity to work like a tree. @@ -8,12 +9,13 @@ import {TreeType} from "../../metadata/types/TreeTypes"; * @TreeParent decorator must be used in tree entities. * TreeRepository can be used to manipulate with tree entities. */ -export function Tree(type: TreeType): ClassDecorator { +export function Tree(type: TreeType, options?: ClosureTreeOptions): ClassDecorator { return function (target: Function) { getMetadataArgsStorage().trees.push({ target: target, - type: type + type: type, + options: type === "closure-table" ? options : undefined } as TreeMetadataArgs); }; } diff --git a/src/metadata-args/TreeMetadataArgs.ts b/src/metadata-args/TreeMetadataArgs.ts index 021cd48bda..bd1897db37 100644 --- a/src/metadata-args/TreeMetadataArgs.ts +++ b/src/metadata-args/TreeMetadataArgs.ts @@ -1,4 +1,5 @@ import {TreeType} from "../metadata/types/TreeTypes"; +import {ClosureTreeOptions} from "../metadata/types/ClosureTreeOptions"; /** * Stores metadata collected for Tree entities. @@ -15,4 +16,8 @@ export interface TreeMetadataArgs { */ type: TreeType; + /** + * Tree options + */ + options?: ClosureTreeOptions; } diff --git a/src/metadata-builder/ClosureJunctionEntityMetadataBuilder.ts b/src/metadata-builder/ClosureJunctionEntityMetadataBuilder.ts index 36250479a0..6fedeb5091 100644 --- a/src/metadata-builder/ClosureJunctionEntityMetadataBuilder.ts +++ b/src/metadata-builder/ClosureJunctionEntityMetadataBuilder.ts @@ -32,7 +32,7 @@ export class ClosureJunctionEntityMetadataBuilder { connection: this.connection, args: { target: "", - name: parentClosureEntityMetadata.tableNameWithoutPrefix, + name: parentClosureEntityMetadata.treeOptions && parentClosureEntityMetadata.treeOptions.closureTableName ? parentClosureEntityMetadata.treeOptions.closureTableName : parentClosureEntityMetadata.tableNameWithoutPrefix, type: "closure-junction" } }); @@ -48,7 +48,7 @@ export class ClosureJunctionEntityMetadataBuilder { args: { target: "", mode: "virtual", - propertyName: primaryColumn.propertyName + "_ancestor", // todo: naming strategy + propertyName: parentClosureEntityMetadata.treeOptions && parentClosureEntityMetadata.treeOptions.ancestorColumnName ? parentClosureEntityMetadata.treeOptions.ancestorColumnName(primaryColumn) : primaryColumn.propertyName + "_ancestor", options: { primary: true, length: primaryColumn.length, @@ -64,7 +64,7 @@ export class ClosureJunctionEntityMetadataBuilder { args: { target: "", mode: "virtual", - propertyName: primaryColumn.propertyName + "_descendant", + propertyName: parentClosureEntityMetadata.treeOptions && parentClosureEntityMetadata.treeOptions.descendantColumnName ? parentClosureEntityMetadata.treeOptions.descendantColumnName(primaryColumn) : primaryColumn.propertyName + "_descendant", options: { primary: true, length: primaryColumn.length, diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 6975928d3e..a2246ada94 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -26,6 +26,7 @@ import {RelationMetadata} from "./RelationMetadata"; import {TableType} from "./types/TableTypes"; import {TreeType} from "./types/TreeTypes"; import {UniqueMetadata} from "./UniqueMetadata"; +import {ClosureTreeOptions} from "./types/ClosureTreeOptions"; /** * Contains all entity metadata. @@ -191,6 +192,11 @@ export class EntityMetadata { */ treeType?: TreeType; + /** + * Indicates if this entity is a tree, what options of tree it has. + */ + treeOptions?: ClosureTreeOptions; + /** * Checks if this table is a junction table of the closure table. * This type is for tables that contain junction metadata of the closure tables. @@ -503,6 +509,7 @@ export class EntityMetadata { this.inheritanceTree = options.inheritanceTree || []; this.inheritancePattern = options.inheritancePattern; this.treeType = options.tableTree ? options.tableTree.type : undefined; + this.treeOptions = options.tableTree ? options.tableTree.options : undefined; this.parentClosureEntityMetadata = options.parentClosureEntityMetadata!; this.tableMetadataArgs = options.args; this.target = this.tableMetadataArgs.target; diff --git a/src/metadata/types/ClosureTreeOptions.ts b/src/metadata/types/ClosureTreeOptions.ts new file mode 100644 index 0000000000..444924c134 --- /dev/null +++ b/src/metadata/types/ClosureTreeOptions.ts @@ -0,0 +1,11 @@ +/** + * Tree type. + * Specifies what table pattern will be used for the tree entity. + */ +import {ColumnMetadata} from "../ColumnMetadata"; + +export interface ClosureTreeOptions { + closureTableName?: string, + ancestorColumnName?: (column: ColumnMetadata) => string, + descendantColumnName?: (column: ColumnMetadata) => string, +} diff --git a/src/persistence/tree/ClosureSubjectExecutor.ts b/src/persistence/tree/ClosureSubjectExecutor.ts index 0a88e34516..2a9d7021f5 100644 --- a/src/persistence/tree/ClosureSubjectExecutor.ts +++ b/src/persistence/tree/ClosureSubjectExecutor.ts @@ -22,7 +22,7 @@ export class ClosureSubjectExecutor { /** * Removes all children of the given subject's entity. - async deleteChildrenOf(subject: Subject) { + async deleteChildrenOf(subject: Subject) { // const relationValue = subject.metadata.treeParentRelation.getEntityValue(subject.databaseEntity); // console.log("relationValue: ", relationValue); // this.queryRunner.manager @@ -75,14 +75,14 @@ export class ClosureSubjectExecutor { firstQueryParameters.push(childEntityIdValues[index]); return this.queryRunner.connection.driver.createParameter("child_entity_" + column.databaseName, firstQueryParameters.length - 1); }); - const whereCondition = subject.metadata.primaryColumns.map(column => { - const columnName = escape(column.databaseName + "_descendant"); - const parentId = column.getEntityValue(parent); + const whereCondition = subject.metadata.closureJunctionTable.descendantColumns.map(column => { + const columnName = escape(column.databaseName); + const parentId = column.referencedColumn!.getEntityValue(parent); if (!parentId) throw new CannotAttachTreeChildrenEntityError(subject.metadata.name); firstQueryParameters.push(parentId); - const parameterName = this.queryRunner.connection.driver.createParameter("parent_entity_" + column.databaseName, firstQueryParameters.length - 1); + const parameterName = this.queryRunner.connection.driver.createParameter("parent_entity_" + column.referencedColumn!.databaseName, firstQueryParameters.length - 1); return columnName + " = " + parameterName; }).join(", "); @@ -109,4 +109,4 @@ export class ClosureSubjectExecutor { } -} \ No newline at end of file +} diff --git a/test/github-issues/7068/entity/Category.ts b/test/github-issues/7068/entity/Category.ts new file mode 100644 index 0000000000..dfe54addc8 --- /dev/null +++ b/test/github-issues/7068/entity/Category.ts @@ -0,0 +1,30 @@ +import {PrimaryGeneratedColumn} from "../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../src/decorator/columns/Column"; +import {TreeParent} from "../../../../src/decorator/tree/TreeParent"; +import {TreeChildren} from "../../../../src/decorator/tree/TreeChildren"; +import {Entity} from "../../../../src/decorator/entity/Entity"; +import {Tree} from "../../../../src/decorator/tree/Tree"; + +@Entity() +@Tree("closure-table", { + closureTableName: "category_xyz_closure", + ancestorColumnName: (column) => "ancestor_xyz_" + column.propertyName, + descendantColumnName: (column) => "descendant_xyz_" + column.propertyName, +}) +export class Category { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @TreeParent() + parentCategory: Category; + + @TreeChildren({cascade: true}) + childCategories: Category[]; + + // @TreeLevelColumn() + // level: number; +} diff --git a/test/github-issues/7068/issue-7068.ts b/test/github-issues/7068/issue-7068.ts new file mode 100644 index 0000000000..f961c284e1 --- /dev/null +++ b/test/github-issues/7068/issue-7068.ts @@ -0,0 +1,190 @@ +import "reflect-metadata"; +import {Category} from "./entity/Category"; +import {Connection} from "../../../src/connection/Connection"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../utils/test-utils"; + +describe.only("github issues > #7068", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [Category] + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("categories should be attached via parent and saved properly", () => Promise.all(connections.map(async connection => { + const categoryRepository = connection.getTreeRepository(Category); + + const a1 = new Category(); + a1.name = "a1"; + await categoryRepository.save(a1); + + const a11 = new Category(); + a11.name = "a11"; + a11.parentCategory = a1; + await categoryRepository.save(a11); + + const a12 = new Category(); + a12.name = "a12"; + a12.parentCategory = a1; + await categoryRepository.save(a12); + + const rootCategories = await categoryRepository.findRoots(); + rootCategories.should.be.eql([{ + id: 1, + name: "a1" + }]); + + const a11Parent = await categoryRepository.findAncestors(a11); + a11Parent.length.should.be.equal(2); + a11Parent.should.deep.include({ id: 1, name: "a1" }); + a11Parent.should.deep.include({ id: 2, name: "a11" }); + + const a1Children = await categoryRepository.findDescendants(a1); + a1Children.length.should.be.equal(3); + a1Children.should.deep.include({ id: 1, name: "a1" }); + a1Children.should.deep.include({ id: 2, name: "a11" }); + a1Children.should.deep.include({ id: 3, name: "a12" }); + }))); + + it("categories should be attached via children and saved properly", () => Promise.all(connections.map(async connection => { + const categoryRepository = connection.getTreeRepository(Category); + + const a1 = new Category(); + a1.name = "a1"; + await categoryRepository.save(a1); + + const a11 = new Category(); + a11.name = "a11"; + + const a12 = new Category(); + a12.name = "a12"; + + a1.childCategories = [a11, a12]; + await categoryRepository.save(a1); + + const rootCategories = await categoryRepository.findRoots(); + rootCategories.should.be.eql([{ + id: 1, + name: "a1" + }]); + + const a11Parent = await categoryRepository.findAncestors(a11); + a11Parent.length.should.be.equal(2); + a11Parent.should.deep.include({ id: 1, name: "a1" }); + a11Parent.should.deep.include({ id: 2, name: "a11" }); + + const a1Children = await categoryRepository.findDescendants(a1); + a1Children.length.should.be.equal(3); + a1Children.should.deep.include({ id: 1, name: "a1" }); + a1Children.should.deep.include({ id: 2, name: "a11" }); + a1Children.should.deep.include({ id: 3, name: "a12" }); + }))); + + it("categories should be attached via children and saved properly and everything must be saved in cascades", () => Promise.all(connections.map(async connection => { + const categoryRepository = connection.getTreeRepository(Category); + + const a1 = new Category(); + a1.name = "a1"; + + const a11 = new Category(); + a11.name = "a11"; + + const a12 = new Category(); + a12.name = "a12"; + + const a111 = new Category(); + a111.name = "a111"; + + const a112 = new Category(); + a112.name = "a112"; + + a1.childCategories = [a11, a12]; + a11.childCategories = [a111, a112]; + await categoryRepository.save(a1); + + const rootCategories = await categoryRepository.findRoots(); + rootCategories.should.be.eql([{ + id: 1, + name: "a1" + }]); + + const a11Parent = await categoryRepository.findAncestors(a11); + a11Parent.length.should.be.equal(2); + a11Parent.should.deep.include({ id: 1, name: "a1" }); + a11Parent.should.deep.include({ id: 2, name: "a11" }); + + const a1Children = await categoryRepository.findDescendants(a1); + const a1ChildrenNames = a1Children.map(child => child.name); + a1ChildrenNames.length.should.be.equal(5); + a1ChildrenNames.should.deep.include("a1"); + a1ChildrenNames.should.deep.include("a11"); + a1ChildrenNames.should.deep.include("a12"); + a1ChildrenNames.should.deep.include("a111"); + a1ChildrenNames.should.deep.include("a112"); + }))); + + // todo: finish implementation and implement on other trees + it.skip("categories should remove removed children", () => Promise.all(connections.map(async connection => { + const categoryRepository = connection.getTreeRepository(Category); + + const a1 = new Category(); + a1.name = "a1"; + const a11 = new Category(); + a11.name = "a11"; + const a12 = new Category(); + a12.name = "a12"; + a1.childCategories = [a11, a12]; + await categoryRepository.save(a1); + + const a1Children1 = await categoryRepository.findDescendants(a1); + const a1ChildrenNames1 = a1Children1.map(child => child.name); + a1ChildrenNames1.length.should.be.equal(3); + a1ChildrenNames1.should.deep.include("a1"); + a1ChildrenNames1.should.deep.include("a11"); + a1ChildrenNames1.should.deep.include("a12"); + + // a1.childCategories = [a11]; + // await categoryRepository.save(a1); + // + // const a1Children2 = await categoryRepository.findDescendants(a1); + // const a1ChildrenNames2 = a1Children2.map(child => child.name); + // a1ChildrenNames2.length.should.be.equal(3); + // a1ChildrenNames2.should.deep.include("a1"); + // a1ChildrenNames2.should.deep.include("a11"); + // a1ChildrenNames2.should.deep.include("a12"); + }))); + + // todo: finish implementation and implement on other trees + it.skip("sub-category should be removed with all its children", () => Promise.all(connections.map(async connection => { + const categoryRepository = connection.getTreeRepository(Category); + + const a1 = new Category(); + a1.name = "a1"; + const a11 = new Category(); + a11.name = "a11"; + const a12 = new Category(); + a12.name = "a12"; + a1.childCategories = [a11, a12]; + await categoryRepository.save(a1); + + const a1Children1 = await categoryRepository.findDescendants(a1); + const a1ChildrenNames1 = a1Children1.map(child => child.name); + a1ChildrenNames1.length.should.be.equal(3); + a1ChildrenNames1.should.deep.include("a1"); + a1ChildrenNames1.should.deep.include("a11"); + a1ChildrenNames1.should.deep.include("a12"); + + await categoryRepository.remove(a1); + + // a1.childCategories = [a11]; + // await categoryRepository.save(a1); + // + // const a1Children2 = await categoryRepository.findDescendants(a1); + // const a1ChildrenNames2 = a1Children2.map(child => child.name); + // a1ChildrenNames2.length.should.be.equal(3); + // a1ChildrenNames2.should.deep.include("a1"); + // a1ChildrenNames2.should.deep.include("a11"); + // a1ChildrenNames2.should.deep.include("a12"); + }))); +});