From 31b6dec6236f253adbae4e6ca65f175669bfea8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Tkaczy=C5=84ski?= Date: Tue, 5 Sep 2023 15:52:25 +0200 Subject: [PATCH 1/3] feat: add nested entity filters --- src/graphql/controller.ts | 147 +++++++++++------- src/graphql/resolvers.ts | 91 ++++++++--- .../__snapshots__/controller.test.ts.snap | 4 +- 3 files changed, 162 insertions(+), 80 deletions(-) diff --git a/src/graphql/controller.ts b/src/graphql/controller.ts index 8a77e6b..019fc81 100644 --- a/src/graphql/controller.ts +++ b/src/graphql/controller.ts @@ -49,6 +49,15 @@ const GraphQLOrderDirection = new GraphQLEnumType({ } }); +type WhereResult = { + where: { + type: GraphQLInputObjectType; + }; + orderByValues: Record; +}; + +const cache = new Map(); + /** * Controller for performing actions based on the graphql schema provided to its * constructor. It exposes public functions to generate graphql or database @@ -297,78 +306,96 @@ export class GqlEntityController { type: GraphQLObjectType, resolver: GraphQLFieldResolver ): GraphQLFieldConfig { - const whereInputConfig: GraphQLInputObjectTypeConfig = { - name: `Where${type.name}`, - fields: {} - }; + const getWhereType = (type: GraphQLObjectType): WhereResult => { + const name = `${type.name}_filter`; + + const cachedValue = cache.get(name); + if (cachedValue) return cachedValue; + + const orderByValues = {}; + const whereInputConfig: GraphQLInputObjectTypeConfig = { + name, + fields: {} + }; + + this.getTypeFields(type).forEach(field => { + // all field types in a where input variable must be optional + // so we try to extract the non null type here. + let nonNullFieldType = getNonNullType(field.type); + + if (nonNullFieldType instanceof GraphQLObjectType) { + const fields = type.getFields(); + const idField = fields['id']; + + if ( + idField && + idField.type instanceof GraphQLNonNull && + idField.type.ofType instanceof GraphQLScalarType && + ['String', 'ID'].includes(idField.type.ofType.name) + ) { + whereInputConfig.fields[`${field.name}_`] = getWhereType(nonNullFieldType).where; + + nonNullFieldType = getNonNullType(idField.type); + } + } - const orderByValues = {}; + // avoid setting up where filters for non scalar types + if (!isLeafType(nonNullFieldType)) { + return; + } - this.getTypeFields(type).forEach(field => { - // all field types in a where input variable must be optional - // so we try to extract the non null type here. - let nonNullFieldType = getNonNullType(field.type); + if (nonNullFieldType === GraphQLInt) { + whereInputConfig.fields[`${field.name}_gt`] = { type: GraphQLInt }; + whereInputConfig.fields[`${field.name}_gte`] = { type: GraphQLInt }; + whereInputConfig.fields[`${field.name}_lt`] = { type: GraphQLInt }; + whereInputConfig.fields[`${field.name}_lte`] = { type: GraphQLInt }; + } - if (nonNullFieldType instanceof GraphQLObjectType) { - const fields = type.getFields(); - const idField = fields['id']; + if ( + (nonNullFieldType instanceof GraphQLScalarType && nonNullFieldType.name === 'BigInt') || + this.decimalTypes[nonNullFieldType.name] + ) { + whereInputConfig.fields[`${field.name}_gt`] = { type: nonNullFieldType }; + whereInputConfig.fields[`${field.name}_gte`] = { type: nonNullFieldType }; + whereInputConfig.fields[`${field.name}_lt`] = { type: nonNullFieldType }; + whereInputConfig.fields[`${field.name}_lte`] = { type: nonNullFieldType }; + } if ( - idField && - idField.type instanceof GraphQLNonNull && - idField.type.ofType instanceof GraphQLScalarType && - ['String', 'ID'].includes(idField.type.ofType.name) + nonNullFieldType === GraphQLString || + (nonNullFieldType as GraphQLScalarType).name === 'Text' ) { - nonNullFieldType = getNonNullType(idField.type); + whereInputConfig.fields[`${field.name}_contains`] = { type: GraphQLString }; + whereInputConfig.fields[`${field.name}_not_contains`] = { type: GraphQLString }; + whereInputConfig.fields[`${field.name}_contains_nocase`] = { type: GraphQLString }; + whereInputConfig.fields[`${field.name}_not_contains_nocase`] = { type: GraphQLString }; } - } - // avoid setting up where filters for non scalar types - if (!isLeafType(nonNullFieldType)) { - return; - } + if ((nonNullFieldType as GraphQLScalarType).name !== 'Text') { + whereInputConfig.fields[`${field.name}`] = { type: nonNullFieldType }; + whereInputConfig.fields[`${field.name}_not`] = { type: nonNullFieldType }; + whereInputConfig.fields[`${field.name}_in`] = { + type: new GraphQLList(nonNullFieldType) + }; + whereInputConfig.fields[`${field.name}_not_in`] = { + type: new GraphQLList(nonNullFieldType) + }; + } - if (nonNullFieldType === GraphQLInt) { - whereInputConfig.fields[`${field.name}_gt`] = { type: GraphQLInt }; - whereInputConfig.fields[`${field.name}_gte`] = { type: GraphQLInt }; - whereInputConfig.fields[`${field.name}_lt`] = { type: GraphQLInt }; - whereInputConfig.fields[`${field.name}_lte`] = { type: GraphQLInt }; - } + orderByValues[field.name] = { value: field.name }; + }); - if ( - (nonNullFieldType instanceof GraphQLScalarType && nonNullFieldType.name === 'BigInt') || - this.decimalTypes[nonNullFieldType.name] - ) { - whereInputConfig.fields[`${field.name}_gt`] = { type: nonNullFieldType }; - whereInputConfig.fields[`${field.name}_gte`] = { type: nonNullFieldType }; - whereInputConfig.fields[`${field.name}_lt`] = { type: nonNullFieldType }; - whereInputConfig.fields[`${field.name}_lte`] = { type: nonNullFieldType }; - } + const result = { + where: { type: new GraphQLInputObjectType(whereInputConfig) }, + orderByValues + }; - if ( - nonNullFieldType === GraphQLString || - (nonNullFieldType as GraphQLScalarType).name === 'Text' - ) { - whereInputConfig.fields[`${field.name}_contains`] = { type: GraphQLString }; - whereInputConfig.fields[`${field.name}_not_contains`] = { type: GraphQLString }; - whereInputConfig.fields[`${field.name}_contains_nocase`] = { type: GraphQLString }; - whereInputConfig.fields[`${field.name}_not_contains_nocase`] = { type: GraphQLString }; - } + cache.set(name, result); - if ((nonNullFieldType as GraphQLScalarType).name !== 'Text') { - whereInputConfig.fields[`${field.name}`] = { type: nonNullFieldType }; - whereInputConfig.fields[`${field.name}_not`] = { type: nonNullFieldType }; - whereInputConfig.fields[`${field.name}_in`] = { - type: new GraphQLList(nonNullFieldType) - }; - whereInputConfig.fields[`${field.name}_not_in`] = { - type: new GraphQLList(nonNullFieldType) - }; - } + return result; + }; - // add fields to orderBy enum - orderByValues[field.name] = { value: field.name }; - }); + const { where, orderByValues } = getWhereType(type); const OrderByEnum = new GraphQLEnumType({ name: `OrderBy${type.name}Fields`, @@ -390,7 +417,7 @@ export class GqlEntityController { orderDirection: { type: GraphQLOrderDirection }, - where: { type: new GraphQLInputObjectType(whereInputConfig) } + where }, resolve: resolver }; diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index b686e6d..a8ed7b8 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -5,7 +5,8 @@ import { GraphQLObjectType, GraphQLResolveInfo, GraphQLScalarType, - isListType + isListType, + isScalarType } from 'graphql'; import { parseResolveInfo, @@ -38,38 +39,64 @@ export async function queryMulti(parent, args, context: ResolverContext, info) { const tableName = getTableName(returnType.name.toLowerCase()); - let query = knex.select('*').from(tableName); + const nestedEntitiesMappings = {} as Record>; - if (args.where) { - Object.entries(args.where).map((w: [string, any]) => { - // TODO: we could generate args.where as objects { name, column, operator, value } + let query = knex.select(`${tableName}.*`).from(tableName); + const handleWhere = (query: Knex.QueryBuilder, prefix: string, where: Record) => { + Object.entries(where).map((w: [string, any]) => { + // TODO: we could generate where as objects { name, column, operator, value } // so we don't have to cut it there if (w[0].endsWith('_not')) { - query = query.where(w[0].slice(0, -4), '!=', w[1]); + query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '!=', w[1]); } else if (w[0].endsWith('_gt')) { - query = query.where(w[0].slice(0, -3), '>', w[1]); + query = query.where(`${prefix}.${w[0].slice(0, -3)}`, '>', w[1]); } else if (w[0].endsWith('_gte')) { - query = query.where(w[0].slice(0, -4), '>=', w[1]); + query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '>=', w[1]); } else if (w[0].endsWith('_lt')) { - query = query.where(w[0].slice(0, -3), '<', w[1]); + query = query.where(`${prefix}.${w[0].slice(0, -3)}`, '<', w[1]); } else if (w[0].endsWith('_lte')) { - query = query.where(w[0].slice(0, -4), '<=', w[1]); + query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '<=', w[1]); } else if (w[0].endsWith('_not_contains')) { - query = query.not.whereLike(w[0].slice(0, -13), `%${w[1]}%`); + query = query.not.whereLike(`${prefix}.${w[0].slice(0, -13)}`, `%${w[1]}%`); } else if (w[0].endsWith('_not_contains_nocase')) { - query = query.not.whereILike(w[0].slice(0, -20), `%${w[1]}%`); + query = query.not.whereILike(`${prefix}.${w[0].slice(0, -20)}`, `%${w[1]}%`); } else if (w[0].endsWith('_contains')) { - query = query.whereLike(w[0].slice(0, -9), `%${w[1]}%`); + query = query.whereLike(`${prefix}.${w[0].slice(0, -9)}`, `%${w[1]}%`); } else if (w[0].endsWith('_contains_nocase')) { - query = query.whereILike(w[0].slice(0, -16), `%${w[1]}%`); + query = query.whereILike(`${prefix}.${w[0].slice(0, -16)}`, `%${w[1]}%`); } else if (w[0].endsWith('_not_in')) { - query = query.not.whereIn(w[0].slice(0, -7), w[1]); + query = query.not.whereIn(`${prefix}.${w[0].slice(0, -7)}`, w[1]); } else if (w[0].endsWith('_in')) { - query = query.whereIn(w[0].slice(0, -3), w[1] as any); + query = query.whereIn(`${prefix}.${w[0].slice(0, -3)}`, w[1]); + } else if (typeof w[1] === 'object' && w[0].endsWith('_')) { + const fieldName = w[0].slice(0, -1); + const nestedReturnType = returnType.getFields()[fieldName].type as GraphQLObjectType; + const nestedTableName = getTableName(nestedReturnType.name.toLowerCase()); + + const fields = Object.values(nestedReturnType.getFields()) + .filter(field => isScalarType(field.type)) + .map(field => field.name); + + nestedEntitiesMappings[fieldName] = { + [`${fieldName}.id`]: `${nestedTableName}.id`, + ...Object.fromEntries( + fields.map(field => [`${fieldName}.${field}`, `${nestedTableName}.${field}`]) + ) + }; + + query = query + .columns(nestedEntitiesMappings[fieldName]) + .innerJoin(nestedTableName, `${tableName}.${fieldName}`, '=', `${nestedTableName}.id`); + + handleWhere(query, nestedTableName, w[1]); } else { query = query.where(w[0], w[1]); } }); + }; + + if (args.where) { + handleWhere(query, tableName, args.where); } if (args.orderBy) { @@ -80,7 +107,28 @@ export async function queryMulti(parent, args, context: ResolverContext, info) { log.debug({ sql: query.toQuery(), args }, 'executing multi query'); const result = await query; - return result.map(item => formatItem(item, jsonFields)); + return result.map(item => { + const nested = Object.fromEntries( + Object.entries(nestedEntitiesMappings).map(([fieldName, mapping]) => { + return [ + fieldName, + Object.fromEntries( + Object.entries(mapping).map(([to, from]) => { + const exploded = from.split('.'); + const key = exploded[exploded.length - 1]; + + return [key, item[to]]; + }) + ) + ]; + }) + ); + + return { + ...formatItem(item, jsonFields), + ...nested + }; + }); } export async function querySingle( @@ -92,7 +140,14 @@ export async function querySingle( const returnType = getNonNullType(info.returnType) as GraphQLObjectType; const jsonFields = getJsonFields(returnType); - const id = parent?.[info.fieldName] || args.id; + const currentValue = parent?.[info.fieldName]; + + const alreadyResolvedInParent = typeof currentValue === 'object'; + if (alreadyResolvedInParent) { + return formatItem(currentValue, jsonFields); + } + + const id = currentValue || args.id; const parsed = parseResolveInfo(info); if (parsed) { diff --git a/test/unit/graphql/__snapshots__/controller.test.ts.snap b/test/unit/graphql/__snapshots__/controller.test.ts.snap index b168c4d..6c40db1 100644 --- a/test/unit/graphql/__snapshots__/controller.test.ts.snap +++ b/test/unit/graphql/__snapshots__/controller.test.ts.snap @@ -19,7 +19,7 @@ create index \`votes_big_decimal_index\` on \`votes\` (\`big_decimal\`)" exports[`GqlEntityController generateQueryFields should work 1`] = ` "type Query { vote(id: Int!): Vote - votes(first: Int, skip: Int, orderBy: OrderByVoteFields, orderDirection: OrderDirection, where: WhereVote): [Vote] + votes(first: Int, skip: Int, orderBy: OrderByVoteFields, orderDirection: OrderDirection, where: Vote_filter): [Vote] } type Vote { @@ -38,7 +38,7 @@ enum OrderDirection { desc } -input WhereVote { +input Vote_filter { id_gt: Int id_gte: Int id_lt: Int From 16f5409b51907e30048d95e15bf7c9d2fba6b22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Tkaczy=C5=84ski?= Date: Wed, 6 Sep 2023 09:08:08 +0200 Subject: [PATCH 2/3] fix: add prefix to equal where query --- src/graphql/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index a8ed7b8..60ed98a 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -90,7 +90,7 @@ export async function queryMulti(parent, args, context: ResolverContext, info) { handleWhere(query, nestedTableName, w[1]); } else { - query = query.where(w[0], w[1]); + query = query.where(`${prefix}.${w[0]}`, w[1]); } }); }; From ab8975856f44c3433c1b6655d060988ce9288573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Tkaczy=C5=84ski?= Date: Wed, 6 Sep 2023 09:13:13 +0200 Subject: [PATCH 3/3] fix: select non-nullable types --- src/graphql/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 60ed98a..d5087b8 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -74,7 +74,7 @@ export async function queryMulti(parent, args, context: ResolverContext, info) { const nestedTableName = getTableName(nestedReturnType.name.toLowerCase()); const fields = Object.values(nestedReturnType.getFields()) - .filter(field => isScalarType(field.type)) + .filter(field => isScalarType(getNonNullType(field.type))) .map(field => field.name); nestedEntitiesMappings[fieldName] = {