Skip to content

Commit

Permalink
feat: add relations option to tree queries (#7981)
Browse files Browse the repository at this point in the history
* feat: add relation option to tree queries

Add possibility to load relations of tree entities

Closes: #7974 #4564

* style: remove unused declaration

remove unused declaration to satisfy linting.

* Update tree-entities.md

docs: rename variable for copy paste

* Update FindOptionsUtils.ts

style: remove prettified code

* style: remove prettified code

* style: remove prettified code

* test: enable test for all drivers
  • Loading branch information
TheProgrammer21 committed Aug 4, 2021
1 parent 2344db6 commit ca26297
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 15 deletions.
3 changes: 3 additions & 0 deletions docs/tree-entities.md
Expand Up @@ -203,6 +203,9 @@ There are other special methods to work with tree entities through `TreeReposito
```typescript
const treeCategories = await repository.findTrees();
// returns root categories with sub categories inside

const treeCategoriesWithRelations = await repository.findTrees({ relations: ["sites"] });
// automatically joins the sites relation
```

* `findRoots` - Roots are entities that have no ancestors. Finds them all.
Expand Down
2 changes: 1 addition & 1 deletion src/find-options/FindOptionsUtils.ts
Expand Up @@ -222,7 +222,7 @@ export class FindOptionsUtils {
/**
* Adds joins for all relations and sub-relations of the given relations provided in the find options.
*/
protected static applyRelationsRecursively(qb: SelectQueryBuilder<any>, allRelations: string[], alias: string, metadata: EntityMetadata, prefix: string): void {
public static applyRelationsRecursively(qb: SelectQueryBuilder<any>, allRelations: string[], alias: string, metadata: EntityMetadata, prefix: string): void {

// find all relations that match given prefix
let matchedBaseRelations: string[] = [];
Expand Down
11 changes: 11 additions & 0 deletions src/find-options/FindTreeOptions.ts
@@ -0,0 +1,11 @@
/**
* Defines a special criteria to find specific entities.
*/
export interface FindTreeOptions {

/**
* Indicates what relations of entity should be loaded (simplified left join form).
*/
relations?: string[];

}
59 changes: 45 additions & 14 deletions src/repository/TreeRepository.ts
Expand Up @@ -3,6 +3,9 @@ import {SelectQueryBuilder} from "../query-builder/SelectQueryBuilder";
import {ObjectLiteral} from "../common/ObjectLiteral";
import {AbstractSqliteDriver} from "../driver/sqlite-abstract/AbstractSqliteDriver";
import { TypeORMError } from "../error/TypeORMError";
import { FindTreeOptions } from "../find-options/FindTreeOptions";
import { FindRelationsNotFoundError } from "../error";
import { FindOptionsUtils } from "../find-options/FindOptionsUtils";

/**
* Repository with additional functions to work with trees.
Expand All @@ -18,23 +21,38 @@ export class TreeRepository<Entity> extends Repository<Entity> {
/**
* Gets complete trees for all roots in the table.
*/
async findTrees(): Promise<Entity[]> {
const roots = await this.findRoots();
await Promise.all(roots.map(root => this.findDescendantsTree(root)));
async findTrees(options?: FindTreeOptions): Promise<Entity[]> {
const roots = await this.findRoots(options);
await Promise.all(roots.map(root => this.findDescendantsTree(root, options)));
return roots;
}

/**
* Roots are entities that have no ancestors. Finds them all.
*/
findRoots(): Promise<Entity[]> {
findRoots(options?: FindTreeOptions): Promise<Entity[]> {
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
const parentPropertyName = this.manager.connection.namingStrategy.joinColumnName(
this.metadata.treeParentRelation!.propertyName, this.metadata.primaryColumns[0].propertyName
);

return this.createQueryBuilder("treeEntity")
const qb = this.createQueryBuilder("treeEntity");

if (options?.relations) {
const allRelations = [...options.relations];

FindOptionsUtils.applyRelationsRecursively(qb, allRelations, qb.expressionMap.mainAlias!.name, qb.expressionMap.mainAlias!.metadata, "");

// recursive removes found relations from allRelations array
// if there are relations left in this array it means those relations were not found in the entity structure
// so, we give an exception about not found relations
if (allRelations.length > 0)
throw new FindRelationsNotFoundError(allRelations);
}


return qb
.where(`${escapeAlias("treeEntity")}.${escapeColumn(parentPropertyName)} IS NULL`)
.getMany();
}
Expand All @@ -51,16 +69,29 @@ export class TreeRepository<Entity> extends Repository<Entity> {
/**
* Gets all children (descendants) of the given entity. Returns them in a tree - nested into each other.
*/
findDescendantsTree(entity: Entity): Promise<Entity> {
async findDescendantsTree(entity: Entity, options?: FindTreeOptions): Promise<Entity> {
// todo: throw exception if there is no column of this relation?
return this
.createDescendantsQueryBuilder("treeEntity", "treeClosure", entity)
.getRawAndEntities()
.then(entitiesAndScalars => {
const relationMaps = this.createRelationMaps("treeEntity", entitiesAndScalars.raw);
this.buildChildrenEntityTree(entity, entitiesAndScalars.entities, relationMaps);
return entity;
});

const qb: SelectQueryBuilder<Entity> = this.createDescendantsQueryBuilder("treeEntity", "treeClosure", entity);

if (options?.relations) {
// Copy because `applyRelationsRecursively` modifies it
const allRelations = [...options.relations];

FindOptionsUtils.applyRelationsRecursively(qb, allRelations, qb.expressionMap.mainAlias!.name, qb.expressionMap.mainAlias!.metadata, "");

// recursive removes found relations from allRelations array
// if there are relations left in this array it means those relations were not found in the entity structure
// so, we give an exception about not found relations
if (allRelations.length > 0)
throw new FindRelationsNotFoundError(allRelations);
}

const entities = await qb.getRawAndEntities();
const relationMaps = this.createRelationMaps("treeEntity", entities.raw);
this.buildChildrenEntityTree(entity, entities.entities, relationMaps);

return entity;
}

/**
Expand Down
25 changes: 25 additions & 0 deletions test/github-issues/7974/entity/Category.ts
@@ -0,0 +1,25 @@
import { BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn, Tree, TreeChildren, TreeParent } from "../../../../src";
import { Site } from "./Site";

@Entity()
@Tree("materialized-path")
export class Category extends BaseEntity {

@PrimaryGeneratedColumn()
pk: number;

@Column({
length: 250,
nullable: false
})
title: string;

@TreeParent()
parentCategory: Category | null;

@TreeChildren()
childCategories: Category[];

@OneToMany(() => Site, site => site.parentCategory)
sites: Site[];
}
21 changes: 21 additions & 0 deletions test/github-issues/7974/entity/Site.ts
@@ -0,0 +1,21 @@
import { BaseEntity, Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from "../../../../src";
import { Category } from "./Category";

@Entity()
export class Site extends BaseEntity {

@PrimaryGeneratedColumn()
pk: number;

@CreateDateColumn()
createdAt: Date;

@Column({
length: 250,
nullable: false
})
title: string;

@ManyToOne(() => Category)
parentCategory: Category;
}
125 changes: 125 additions & 0 deletions test/github-issues/7974/issue-7974.ts
@@ -0,0 +1,125 @@
import { expect } from "chai";
import "reflect-metadata";
import { Connection } from "../../../src";
import { closeTestingConnections, createTestingConnections, reloadTestingDatabases } from "../../utils/test-utils";
import { Category } from "./entity/Category";
import { Site } from "./entity/Site";

describe("github issues > #7974 Adding relations option to findTrees()", () => {

let connections: Connection[];

before(async () => connections = await createTestingConnections({
entities: [Category, Site],
schemaCreate: true,
dropSchema: true
}));

beforeEach(async () => {
await reloadTestingDatabases(connections);
for (let connection of connections) {
let categoryRepo = connection.getRepository(Category);
let siteRepo = connection.getRepository(Site);

let c1: Category = new Category();
c1.title = "Category 1";
c1.parentCategory = null;
c1.childCategories = [];
c1.sites = [];

let c2: Category = new Category();
c2.title = "Category 2";
c2.parentCategory = null;
c2.childCategories = [];
c2.sites = [];

let c3: Category = new Category();
c3.title = "Category 1.1";
c3.parentCategory = c1;
c3.childCategories = [];
c3.sites = [];

let c4: Category = new Category();
c4.title = "Category 1.1.1";
c4.parentCategory = c3;
c4.childCategories = [];
c4.sites = [];

c1.childCategories = [c3];
c3.childCategories = [c4];

let s1: Site = new Site();
s1.title = "Site of Category 1";
s1.parentCategory = c1;

let s2: Site = new Site();
s2.title = "Site of Category 1";
s2.parentCategory = c1;

let s3: Site = new Site();
s3.title = "Site of Category 1.1";
s3.parentCategory = c3;

let s4: Site = new Site();
s4.title = "Site of Category 1.1";
s4.parentCategory = c3;

let s5: Site = new Site();
s5.title = "Site of Category 1.1.1";
s5.parentCategory = c4;

// Create the categories
c1 = await categoryRepo.save(c1);
c2 = await categoryRepo.save(c2);
c3 = await categoryRepo.save(c3);
c4 = await categoryRepo.save(c4);

// Create the sites
[s1, s2, s3, s4, s5] =
await Promise.all([
siteRepo.save(s1),
siteRepo.save(s2),
siteRepo.save(s3),
siteRepo.save(s4),
siteRepo.save(s5)
]);

// Set the just created relations correctly
c1.sites = [s1, s2];
c2.sites = [];
c3.sites = [s3, s4];
c4.sites = [s5];
}
});

after(() => closeTestingConnections(connections));

it("should return tree without sites relations", async () => await Promise.all(connections.map(async connection => {

let result = await connection.getTreeRepository(Category).findTrees();

// The complete tree should exist but other relations than the parent- / child-relations should not be loaded
expect(result).to.have.lengthOf(2);
expect(result[0].sites).equals(undefined);
expect(result[0].childCategories).to.have.lengthOf(1);
expect(result[0].childCategories[0].childCategories).to.have.lengthOf(1);
expect(result[0].childCategories[0].childCategories[0].sites).equal(undefined);

})));

it("should return tree with sites relations", async () => await Promise.all(connections.map(async connection => {

let result = await connection.getTreeRepository(Category).findTrees({ relations: ["sites"] });

// The complete tree should exist and site relations should not be loaded for every category
expect(result).to.have.lengthOf(2);
expect(result[0].sites).lengthOf(2);
expect(result[1].sites).to.be.an("array");
expect(result[1].sites).to.have.lengthOf(0);
expect(result[0].childCategories[0].sites).to.have.lengthOf(2);
expect(result[0].childCategories[0].childCategories[0].sites).to.have.lengthOf(1);
expect(result[0].childCategories[0].childCategories[0].sites).to.be.an("array");
expect(result[0].childCategories[0].childCategories[0].sites[0].title).to.be.equal("Site of Category 1.1.1");
})));

});

0 comments on commit ca26297

Please sign in to comment.