From 640ae29836f73a310672dec6f6aa58748a40a662 Mon Sep 17 00:00:00 2001 From: James Ward Date: Fri, 25 Jun 2021 15:17:51 -0400 Subject: [PATCH] fix: better support of relation-based properties in where clauses --- docs/find-options.md | 5 +- src/find-options/FindOptionsUtils.ts | 72 ++-- src/metadata/EntityMetadata.ts | 19 +- src/query-builder/QueryBuilder.ts | 173 ++++++++- src/query-builder/UpdateQueryBuilder.ts | 3 +- .../query-builder/select/entity/Category.ts | 7 +- .../query-builder/select/entity/HeroImage.ts | 21 ++ .../query-builder/select/entity/Post.ts | 29 +- .../query-builder/select/entity/Tag.ts | 16 + .../select/query-builder-select.ts | 334 +++++++++++++++--- 10 files changed, 575 insertions(+), 104 deletions(-) create mode 100644 test/functional/query-builder/select/entity/HeroImage.ts create mode 100644 test/functional/query-builder/select/entity/Tag.ts diff --git a/docs/find-options.md b/docs/find-options.md index fe1c44a9657..06068151cf0 100644 --- a/docs/find-options.md +++ b/docs/find-options.md @@ -160,7 +160,10 @@ userRepository.find({ relations: ["profile", "photos", "videos"], where: { firstName: "Timber", - lastName: "Saw" + lastName: "Saw", + profile: { + userName: "tshaw" + } }, order: { name: "ASC", diff --git a/src/find-options/FindOptionsUtils.ts b/src/find-options/FindOptionsUtils.ts index 09dd246b7e3..0f1ce50f7c0 100644 --- a/src/find-options/FindOptionsUtils.ts +++ b/src/find-options/FindOptionsUtils.ts @@ -99,47 +99,19 @@ export class FindOptionsUtils { if (options.select) { qb.select([]); options.select.forEach(select => { - if (!metadata.findColumnWithPropertyPath(String(select))) + if (!metadata.hasColumnWithPropertyPath(`${select}`)) throw new TypeORMError(`${select} column was not found in the ${metadata.name} entity.`); - qb.addSelect(qb.alias + "." + select); - }); - } - - if (options.where) - qb.where(options.where); - - if ((options as FindManyOptions).skip) - qb.skip((options as FindManyOptions).skip!); + const columns = metadata.findColumnsWithPropertyPath(`${select}`); - if ((options as FindManyOptions).take) - qb.take((options as FindManyOptions).take!); - - if (options.order) - Object.keys(options.order).forEach(key => { - const order = ((options as FindOneOptions).order as any)[key as any]; - - if (!metadata.findColumnWithPropertyPath(key)) - throw new TypeORMError(`${key} column was not found in the ${metadata.name} entity.`); - - switch (order) { - case 1: - qb.addOrderBy(qb.alias + "." + key, "ASC"); - break; - case -1: - qb.addOrderBy(qb.alias + "." + key, "DESC"); - break; - case "ASC": - qb.addOrderBy(qb.alias + "." + key, "ASC"); - break; - case "DESC": - qb.addOrderBy(qb.alias + "." + key, "DESC"); - break; + for (const column of columns) { + qb.addSelect(qb.alias + "." + column.propertyPath); } }); + } if (options.relations) { - const allRelations = options.relations.map(relation => relation); + const allRelations = options.relations; this.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 @@ -207,6 +179,38 @@ export class FindOptionsUtils { qb.loadAllRelationIds(options.loadRelationIds as any); } + if (options.where) + qb.where(options.where); + + if ((options as FindManyOptions).skip) + qb.skip((options as FindManyOptions).skip!); + + if ((options as FindManyOptions).take) + qb.take((options as FindManyOptions).take!); + + if (options.order) + Object.keys(options.order).forEach(key => { + const order = ((options as FindOneOptions).order as any)[key as any]; + + if (!metadata.findColumnWithPropertyPath(key)) + throw new Error(`${key} column was not found in the ${metadata.name} entity.`); + + switch (order) { + case 1: + qb.addOrderBy(qb.alias + "." + key, "ASC"); + break; + case -1: + qb.addOrderBy(qb.alias + "." + key, "DESC"); + break; + case "ASC": + qb.addOrderBy(qb.alias + "." + key, "ASC"); + break; + case "DESC": + qb.addOrderBy(qb.alias + "." + key, "DESC"); + break; + } + }); + return qb; } diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 906b4dcefc1..b34c492f152 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -654,6 +654,14 @@ export class EntityMetadata { return this.columns.find(column => column.databaseName === databaseName); } + /** + * Checks if there is a column or relationship with a given property path. + */ + hasColumnWithPropertyPath(propertyPath: string): boolean { + const hasColumn = this.columns.some(column => column.propertyPath === propertyPath); + return hasColumn || this.hasRelationWithPropertyPath(propertyPath); + } + /** * Finds column with a given property path. */ @@ -682,13 +690,20 @@ export class EntityMetadata { // in the case if column with property path was not found, try to find a relation with such property path // if we find relation and it has a single join column then its the column user was seeking - const relation = this.relations.find(relation => relation.propertyPath === propertyPath); + const relation = this.findRelationWithPropertyPath(propertyPath); if (relation && relation.joinColumns) return relation.joinColumns; return []; } + /** + * Checks if there is a relation with the given property path. + */ + hasRelationWithPropertyPath(propertyPath: string): boolean { + return this.relations.some(relation => relation.propertyPath === propertyPath); + } + /** * Finds relation with the given property path. */ @@ -740,6 +755,8 @@ export class EntityMetadata { /** * Creates a property paths for a given entity. + * + * @deprecated */ static createPropertyPath(metadata: EntityMetadata, entity: ObjectLiteral, prefix: string = "") { const paths: string[] = []; diff --git a/src/query-builder/QueryBuilder.ts b/src/query-builder/QueryBuilder.ts index 3244b14da6e..82e6aedb14b 100644 --- a/src/query-builder/QueryBuilder.ts +++ b/src/query-builder/QueryBuilder.ts @@ -832,6 +832,154 @@ export abstract class QueryBuilder { : whereStrings[0]; } + private findColumnsForPropertyPath(propertyPath: string): [ Alias, string[], ColumnMetadata[] ] { + // Make a helper to iterate the entity & relations? + // Use that to set the correct alias? Or the other way around? + + // Start with the main alias with our property paths + let alias = this.expressionMap.mainAlias; + const root: string[] = []; + const propertyPathParts = propertyPath.split("."); + + while (propertyPathParts.length > 1) { + const part = propertyPathParts[0]; + + if (!alias?.hasMetadata) { + // If there's no metadata, we're wasting our time + // and can't actually look any of this up. + break; + } + + if (alias.metadata.hasEmbeddedWithPropertyPath(part)) { + // If this is an embedded then we should combine the two as part of our lookup. + // Instead of just breaking, we keep going with this in case there's an embedded/relation + // inside an embedded. + propertyPathParts.unshift( + `${propertyPathParts.shift()}.${propertyPathParts.shift()}` + ); + continue; + } + + if (alias.metadata.hasRelationWithPropertyPath(part)) { + // If this is a relation then we should find the aliases + // that match the relation & then continue further down + // the property path + const joinAttr = this.expressionMap.joinAttributes.find( + (joinAttr) => joinAttr.relationPropertyPath === part + ); + + if (!joinAttr?.alias) { + const fullRelationPath = root.length > 0 ? `${root.join(".")}.${part}` : part; + throw new Error(`Cannot find alias for relation at ${fullRelationPath}`); + } + + alias = joinAttr.alias; + root.push(...part.split(".")); + propertyPathParts.shift(); + continue; + } + + break; + } + + if (!alias) { + throw new Error(`Cannot find alias for property ${propertyPath}`); + } + + // Remaining parts are combined back and used to find the actual property path + const aliasPropertyPath = propertyPathParts.join("."); + + const columns = alias.metadata.findColumnsWithPropertyPath(aliasPropertyPath); + + if (!columns.length) { + throw new EntityColumnNotFound(propertyPath); + } + + return [ alias, root, columns ]; + } + + /** + * Creates a property paths for a given ObjectLiteral. + */ + protected createPropertyPath(metadata: EntityMetadata, entity: ObjectLiteral, prefix: string = "") { + const paths: string[] = []; + + for (const key of Object.keys(entity)) { + const path = prefix ? `${prefix}.${key}` : key; + + // There's times where we don't actually want to traverse deeper. + // If the value is a `FindOperator`, or null, or not an object, then we don't, for example. + if (entity[key] === null || typeof entity[key] !== "object" || entity[key] instanceof FindOperator) { + paths.push(path); + continue; + } + + if (metadata.hasEmbeddedWithPropertyPath(path)) { + const subPaths = this.createPropertyPath(metadata, entity[key], path); + paths.push(...subPaths); + continue; + } + + if (metadata.hasRelationWithPropertyPath(path)) { + const relation = metadata.findRelationWithPropertyPath(path)!; + + // There's also cases where we don't want to return back all of the properties. + // These handles the situation where someone passes the model & we don't need to make + // a HUGE `where` to uniquely look up the entity. + + // In the case of a *-to-one, there's only ever one possible entity on the other side + // so if the join columns are all defined we can return just the relation itself + // because it will fetch only the join columns and do the lookup. + if (relation.relationType === "one-to-one" || relation.relationType === "many-to-one") { + const joinColumns = relation.joinColumns + .map(j => j.referencedColumn) + .filter((j): j is ColumnMetadata => !!j); + + const hasAllJoinColumns = joinColumns.length > 0 && joinColumns.every( + column => column.getEntityValue(entity[key], false) + ); + + if (hasAllJoinColumns) { + paths.push(path); + continue; + } + } + + if (relation.relationType === "one-to-many" || relation.relationType === "many-to-many") { + throw new Error(`Cannot query across ${relation.relationType} for property ${path}`); + } + + // For any other case, if the `entity[key]` contains all of the primary keys we can do a + // lookup via these. We don't need to look up via any other values 'cause these are + // the unique primary keys. + // This handles the situation where someone passes the model & we don't need to make + // a HUGE where. + const primaryColumns = relation.inverseEntityMetadata.primaryColumns; + const hasAllPrimaryKeys = primaryColumns.length > 0 && primaryColumns.every( + column => column.getEntityValue(entity[key], false) + ); + + if (hasAllPrimaryKeys) { + const subPaths = primaryColumns.map( + column => `${path}.${column.propertyPath}` + ); + paths.push(...subPaths); + continue; + } + + // If nothing else, just return every property that's being passed to us. + const subPaths = this.createPropertyPath(relation.inverseEntityMetadata, entity[key]) + .map(p => `${path}.${p}`); + paths.push(...subPaths); + continue; + } + + paths.push(path); + } + + return paths; + } + /** * Computes given where argument - transforms to a where string all forms it can take. */ @@ -864,19 +1012,28 @@ export abstract class QueryBuilder { if (this.expressionMap.mainAlias!.hasMetadata) { andConditions = wheres.map((where, whereIndex) => { - const propertyPaths = EntityMetadata.createPropertyPath(this.expressionMap.mainAlias!.metadata, where); + const propertyPaths = this.createPropertyPath(this.expressionMap.mainAlias!.metadata, where); return propertyPaths.map((propertyPath, propertyIndex) => { - const columns = this.expressionMap.mainAlias!.metadata.findColumnsWithPropertyPath(propertyPath); - - if (!columns.length) { - throw new EntityColumnNotFound(propertyPath); - } + const [ alias, aliasPropertyPath, columns ] = this.findColumnsForPropertyPath(propertyPath); return columns.map((column, columnIndex) => { - const aliasPath = this.expressionMap.aliasNamePrefixingEnabled ? `${this.alias}.${propertyPath}` : column.propertyPath; - let parameterValue = column.getEntityValue(where, true); + // Use the correct alias & the property path from the column + const aliasPath = this.expressionMap.aliasNamePrefixingEnabled ? `${alias.name}.${column.propertyPath}` : column.propertyPath; + + let containedWhere = where; + + for (const part of aliasPropertyPath) { + if (!containedWhere || !(part in containedWhere)) { + containedWhere = {}; + break; + } + + containedWhere = containedWhere[part]; + } + + let parameterValue = column.getEntityValue(containedWhere, true); const parameterName = "where_" + whereIndex + "_" + propertyIndex + "_" + columnIndex; const parameterBaseCount = Object.keys(this.expressionMap.nativeParameters).filter(x => x.startsWith(parameterName)).length; diff --git a/src/query-builder/UpdateQueryBuilder.ts b/src/query-builder/UpdateQueryBuilder.ts index 549028e40c3..bcf5db9a6c4 100644 --- a/src/query-builder/UpdateQueryBuilder.ts +++ b/src/query-builder/UpdateQueryBuilder.ts @@ -9,7 +9,6 @@ import {SqlServerDriver} from "../driver/sqlserver/SqlServerDriver"; import {PostgresDriver} from "../driver/postgres/PostgresDriver"; import {WhereExpression} from "./WhereExpression"; import {Brackets} from "./Brackets"; -import {EntityMetadata} from "../metadata/EntityMetadata"; import {UpdateResult} from "./result/UpdateResult"; import {ReturningStatementNotSupportedError} from "../error/ReturningStatementNotSupportedError"; import {ReturningResultsEntityUpdator} from "./ReturningResultsEntityUpdator"; @@ -406,7 +405,7 @@ export class UpdateQueryBuilder extends QueryBuilder implements this.connection.driver instanceof SapDriver ? 0 : Object.keys(this.expressionMap.nativeParameters).length; if (metadata) { - EntityMetadata.createPropertyPath(metadata, valuesSet).forEach(propertyPath => { + this.createPropertyPath(metadata, valuesSet).forEach(propertyPath => { // todo: make this and other query builder to work with properly with tables without metadata const columns = metadata.findColumnsWithPropertyPath(propertyPath); diff --git a/test/functional/query-builder/select/entity/Category.ts b/test/functional/query-builder/select/entity/Category.ts index 561d0128575..5dea8c8c041 100644 --- a/test/functional/query-builder/select/entity/Category.ts +++ b/test/functional/query-builder/select/entity/Category.ts @@ -2,6 +2,8 @@ import {Entity} from "../../../../../src/decorator/entity/Entity"; import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; import {Column} from "../../../../../src/decorator/columns/Column"; import {VersionColumn} from "../../../../../src/decorator/columns/VersionColumn"; +import { Post } from "./Post"; +import { OneToMany } from "../../../../../src"; @Entity() export class Category { @@ -18,4 +20,7 @@ export class Category { @VersionColumn() version: string; -} \ No newline at end of file + @OneToMany(() => Post, (post) => post.category) + posts: Post[] + +} diff --git a/test/functional/query-builder/select/entity/HeroImage.ts b/test/functional/query-builder/select/entity/HeroImage.ts new file mode 100644 index 00000000000..7a561ce78d2 --- /dev/null +++ b/test/functional/query-builder/select/entity/HeroImage.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, +} from "../../../../../src"; +import { Post } from "./Post"; + +@Entity() +export class HeroImage { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + url: string; + + @OneToOne(() => Post, (post) => post.heroImage) + post: Post; + +} diff --git a/test/functional/query-builder/select/entity/Post.ts b/test/functional/query-builder/select/entity/Post.ts index 3f1f1d34b21..a0858d291c5 100644 --- a/test/functional/query-builder/select/entity/Post.ts +++ b/test/functional/query-builder/select/entity/Post.ts @@ -1,9 +1,16 @@ -import {Entity} from "../../../../../src/decorator/entity/Entity"; -import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; -import {Column} from "../../../../../src/decorator/columns/Column"; -import {VersionColumn} from "../../../../../src/decorator/columns/VersionColumn"; -import {Category} from "./Category"; -import {ManyToOne} from "../../../../../src/decorator/relations/ManyToOne"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + VersionColumn, + ManyToOne, + JoinTable, + ManyToMany, + OneToOne, JoinColumn, +} from "../../../../../src"; +import { Tag } from "./Tag"; +import { Category } from "./Category"; +import { HeroImage } from "./HeroImage"; @Entity() export class Post { @@ -23,7 +30,15 @@ export class Post { @VersionColumn() version: string; + @OneToOne(() => HeroImage, (hero) => hero.post) + @JoinColumn() + heroImage: HeroImage; + @ManyToOne(type => Category) category: Category; -} \ No newline at end of file + @ManyToMany(() => Tag, (tag) => tag.posts) + @JoinTable() + tags: Tag[] + +} diff --git a/test/functional/query-builder/select/entity/Tag.ts b/test/functional/query-builder/select/entity/Tag.ts new file mode 100644 index 00000000000..449d704d329 --- /dev/null +++ b/test/functional/query-builder/select/entity/Tag.ts @@ -0,0 +1,16 @@ +import { Post } from "./Post"; +import { Entity, ManyToMany, Column, PrimaryGeneratedColumn } from "../../../../../src"; + +@Entity() +export class Tag { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @ManyToMany(() => Post, (post) => post.tags) + posts: Post[] + +} diff --git a/test/functional/query-builder/select/query-builder-select.ts b/test/functional/query-builder/select/query-builder-select.ts index 9f004add6eb..a46800a9d96 100644 --- a/test/functional/query-builder/select/query-builder-select.ts +++ b/test/functional/query-builder/select/query-builder-select.ts @@ -1,17 +1,18 @@ import "reflect-metadata"; import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; -import {Connection} from "../../../../src/connection/Connection"; -import {Post} from "./entity/Post"; import {expect} from "chai"; -import {EntityNotFoundError} from "../../../../src/error/EntityNotFoundError"; +import { EntityNotFoundError, Connection, IsNull, In, Raw } from "../../../../src"; import {MysqlDriver} from "../../../../src/driver/mysql/MysqlDriver"; +import { Category } from "./entity/Category"; +import {Post} from "./entity/Post"; +import { Tag } from "./entity/Tag"; +import { HeroImage } from "./entity/HeroImage"; describe("query builder > select", () => { - let connections: Connection[]; before(async () => connections = await createTestingConnections({ - entities: [__dirname + "/entity/*{.js,.ts}"], - enabledDrivers: ["mysql"] + entities: [Category, Post, Tag, HeroImage], + enabledDrivers: ["sqlite"], })); beforeEach(() => reloadTestingDatabases(connections)); after(() => closeTestingConnections(connections)); @@ -26,6 +27,7 @@ describe("query builder > select", () => { "post.description AS post_description, " + "post.rating AS post_rating, " + "post.version AS post_version, " + + "post.heroImageId AS post_heroImageId, " + "post.categoryId AS post_categoryId " + "FROM post post"); }))); @@ -41,6 +43,7 @@ describe("query builder > select", () => { "post.description AS post_description, " + "post.rating AS post_rating, " + "post.version AS post_version, " + + "post.heroImageId AS post_heroImageId, " + "post.categoryId AS post_categoryId " + "FROM post post"); }))); @@ -56,10 +59,11 @@ describe("query builder > select", () => { "post.description AS post_description, " + "post.rating AS post_rating, " + "post.version AS post_version, " + + "post.heroImageId AS post_heroImageId, " + "post.categoryId AS post_categoryId, " + "category.id AS category_id, " + - "category.name AS category_name," + - " category.description AS category_description, " + + "category.name AS category_name, " + + "category.description AS category_description, " + "category.version AS category_version " + "FROM post post LEFT JOIN category category"); }))); @@ -77,6 +81,7 @@ describe("query builder > select", () => { "FROM post post LEFT JOIN category category"); }))); + it("should append entity mapped columns to select statement, if they passed as array", () => Promise.all(connections.map(async connection => { const sql = connection.createQueryBuilder(Post, "post") .select(["post.id", "post.title"]) @@ -113,48 +118,277 @@ describe("query builder > select", () => { expect(sql).to.equal("SELECT post.name FROM post post"); }))); - it("should return a single entity for getOne when found", () => Promise.all(connections.map(async connection => { - await connection.getRepository(Post).save({ id: 1, title: "Hello", description: 'World', rating: 0 }); - - const entity = await connection.createQueryBuilder(Post, "post") - .where("post.id = :id", { id: 1 }) - .getOne(); - - expect(entity).not.to.be.undefined; - expect(entity!.id).to.equal(1); - expect(entity!.title).to.equal("Hello"); - }))); - - it("should return undefined for getOne when not found", () => Promise.all(connections.map(async connection => { - await connection.getRepository(Post).save({ id: 1, title: "Hello", description: 'World', rating: 0 }); - - const entity = await connection.createQueryBuilder(Post, "post") - .where("post.id = :id", { id: 2 }) - .getOne(); - - expect(entity).to.be.undefined; - }))); - - it("should return a single entity for getOneOrFail when found", () => Promise.all(connections.map(async connection => { - await connection.getRepository(Post).save({ id: 1, title: "Hello", description: 'World', rating: 0 }); - - const entity = await connection.createQueryBuilder(Post, "post") - .where("post.id = :id", { id: 1 }) - .getOneOrFail(); - - expect(entity.id).to.equal(1); - expect(entity.title).to.equal("Hello"); - }))); - - it("should throw an Error for getOneOrFail when not found", () => Promise.all(connections.map(async connection => { - await connection.getRepository(Post).save({ id: 1, title: "Hello", description: 'World', rating: 0 }); - - await expect( - connection.createQueryBuilder(Post, "post") - .where("post.id = :id", { id: 2 }) - .getOneOrFail() - ).to.be.rejectedWith(EntityNotFoundError); - }))); + describe("with relations and where clause", () => { + describe("many-to-one", () => { + it("should craft query with exact value", () => Promise.all(connections.map(async connection => { + // For github issues #2707 + + const [sql, params] = connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.category", "category_join") + .where({ + "category": { + "name": "Foo" + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "post"."id" AS "post_id" FROM "post" "post" ' + + 'LEFT JOIN "category" "category_join" ON "category_join"."id"="post"."categoryId" ' + + 'WHERE "category_join"."name" = ?' + ); + + expect(params).to.eql(["Foo"]); + }))); + + it("should craft query with FindOperator", () => Promise.all(connections.map(async connection => { + const [sql, params] = connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.category", "category_join") + .where({ + "category": { + "name": IsNull() + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "post"."id" AS "post_id" FROM "post" "post" ' + + 'LEFT JOIN "category" "category_join" ON "category_join"."id"="post"."categoryId" ' + + 'WHERE "category_join"."name" IS NULL' + ); + + expect(params).to.eql([]); + }))); + + it("should craft query with Raw", () => Promise.all(connections.map(async connection => { + // For github issue #6264 + const [sql, params] = connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.category", "category_join") + .where({ + "category": { + "name": Raw(path => `SOME_FUNCTION(${path})`) + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "post"."id" AS "post_id" FROM "post" "post" ' + + 'LEFT JOIN "category" "category_join" ON "category_join"."id"="post"."categoryId" ' + + 'WHERE SOME_FUNCTION("category_join"."name")' + ); + + expect(params).to.eql([]); + }))); + }) + + describe("one-to-many", () => { + it("should craft query with exact value", () => Promise.all(connections.map(async connection => { + expect(() => { + connection.createQueryBuilder(Category, "category") + .select("category.id") + .leftJoin("category.posts", "posts") + .where({ + posts: { + id: 10 + } + }) + .getQueryAndParameters(); + }).to.throw(); + }))); + + it("should craft query with FindOperator", () => Promise.all(connections.map(async connection => { + // For github issue #6647 + + expect(() => { + connection.createQueryBuilder(Category, "category") + .select("category.id") + .leftJoin("category.posts", "posts") + .where({ + posts: { + id: IsNull() + } + }) + .getQueryAndParameters(); + }).to.throw(); + }))); + }); + + describe("many-to-many", () => { + it("should craft query with exact value", () => Promise.all(connections.map(async connection => { + + expect(() => { + connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.tags", "tags_join") + .where({ + "tags": { + "name": "Foo" + } + }) + .getQueryAndParameters(); + }).to.throw(); + }))); + + it("should craft query with FindOperator", () => Promise.all(connections.map(async connection => { + expect(() => { + connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.tags", "tags_join") + .where({ + "tags": { + "name": IsNull() + } + }) + .getQueryAndParameters(); + }).to.throw(); + }))); + }); + + describe("one-to-one", () => { + it("should craft query with exact value", () => Promise.all(connections.map(async connection => { + const [sql, params] = connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.heroImage", "hero_join") + .where({ + heroImage: { + url: "Foo" + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "post"."id" AS "post_id" FROM "post" "post" ' + + 'LEFT JOIN "hero_image" "hero_join" ON "hero_join"."id"="post"."heroImageId" ' + + 'WHERE "hero_join"."url" = ?' + ); + + expect(params).to.eql(["Foo"]); + }))); + + it("should craft query with FindOperator", () => Promise.all(connections.map(async connection => { + const [sql, params] = connection.createQueryBuilder(Post, "post") + .select("post.id") + .leftJoin("post.heroImage", "hero_join") + .where({ + heroImage: { + url: IsNull() + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "post"."id" AS "post_id" FROM "post" "post" ' + + 'LEFT JOIN "hero_image" "hero_join" ON "hero_join"."id"="post"."heroImageId" ' + + 'WHERE "hero_join"."url" IS NULL' + ); + + expect(params).to.eql([]); + }))); + }); + + describe("deeply nested relations", () => { + it("should craft query with exact value", () => Promise.all(connections.map(async connection => { + // For github issue #7251 + + const [sql, params] = connection.createQueryBuilder(HeroImage, "hero") + .leftJoin("hero.post", "posts") + .leftJoin("posts.category", "category") + .where({ + post: { + category: { + name: "Foo" + } + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "hero"."id" AS "hero_id", "hero"."url" AS "hero_url" ' + + 'FROM "hero_image" "hero" ' + + 'LEFT JOIN "post" "posts" ON "posts"."heroImageId"="hero"."id" ' + + 'LEFT JOIN "category" "category" ON "category"."id"="posts"."categoryId" ' + + 'WHERE "category"."name" = ?' + ); + + expect(params).to.eql(["Foo"]); + }))); + + it("should craft query with FindOperator", () => Promise.all(connections.map(async connection => { + // For github issue #4906 + + const [sql, params] = connection.createQueryBuilder(HeroImage, "hero") + .leftJoin("hero.post", "posts") + .leftJoin("posts.category", "category") + .where({ + post: { + category: { + name: In(["Foo", "Bar", "Baz"]) + } + } + }) + .getQueryAndParameters(); + + expect(sql).to.equal( + 'SELECT "hero"."id" AS "hero_id", "hero"."url" AS "hero_url" ' + + 'FROM "hero_image" "hero" ' + + 'LEFT JOIN "post" "posts" ON "posts"."heroImageId"="hero"."id" ' + + 'LEFT JOIN "category" "category" ON "category"."id"="posts"."categoryId" ' + + 'WHERE "category"."name" IN (?, ?, ?)' + ); + + expect(params).to.eql(["Foo", "Bar", "Baz"]); + }))); + }); + }); + + describe("query execution and retrieval", () => { + it("should return a single entity for getOne when found", () => Promise.all(connections.map(async connection => { + await connection.getRepository(Post).save({ id: 1, title: "Hello", description: "World", rating: 0 }); + + const entity = await connection.createQueryBuilder(Post, "post") + .where("post.id = :id", { id: 1 }) + .getOne(); + + expect(entity).not.to.be.undefined; + expect(entity!.id).to.equal(1); + expect(entity!.title).to.equal("Hello"); + }))); + + it("should return undefined for getOne when not found", () => Promise.all(connections.map(async connection => { + await connection.getRepository(Post).save({ id: 1, title: "Hello", description: "World", rating: 0 }); + + const entity = await connection.createQueryBuilder(Post, "post") + .where("post.id = :id", { id: 2 }) + .getOne(); + + expect(entity).to.be.undefined; + }))); + + it("should return a single entity for getOneOrFail when found", () => Promise.all(connections.map(async connection => { + await connection.getRepository(Post).save({ id: 1, title: "Hello", description: "World", rating: 0 }); + + const entity = await connection.createQueryBuilder(Post, "post") + .where("post.id = :id", { id: 1 }) + .getOneOrFail(); + + expect(entity.id).to.equal(1); + expect(entity.title).to.equal("Hello"); + }))); + + it("should throw an Error for getOneOrFail when not found", () => Promise.all(connections.map(async connection => { + await connection.getRepository(Post).save({ id: 1, title: "Hello", description: "World", rating: 0 }); + + await expect( + connection.createQueryBuilder(Post, "post") + .where("post.id = :id", { id: 2 }) + .getOneOrFail() + ).to.be.rejectedWith(EntityNotFoundError); + }))); + + }) it("Support max execution time", () => Promise.all(connections.map(async connection => { // MAX_EXECUTION_TIME supports only in MySQL