From 04f2dca8fdec18d2351f6740de693fc5d45845f5 Mon Sep 17 00:00:00 2001 From: Metin Demir Date: Wed, 5 May 2021 09:53:40 +0200 Subject: [PATCH] feat(hydra-cli): support cross filters for entity relationship (#381) * hydra-cli: update relation decorator with new options * hydra-cli: cross filter queries for oto, mto, otm relation types * hydra-cli: update templates for MTM relations * hydra-cli: fix table name * hydra-cli: cross-relation filters docs * hydra-cli: fix tests * hydra-cli: upgrade warthog version --- docs/schema-spec/cross-filters.md | 111 +++++++++++++++++ packages/hydra-cli/package.json | 4 +- .../hydra-cli/src/generate/ModelRenderer.ts | 15 ++- .../hydra-cli/src/generate/field-context.ts | 1 + packages/hydra-cli/src/generate/utils.ts | 5 + packages/hydra-cli/src/model/Relation.ts | 20 +++ .../src/templates/entities/model.ts.mst | 39 +++++- .../src/templates/entities/resolver.ts.mst | 115 +++++++++++++++++- .../test/helpers/ModelRenderer.test.ts | 8 +- yarn.lock | 30 +---- 10 files changed, 311 insertions(+), 37 deletions(-) create mode 100644 docs/schema-spec/cross-filters.md diff --git a/docs/schema-spec/cross-filters.md b/docs/schema-spec/cross-filters.md new file mode 100644 index 000000000..6c5c0119d --- /dev/null +++ b/docs/schema-spec/cross-filters.md @@ -0,0 +1,111 @@ +--- +description: Filter query results with related entity fields +--- + +# Cross-relation filters + +Cross-relation filters allows you to filter query results with the related entity fields. + +During the example we will use the below schema: + +```graphql +type Channel @entity { + id: ID! + handle: String! + videos: [Video!] @derivedFrom(field: "channel") +} + +type Video @entity { + id: ID! + title: String! + channel: Channel! + featured: FeaturedVideo @derivedFrom(field: "video") + publishedBefore: Bool! +} + +type FeaturedVideo @entity { + id: ID! + video: Video! +} +``` + +**Filter 1-1 relations** + +Fetch all the featured videos those title contains `joy`: + +```graphql +query { + featuredVideos(where: { video: { title_contains: "joy" } }) { + id + video { + title + } + } +} +``` + +**Filter 1-M relations** + +Fetch all the videos published under `Joystream` channel: + +```graphql +query { + videos(where: { channel: { handle_eq: "Joystream" } }) { + title + } +} +``` + +**Modifiers** +There are three modifiers can be use for M-1 and M-M relationships. + +- some: if any of the entities in the relation satify a condition +- every: if all of the entities in the relation satify a condition +- none: if none of the entities in the relation satify a condition + +**Fetch if any of the entities in the relation satify a condition** +Example: + +Fetch all channels which have at least one video with a title that contains `kid` + +```graphql +query { + channels(where: { videos_some: { title_contains: "kid" } }) { + handle + videos { + title + } + } +} +``` + +**Fetch if all of the entities in the relation satify a condition** +Example: + +Fetch all channels which have all of their videos `publishedBefore_eq: true`: + +```graphql +query { + channels(where: { videos_every: { publishedBefore_eq: true } }) { + handle + videos { + title + } + } +} +``` + +**Fetch if none of the entities in the relation satify a condition** + +Fetch all channels which have none of their videos `publishedBefore_eq: true`: + +```graphql +query { + channels(where: { videos_none: { publishedBefore_eq: true } }) { + handle + videos { + title + } + } +} +``` diff --git a/packages/hydra-cli/package.json b/packages/hydra-cli/package.json index b6941e26b..12d5fc13a 100644 --- a/packages/hydra-cli/package.json +++ b/packages/hydra-cli/package.json @@ -71,12 +71,12 @@ "mustache": "^4.0.1", "pluralize": "^8.0.0", "tslib": "1.11.2", - "warthog": "/~https://github.com/metmirr/warthog/releases/download/v2.23.0/warthog-v2.23.0.tgz" + "warthog": "/~https://github.com/metmirr/warthog/releases/download/v2.26.0/warthog-v2.26.0.tgz" }, "queryNodeDependencies": { "lodash": "^4.17.20", "pg-listen": "^1.7.0", - "warthog": "/~https://github.com/metmirr/warthog/releases/download/v2.23.0/warthog-v2.23.0.tgz", + "warthog": "/~https://github.com/metmirr/warthog/releases/download/v2.26.0/warthog-v2.26.0.tgz", "typeorm": "^0.2.31" }, "devDependencies": { diff --git a/packages/hydra-cli/src/generate/ModelRenderer.ts b/packages/hydra-cli/src/generate/ModelRenderer.ts index 6b62e35ec..c9319f8af 100644 --- a/packages/hydra-cli/src/generate/ModelRenderer.ts +++ b/packages/hydra-cli/src/generate/ModelRenderer.ts @@ -6,6 +6,7 @@ import * as utils from './utils' import { GraphQLEnumType } from 'graphql' import { AbstractRenderer } from './AbstractRenderer' import { withEnum } from './enum-context' +import { getRelationType } from '../model/Relation' const debug = Debug('qnode-cli:model-renderer') @@ -134,26 +135,38 @@ export class ModelRenderer extends AbstractRenderer { for (const f of this.objType.fields) { if (!f.relation) continue const returnTypeFunc = f.relation.columnType + fieldResolvers.push({ returnTypeFunc, rootArgType: entityName, fieldName: f.name, rootArgName: 'r', // disable utils.camelCase(entityName) could be a reverved ts/js keyword ie `class` returnType: utils.generateResolverReturnType(returnTypeFunc, f.isList), + relatedTsProp: f.relation.relatedTsProp, + relationType: getRelationType(f.relation), + tableName: returnTypeFunc.toLowerCase(), }) if (f.type !== this.objType.name) { fieldResolverImports.add(utils.generateEntityImport(returnTypeFunc)) + fieldResolverImports.add( + utils.generateEntityServiceImport(returnTypeFunc) + ) } } const imports = Array.from(fieldResolverImports.values()) // If there is at least one field resolver then add typeorm to imports if (imports.length) { - imports.push(`import { getConnection } from 'typeorm';`) + imports.push( + `import { getConnection, getRepository, In, Not } from 'typeorm'; + import _ from 'lodash'; + ` + ) } return { fieldResolvers, fieldResolverImports: imports, + crossFilters: !!fieldResolvers.length, } } diff --git a/packages/hydra-cli/src/generate/field-context.ts b/packages/hydra-cli/src/generate/field-context.ts index 2f6b7cc7b..4279af9cd 100644 --- a/packages/hydra-cli/src/generate/field-context.ts +++ b/packages/hydra-cli/src/generate/field-context.ts @@ -160,6 +160,7 @@ export function withDerivedNames( ...util.names(f.name), relFieldName: util.camelCase(entity.name), relFieldNamePlural: util.camelPlural(entity.name), + entityName: entity.name, } } diff --git a/packages/hydra-cli/src/generate/utils.ts b/packages/hydra-cli/src/generate/utils.ts index 791ab5689..42e4a6ae8 100644 --- a/packages/hydra-cli/src/generate/utils.ts +++ b/packages/hydra-cli/src/generate/utils.ts @@ -78,6 +78,11 @@ export function generateEntityImport(entityName: string): string { return `import {${entityName}} from '../${kebabName}/${kebabName}.model'` } +export function generateEntityServiceImport(name: string): string { + const kebabName = kebabCase(name) + return `import {${name}Service} from '../${kebabName}/${kebabName}.service'` +} + export function generateResolverReturnType( type: string, isList: boolean diff --git a/packages/hydra-cli/src/model/Relation.ts b/packages/hydra-cli/src/model/Relation.ts index 6cf754125..d63c8abaf 100644 --- a/packages/hydra-cli/src/model/Relation.ts +++ b/packages/hydra-cli/src/model/Relation.ts @@ -32,6 +32,9 @@ export interface FieldResolver { rootArgName: string rootArgType: string returnType: string + relatedTsProp: string | undefined + relationType: RelationTypeGuard + tableName: string } export interface EntityRelationship { @@ -61,3 +64,20 @@ export enum RelationType { // ManyToOne MTO = 'mto', } +interface RelationTypeGuard { + isOTO: boolean + isOTM: boolean + isMTO: boolean + isMTM: boolean + isModifier: boolean +} + +export function getRelationType(r: Relation): RelationTypeGuard { + return { + isOTO: r.type === RelationType.OTO, + isOTM: r.type === RelationType.OTM, + isMTO: r.type === RelationType.MTO, + isMTM: r.type === RelationType.MTM, + isModifier: r.type === RelationType.OTM || r.type === RelationType.MTM, + } +} diff --git a/packages/hydra-cli/src/templates/entities/model.ts.mst b/packages/hydra-cli/src/templates/entities/model.ts.mst index 309446cd2..27f642610 100644 --- a/packages/hydra-cli/src/templates/entities/model.ts.mst +++ b/packages/hydra-cli/src/templates/entities/model.ts.mst @@ -58,8 +58,13 @@ export {{#isInterface}}abstract{{/isInterface}} class {{className}} {{#fields}} {{#is.otm}} @OneToMany( - () => {{relation.columnType}}, (param: {{relation.columnType}}) => param.{{relation.relatedTsProp}} - {{#relation.nullable}},{ nullable: true }{{/relation.nullable}} + () => {{relation.columnType}}, (param: {{relation.columnType}}) => param.{{relation.relatedTsProp}}, + { + {{#relation.nullable}}nullable: true,{{/relation.nullable}} + modelName: '{{entityName}}', + relModelName: '{{relation.columnType}}', + propertyName: '{{camelName}}' + } ) {{camelName}}?: {{relation.columnType}}[]; {{/is.otm}} @@ -68,7 +73,13 @@ export {{#isInterface}}abstract{{/isInterface}} class {{className}} @ManyToOne( () => {{relation.columnType}}, {{#relation.relatedTsProp}}(param: {{relation.columnType}}) => param.{{relation.relatedTsProp}},{{/relation.relatedTsProp}} - { skipGraphQLField: true {{#relation.nullable}},nullable: true{{/relation.nullable}} } + { + skipGraphQLField: true, + {{#relation.nullable}}nullable: true,{{/relation.nullable}} + modelName: '{{entityName}}', + relModelName: '{{relation.columnType}}', + propertyName: '{{camelName}}' + } ) {{camelName}}{{^required}}?{{/required}}{{#required}}!{{/required}}: {{relation.columnType}}; {{/is.mto}} @@ -76,13 +87,29 @@ export {{#isInterface}}abstract{{/isInterface}} class {{className}} {{#is.oto}} {{^relation.joinColumn}}@OneToOne{{/relation.joinColumn}} {{#relation.joinColumn}}@OneToOneJoin{{/relation.joinColumn}} - (() => {{relation.columnType}},(param: {{relation.columnType}}) => param.{{relation.relatedTsProp}} {{#relation.nullable}},{ nullable: true }{{/relation.nullable}} ) + ( + () => {{relation.columnType}}, + (param: {{relation.columnType}}) => param.{{relation.relatedTsProp}}, + { + {{#relation.nullable}}nullable: true,{{/relation.nullable}} + modelName: '{{entityName}}', + relModelName: '{{relation.columnType}}', + propertyName: '{{camelName}}', + } + ) {{camelName}}{{^required}}?{{/required}}{{#required}}!{{/required}}: {{relation.columnType}}; {{/is.oto}} {{#is.mtm}} - @ManyToMany(() => {{relation.columnType}}, (param: {{relation.columnType}}) => param.{{relation.relatedTsProp}} - {{#relation.nullable}},{ nullable: true }{{/relation.nullable}} + @ManyToMany( + () => {{relation.columnType}}, + (param: {{relation.columnType}}) => param.{{relation.relatedTsProp}}, + { + {{#relation.nullable}},{ nullable: true }{{/relation.nullable}} + modelName: '{{entityName}}', + relModelName: '{{relation.columnType}}', + propertyName: '{{camelName}}', + } ) {{#relation.joinTable}} @JoinTable({ diff --git a/packages/hydra-cli/src/templates/entities/resolver.ts.mst b/packages/hydra-cli/src/templates/entities/resolver.ts.mst index 06b9aab5e..a8ddd3a12 100644 --- a/packages/hydra-cli/src/templates/entities/resolver.ts.mst +++ b/packages/hydra-cli/src/templates/entities/resolver.ts.mst @@ -87,13 +87,126 @@ export class {{className}}ConnectionWhereArgs extends ConnectionPageInputOptions @Resolver({{className}}) export class {{className}}Resolver { - constructor(@Inject('{{className}}Service') public readonly service: {{className}}Service) {} + constructor( + @Inject('{{className}}Service') public readonly service: {{className}}Service, + {{#fieldResolvers}} + @Inject('{{returnTypeFunc}}Service') public readonly {{fieldName}}Service: {{returnTypeFunc}}Service, + {{/fieldResolvers}} + ) {} @Query(() => [{{className}}]) async {{camelNamePlural}}( @Args() { where, orderBy, limit, offset }: {{className}}WhereArgs, @Fields() fields: string[] ): Promise<{{className}}[]> { + {{#crossFilters}} + if (where) { + const id_in = where.id_in; + + {{#fieldResolvers}} + {{#relationType.isOTO}} + const { {{fieldName}} } = where + if ({{fieldName}}) { + where.id_in = ( + await this.{{fieldName}}Service + .getQueryBuilder({{fieldName}}) + .leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}') + .getMany() + ) + .filter(f => f.{{relatedTsProp}}) + .map(f => f.{{relatedTsProp}}!.id); + delete where.{{fieldName}}; + } + {{/relationType.isOTO}} + + {{#relationType.isModifier}} + const { {{fieldName}}_some, {{fieldName}}_none, {{fieldName}}_every } = where + if ({{fieldName}}_some || {{fieldName}}_none || {{fieldName}}_every) { + const result = await this.{{fieldName}}Service + .getQueryBuilder({{fieldName}}_some || {{fieldName}}_none || {{fieldName}}_every) + .leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}') + .getMany(); + let {{relatedTsProp}}Ids: string[] = [] + {{#relationType.isOTM}} + {{relatedTsProp}}Ids = Array.from(new Set(result.map(v => v.{{relatedTsProp}}.id))); + {{/relationType.isOTM}} + {{#relationType.isMTM}} + Array.from(new Set(result.map(v => v.{{relatedTsProp}}.map(c => {{relatedTsProp}}Ids.push(c.id))))); + {{/relationType.isMTM}} + + delete where.{{fieldName}}_some + delete where.{{fieldName}}_none + delete where.{{fieldName}}_every + + {{#relationType.isOTM}} + if ({{fieldName}}_some) { + where.id_in = {{relatedTsProp}}Ids; + } + {{/relationType.isOTM}} + + {{#relationType.isMTM}} + if ({{fieldName}}_some || {{fieldName}}_every) { + where.id_in = {{relatedTsProp}}Ids; + } + {{/relationType.isMTM}} + + + if ({{fieldName}}_none) { + where.id_in = ( + await getRepository({{returnTypeFunc}}).find({ + select: ['id'], + where: { id: Not(In({{relatedTsProp}}Ids)) } + }) + ).map(c => c.id); + } + + {{#relationType.isOTM}} + if ({{fieldName}}_every) { + getConnection().transaction(async em => { + const finalIds = []; + + // Group entity + const g = _.chain(result) + .groupBy(v => v.{{relatedTsProp}}.id) + .value(); + + // Get all entities with their relations without filtering them + for (const { id, {{fieldName}} } of await em.getRepository({{rootArgType}}).find({ + where: { id: In({{relatedTsProp}}Ids) }, + relations: ['{{fieldName}}'] + })) { + if ({{fieldName}} && {{fieldName}}.length === g[id].length) { + finalIds.push(id); + } + } + where.id_in = finalIds; + }); + } + {{/relationType.isOTM}} + } + {{/relationType.isModifier}} + + {{#relationType.isMTO}} + const { {{fieldName}} } = where + if ({{fieldName}}) { + const entityIds: string[] = []; + ( + await this.{{fieldName}}Service + .getQueryBuilder({{fieldName}}) + .leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}') + .getMany() + ).map((c: any) => entityIds.push(...c.{{relatedTsProp}}!.map((v: any) => v.id))); + where.id_in = entityIds; + delete where.{{fieldName}}; + } + {{/relationType.isMTO}} + {{/fieldResolvers}} + + if (id_in) { + where.id_in = where.id_in ? [...id_in, ...where.id_in] : id_in; + } + } + {{/crossFilters}} return this.service.find<{{className}}WhereInput>(where, orderBy, limit, offset, fields); } diff --git a/packages/hydra-cli/test/helpers/ModelRenderer.test.ts b/packages/hydra-cli/test/helpers/ModelRenderer.test.ts index 2df3df08a..1997a16a3 100644 --- a/packages/hydra-cli/test/helpers/ModelRenderer.test.ts +++ b/packages/hydra-cli/test/helpers/ModelRenderer.test.ts @@ -126,7 +126,7 @@ describe('ModelRenderer', () => { `Should render imports` ) expect(rendered).to.include( - `@OneToMany(() => Post, (param: Post) => param.author)`, + `@OneToMany(() => Post, (param: Post) => param.author,`, 'Should render OTM decorator' ) expect(rendered).to.include( @@ -155,7 +155,7 @@ describe('ModelRenderer', () => { `Should render imports` ) expect(rendered).to.include( - `@ManyToOne(() => Author, (param: Author) => param.postauthor, { skipGraphQLField: true })`, + `@ManyToOne(() => Author, (param: Author) => param.postauthor,`, 'Should render MTO decorator' ) // nullable: true is not includered? expect(rendered).to.include( @@ -181,7 +181,9 @@ describe('ModelRenderer', () => { debug(`rendered: ${JSON.stringify(rendered, null, 2)}`) expect(rendered).to.include( - `@ManyToOne(() => Language, (param: Language) => param.channellanguage, { skipGraphQLField: true, nullable: true })`, + `@ManyToOne(() => Language, (param: Language) => param.channellanguage, { + skipGraphQLField: true, + nullable: true,`, 'Should render MTO decorator with nullable option' ) }) diff --git a/yarn.lock b/yarn.lock index af7010ba0..5c72a9584 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11835,25 +11835,11 @@ pg-connection-string@^2.4.0, pg-connection-string@^2.5.0: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== -pg-format@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/pg-format/-/pg-format-1.0.4.tgz#27734236c2ad3f4e5064915a59334e20040a828e" - integrity sha1-J3NCNsKtP05QZJFaWTNOIAQKgo4= - pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-listen@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/pg-listen/-/pg-listen-1.7.0.tgz#5a5c68a1cabf88d2b78ed9cf133667f597d3b860" - integrity sha512-MKDwKLm4ryhy7iq1yw1K1MvUzBdTkaT16HZToddX9QaT8XSdt3Kins5mYH6DLECGFzFWG09VdXvWOIYogjXrsg== - dependencies: - debug "^4.1.1" - pg-format "^1.0.4" - typed-emitter "^0.1.0" - pg-packet-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pg-packet-stream/-/pg-packet-stream-1.1.0.tgz#e45c3ae678b901a2873af1e17b92d787962ef914" @@ -14158,7 +14144,7 @@ ts-mockito@^2.6.1: dependencies: lodash "^4.17.5" -ts-node-dev@^1.0.0-pre.40, ts-node-dev@^1.0.0-pre.60, ts-node-dev@^1.0.0-pre.63: +ts-node-dev@^1.0.0-pre.40, ts-node-dev@^1.0.0-pre.63: version "1.1.6" resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.1.6.tgz#ee2113718cb5a92c1c8f4229123ad6afbeba01f8" integrity sha512-RTUi7mHMNQospArGz07KiraQcdgUVNXKsgO2HAi7FoiyPMdTDqdniB6K1dqyaIxT7c9v/VpSbfBZPS6uVpaFLQ== @@ -14189,7 +14175,7 @@ ts-node@^7.0.1: source-map-support "^0.5.6" yn "^2.0.0" -ts-node@^8, ts-node@^8.10: +ts-node@^8: version "8.10.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d" integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA== @@ -14368,11 +14354,6 @@ type@^2.0.0: resolved "https://registry.yarnpkg.com/type/-/type-2.5.0.tgz#0a2e78c2e77907b252abe5f298c1b01c63f0db3d" integrity sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw== -typed-emitter@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-0.1.0.tgz#ca532f100ccbf850e3a73b8ebf43d43e4f1f3849" - integrity sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg== - typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -14770,9 +14751,9 @@ warthog@^2.20.0: typeorm-typedi-extensions "^0.2.3" typescript "^3.9.7" -"warthog@/~https://github.com/metmirr/warthog/releases/download/v2.23.0/warthog-v2.23.0.tgz": - version "2.23.0" - resolved "/~https://github.com/metmirr/warthog/releases/download/v2.23.0/warthog-v2.23.0.tgz#4582fc35554580e0af0f43a9b3725aad2eb808c6" +"warthog@/~https://github.com/metmirr/warthog/releases/download/v2.26.0/warthog-v2.26.0.tgz": + version "2.26.0" + resolved "/~https://github.com/metmirr/warthog/releases/download/v2.26.0/warthog-v2.26.0.tgz#0a76a8710c392992e7695b45ca0a8efc81ad4c9a" dependencies: "@types/app-root-path" "^1.2.4" "@types/bn.js" "^4.11.6" @@ -14800,6 +14781,7 @@ warthog@^2.20.0: apollo-server "^2.9.9" apollo-server-express "^2.9.9" app-root-path "^3.0.0" + bn.js "^5.2.0" caller "^1.0.1" class-transformer "^0.2.3" class-validator "^0.11.0"