From 06c25681aa3a7eee72632987b40a5325793e7a09 Mon Sep 17 00:00:00 2001 From: nicola-smartive <112560347+nicola-smartive@users.noreply.github.com> Date: Thu, 23 Nov 2023 14:09:46 +0100 Subject: [PATCH] feat: Reverse filter (#108) --- src/models/model-definitions.ts | 1 + src/resolvers/filters.ts | 49 +++++++++++--- src/resolvers/utils.ts | 2 + src/schema/generate.ts | 17 +++-- tests/api/__snapshots__/query.spec.ts.snap | 74 ++++++++++++++++++++++ tests/api/query.spec.ts | 29 +++++++++ tests/generated/api/index.ts | 2 + tests/generated/client/index.ts | 40 +++++------- tests/generated/models.json | 4 +- tests/generated/schema.graphql | 2 + tests/utils/database/seed.ts | 21 ++++++ tests/utils/models.ts | 2 + 12 files changed, 203 insertions(+), 40 deletions(-) diff --git a/src/models/model-definitions.ts b/src/models/model-definitions.ts index 9fd3da4..5d10965 100644 --- a/src/models/model-definitions.ts +++ b/src/models/model-definitions.ts @@ -63,6 +63,7 @@ export type EntityFieldDefinition = FieldDefinitionBase & | { default?: Value; }; + reverseFilterable?: boolean; searchable?: boolean; orderable?: boolean; comparable?: boolean; diff --git a/src/resolvers/filters.ts b/src/resolvers/filters.ts index 18ba3e2..4262a57 100644 --- a/src/resolvers/filters.ts +++ b/src/resolvers/filters.ts @@ -3,19 +3,19 @@ import { EntityModel, FullContext } from '..'; import { ForbiddenError, UserInputError } from '../errors'; import { OrderBy, Where, normalizeArguments } from './arguments'; import { FieldResolverNode } from './node'; -import { Joins, Ops, addJoin, apply, getColumn, ors } from './utils'; +import { Joins, QueryBuilderOps, addJoin, apply, applyJoins, getColumn, ors } from './utils'; export const SPECIAL_FILTERS: Record = { GT: '?? > ?', GTE: '?? >= ?', LT: '?? < ?', LTE: '?? <= ?', + SOME: 'SOME', }; export type WhereNode = { ctx: FullContext; - rootModel: EntityModel; rootTableAlias: string; model: EntityModel; @@ -69,7 +69,7 @@ export const applyFilters = (node: FieldResolverNode, query: Knex.QueryBuilder, } if (where) { - const ops: Ops = []; + const ops: QueryBuilderOps = []; applyWhere(node, where, ops, joins); void apply(query, ops); } @@ -79,13 +79,48 @@ export const applyFilters = (node: FieldResolverNode, query: Knex.QueryBuilder, } }; -const applyWhere = (node: WhereNode, where: Where, ops: Ops, joins: Joins) => { +const applyWhere = (node: WhereNode, where: Where, ops: QueryBuilderOps, joins: Joins) => { + const aliases = node.ctx.aliases; + for (const key of Object.keys(where)) { const value = where[key]; const specialFilter = key.match(/^(\w+)_(\w+)$/); if (specialFilter) { const [, actualKey, filter] = specialFilter; + + if (filter === 'SOME') { + const reverseRelation = node.model.getReverseRelation(actualKey); + const rootTableAlias = `${node.tableAlias}__W__${key}`; + const targetModel = reverseRelation.targetModel; + const tableAlias = targetModel === targetModel.rootModel ? rootTableAlias : `${node.tableAlias}__WS_${key}`; + + const subWhereNode: WhereNode = { + ctx: node.ctx, + rootTableAlias, + model: targetModel, + tableAlias, + }; + const subOps: QueryBuilderOps = []; + const subJoins: Joins = []; + applyWhere(subWhereNode, value as Where, subOps, subJoins); + + // TODO: make this work with subtypes + ops.push((query) => + query.whereExists((subQuery) => { + void subQuery + .from(`${targetModel.name} as ${aliases.getShort(tableAlias)}`) + .whereRaw(`?? = ??`, [ + `${aliases.getShort(tableAlias)}.${reverseRelation.field.foreignKey}`, + `${aliases.getShort(node.tableAlias)}.id`, + ]); + void apply(subQuery, subOps); + applyJoins(aliases, subQuery, subJoins); + }) + ); + continue; + } + if (!SPECIAL_FILTERS[filter]) { // Should not happen throw new Error(`Invalid filter ${key}.`); @@ -99,15 +134,11 @@ const applyWhere = (node: WhereNode, where: Where, ops: Ops, if (field.kind === 'relation') { const relation = node.model.getRelation(field.name); const targetModel = relation.targetModel; - const rootModel = targetModel.parentModel || targetModel; const rootTableAlias = `${node.model.name}__W__${key}`; - const tableAlias = targetModel === rootModel ? rootTableAlias : `${node.model.name}__WS__${key}`; + const tableAlias = targetModel === targetModel.rootModel ? rootTableAlias : `${node.model.name}__WS__${key}`; const subNode: WhereNode = { ctx: node.ctx, - - rootModel, rootTableAlias, - model: targetModel, tableAlias, }; diff --git a/src/resolvers/utils.ts b/src/resolvers/utils.ts index 0bdda89..1752e94 100644 --- a/src/resolvers/utils.ts +++ b/src/resolvers/utils.ts @@ -126,6 +126,8 @@ export const getNameOrAlias = (node: FieldNode) => { export type Ops = ((target: T) => T)[]; +export type QueryBuilderOps = Ops; + export const apply = (target: T, ops: ((target: T) => T)[]) => ops.reduce((target, op) => op(target), target); type Join = { table1Alias: string; column1: string; table2Name: string; table2Alias: string; column2: string }; diff --git a/src/schema/generate.ts b/src/schema/generate.ts index eb6bb24..bf08448 100644 --- a/src/schema/generate.ts +++ b/src/schema/generate.ts @@ -1,6 +1,6 @@ import { DefinitionNode, DocumentNode, GraphQLSchema, buildASTSchema, print } from 'graphql'; import { Models } from '../models/models'; -import { isQueriableField, isRelation, isRootModel, typeToField } from '../models/utils'; +import { isQueriableField, isRootModel, typeToField } from '../models/utils'; import { Field, document, enm, iface, input, object, scalar } from './utils'; export const generateDefinitions = ({ @@ -77,12 +77,17 @@ export const generateDefinitions = ({ { name: `${field.name}_LT`, type: field.type }, { name: `${field.name}_LTE`, type: field.type }, ]), - ...model.fields - .filter(isRelation) - .filter(({ filterable }) => filterable) - .map(({ name, type }) => ({ + ...model.relations + .filter(({ field: { filterable } }) => filterable) + .map(({ name, targetModel }) => ({ name, - type: `${type}Where`, + type: `${targetModel.name}Where`, + })), + ...model.reverseRelations + .filter(({ field: { reverseFilterable } }) => reverseFilterable) + .map((relation) => ({ + name: `${relation.name}_SOME`, + type: `${relation.targetModel.name}Where`, })), ]), input( diff --git a/tests/api/__snapshots__/query.spec.ts.snap b/tests/api/__snapshots__/query.spec.ts.snap index 992df95..bde623f 100644 --- a/tests/api/__snapshots__/query.spec.ts.snap +++ b/tests/api/__snapshots__/query.spec.ts.snap @@ -3,6 +3,20 @@ exports[`query can be executed 1`] = ` { "manyObjects": [ + { + "another": { + "id": "226a20e8-5c18-4423-99ca-eb0df6ff4fdd", + "manyObjects": [ + { + "field": null, + "id": "604ab55d-ec3e-4857-9f27-219158f80e64", + }, + ], + }, + "field": null, + "id": "fc4e013e-4cb0-4ef8-9f2e-3d475bdf2b90", + "xyz": 2, + }, { "another": { "id": "226a20e8-5c18-4423-99ca-eb0df6ff4fdd", @@ -20,3 +34,63 @@ exports[`query can be executed 1`] = ` ], } `; + +exports[`query processes reverseFilters correctly 1`] = ` +{ + "all": [ + { + "id": "226a20e8-5c18-4423-99ca-eb0df6ff4fdd", + "manyObjects": [ + { + "float": 0.5, + }, + { + "float": 0, + }, + ], + }, + { + "id": "ba5d94a8-0035-4e45-9258-2f7676eb8d18", + "manyObjects": [ + { + "float": 0.5, + }, + ], + }, + ], + "withFloat0": [ + { + "id": "226a20e8-5c18-4423-99ca-eb0df6ff4fdd", + "manyObjects": [ + { + "float": 0.5, + }, + { + "float": 0, + }, + ], + }, + ], + "withFloat0_5": [ + { + "id": "226a20e8-5c18-4423-99ca-eb0df6ff4fdd", + "manyObjects": [ + { + "float": 0.5, + }, + { + "float": 0, + }, + ], + }, + { + "id": "ba5d94a8-0035-4e45-9258-2f7676eb8d18", + "manyObjects": [ + { + "float": 0.5, + }, + ], + }, + ], +} +`; diff --git a/tests/api/query.spec.ts b/tests/api/query.spec.ts index 148ff7e..1655e5a 100644 --- a/tests/api/query.spec.ts +++ b/tests/api/query.spec.ts @@ -25,4 +25,33 @@ describe('query', () => { ).toMatchSnapshot(); }); }); + + it('processes reverseFilters correctly', async () => { + await withServer(async (request) => { + expect( + await request(gql` + query ReverseFiltersQuery { + all: anotherObjects { + id + manyObjects { + float + } + } + withFloat0: anotherObjects(where: { manyObjects_SOME: { float: 0 } }) { + id + manyObjects { + float + } + } + withFloat0_5: anotherObjects(where: { manyObjects_SOME: { float: 0.5 } }) { + id + manyObjects { + float + } + } + } + `) + ).toMatchSnapshot(); + }); + }); }); diff --git a/tests/generated/api/index.ts b/tests/generated/api/index.ts index 725a675..b8acfe8 100644 --- a/tests/generated/api/index.ts +++ b/tests/generated/api/index.ts @@ -56,6 +56,7 @@ export type AnotherObjectOrderBy = { export type AnotherObjectWhere = { deleted?: InputMaybe>; id?: InputMaybe>; + manyObjects_SOME?: InputMaybe; }; export type AnotherObjectWhereUnique = { @@ -612,6 +613,7 @@ export type SomeObjectOrderBy = { export type SomeObjectWhere = { another?: InputMaybe; deleted?: InputMaybe>; + float?: InputMaybe>; id?: InputMaybe>; }; diff --git a/tests/generated/client/index.ts b/tests/generated/client/index.ts index f5cc3a0..5a06e0a 100644 --- a/tests/generated/client/index.ts +++ b/tests/generated/client/index.ts @@ -53,6 +53,7 @@ export type AnotherObjectOrderBy = { export type AnotherObjectWhere = { deleted?: InputMaybe>; id?: InputMaybe>; + manyObjects_SOME?: InputMaybe; }; export type AnotherObjectWhereUnique = { @@ -609,6 +610,7 @@ export type SomeObjectOrderBy = { export type SomeObjectWhere = { another?: InputMaybe; deleted?: InputMaybe>; + float?: InputMaybe>; id?: InputMaybe>; }; @@ -851,6 +853,11 @@ export type SomeQueryQueryVariables = Exact<{ [key: string]: never; }>; export type SomeQueryQuery = { manyObjects: Array<{ __typename: 'SomeObject', id: string, field: string | null, xyz: number, another: { __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', id: string, field: string | null }> } }> }; +export type ReverseFiltersQueryQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ReverseFiltersQueryQuery = { first: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, second: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }> }; + export type DeleteAnotherObjectMutationMutationVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -981,16 +988,6 @@ export type RestoreAnswerMutationMutationVariables = Exact<{ export type RestoreAnswerMutationMutation = { restoreAnswer: string }; -export type AnotherQueryQueryVariables = Exact<{ [key: string]: never; }>; - - -export type AnotherQueryQuery = { manyObjects: Array<{ __typename: 'SomeObject', id: string, field: string | null, another: { __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', id: string, field: string | null }> } }> }; - -export type YetAnotherQueryQueryVariables = Exact<{ [key: string]: never; }>; - - -export type YetAnotherQueryQuery = { someObject: { __typename: 'SomeObject', id: string, field: string | null } }; - type DiscriminateUnion = T extends U ? T : never; export namespace DeleteAnotherObject { @@ -1050,6 +1047,15 @@ export namespace SomeQuery { export type _manyObjects = NonNullable<(NonNullable<(NonNullable)[number]>['another']>)['manyObjects']>)[number]>; } +export namespace ReverseFiltersQuery { + export type Variables = ReverseFiltersQueryQueryVariables; + export type query = ReverseFiltersQueryQuery; + export type first = NonNullable<(NonNullable)[number]>; + export type manyObjects = NonNullable<(NonNullable)[number]>['manyObjects']>)[number]>; + export type second = NonNullable<(NonNullable)[number]>; + export type _manyObjects = NonNullable<(NonNullable)[number]>['manyObjects']>)[number]>; +} + export namespace DeleteAnotherObjectMutation { export type Variables = DeleteAnotherObjectMutationMutationVariables; export type mutation = DeleteAnotherObjectMutationMutation; @@ -1147,17 +1153,3 @@ export namespace RestoreAnswerMutation { export type Variables = RestoreAnswerMutationMutationVariables; export type mutation = RestoreAnswerMutationMutation; } - -export namespace AnotherQuery { - export type Variables = AnotherQueryQueryVariables; - export type query = AnotherQueryQuery; - export type manyObjects = NonNullable<(NonNullable)[number]>; - export type another = (NonNullable)[number]>['another']>); - export type _manyObjects = NonNullable<(NonNullable<(NonNullable)[number]>['another']>)['manyObjects']>)[number]>; -} - -export namespace YetAnotherQuery { - export type Variables = YetAnotherQueryQueryVariables; - export type query = YetAnotherQueryQuery; - export type someObject = (NonNullable); -} diff --git a/tests/generated/models.json b/tests/generated/models.json index d38c483..141561d 100644 --- a/tests/generated/models.json +++ b/tests/generated/models.json @@ -132,6 +132,7 @@ "kind": "relation", "type": "AnotherObject", "filterable": true, + "reverseFilterable": true, "updatable": true, "nonNull": true, "foreignKey": "anotherId" @@ -141,7 +142,8 @@ "type": "Float", "scale": 1, "precision": 1, - "nonNull": true + "nonNull": true, + "filterable": true }, { "kind": "enum", diff --git a/tests/generated/schema.graphql b/tests/generated/schema.graphql index e14885a..eb22ffd 100644 --- a/tests/generated/schema.graphql +++ b/tests/generated/schema.graphql @@ -17,6 +17,7 @@ input AnotherObjectOrderBy { input AnotherObjectWhere { id: [ID!] deleted: [Boolean!] + manyObjects_SOME: SomeObjectWhere } input AnotherObjectWhereUnique { @@ -262,6 +263,7 @@ input SomeObjectOrderBy { input SomeObjectWhere { id: [ID!] + float: [Float!] deleted: [Boolean!] another: AnotherObjectWhere } diff --git a/tests/utils/database/seed.ts b/tests/utils/database/seed.ts index fb38d8a..08750ec 100644 --- a/tests/utils/database/seed.ts +++ b/tests/utils/database/seed.ts @@ -8,7 +8,10 @@ import { models } from '../models'; export const ADMIN_ID = '04e45b48-04cf-4b38-bb25-b9af5ae0b2c4'; export const SOME_ID = '604ab55d-ec3e-4857-9f27-219158f80e64'; +export const SOME_ID_2 = 'fc4e013e-4cb0-4ef8-9f2e-3d475bdf2b90'; +export const SOME_ID_3 = '37a23870-e7f5-45c8-86e5-f14d9f2405f9'; export const ANOTHER_ID = '226a20e8-5c18-4423-99ca-eb0df6ff4fdd'; +export const ANOTHER_ID_2 = 'ba5d94a8-0035-4e45-9258-2f7676eb8d18'; export const QUESTION_ID = '3d0f3254-282f-4f1f-95e3-c1f699f3c1e5'; export const ANSWER_ID = 'f2d7b3f1-8ea1-4c2c-91ec-024432da1b0d'; export const REVIEW_ID = '817c55de-2f77-4159-bd44-9837d868f889'; @@ -26,6 +29,10 @@ export const seed: SeedData = { id: ANOTHER_ID, myselfId: ANOTHER_ID, }, + { + id: ANOTHER_ID_2, + myselfId: ANOTHER_ID, + }, ], SomeObject: [ { @@ -35,6 +42,20 @@ export const seed: SeedData = { list: ['A'], xyz: 1, }, + { + id: SOME_ID_2, + anotherId: ANOTHER_ID, + float: 0.5, + list: ['B'], + xyz: 2, + }, + { + id: SOME_ID_3, + anotherId: ANOTHER_ID_2, + float: 0.5, + list: ['B'], + xyz: 2, + }, ], Question: [ { diff --git a/tests/utils/models.ts b/tests/utils/models.ts index caaa8e1..13c8232 100644 --- a/tests/utils/models.ts +++ b/tests/utils/models.ts @@ -76,6 +76,7 @@ const modelDefinitions: ModelDefinitions = [ kind: 'relation', type: 'AnotherObject', filterable: true, + reverseFilterable: true, updatable: true, nonNull: true, }, @@ -85,6 +86,7 @@ const modelDefinitions: ModelDefinitions = [ scale: 1, precision: 1, nonNull: true, + filterable: true, }, { kind: 'enum',