diff --git a/docs/examples.md b/docs/examples.md index 998b518..5e9c9a2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -95,6 +95,32 @@ query { } ``` +#### Computed Columns with Arguments + +```graphql +query { + allPeople( + filter: { + computedColumn: { + equalTo: 17 + args: { firstArgument: 1, secondArgument: 2 } + } + } + ) { + nodes { + firstName + lastName + } + } +} +``` + +The `args` are passed to the SQL function that is resposible for creating the computed column: + +```sql +FUNCTION people_computed_column(person people, first_argument int, second_argument int) +``` + #### Relations: Nested ```graphql diff --git a/src/PgConnectionArgFilterComputedColumnsPlugin.ts b/src/PgConnectionArgFilterComputedColumnsPlugin.ts index 08a7eae..d8f56cc 100644 --- a/src/PgConnectionArgFilterComputedColumnsPlugin.ts +++ b/src/PgConnectionArgFilterComputedColumnsPlugin.ts @@ -1,6 +1,7 @@ import type { Plugin } from "graphile-build"; import type { PgClass, PgProc, PgType } from "graphile-build-pg"; import { ConnectionFilterResolver } from "./PgConnectionArgFilterPlugin"; +import camelCase from "camelcase"; const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( builder, @@ -31,6 +32,9 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( connectionFilterTypesByTypeName[Self.name] = Self; + let computedColumnNames: string[] = []; + let argumentLists: { name: string; type: PgType }[][] = []; + const procByFieldName = ( introspectionResultsByKind.procedure as PgProc[] ).reduce((memo: { [fieldName: string]: PgProc }, proc) => { @@ -49,19 +53,6 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( proc ); if (!computedColumnDetails) return memo; - const { pseudoColumnName } = computedColumnDetails; - - // Must have only one required argument - const inputArgsCount = proc.argTypeIds.filter( - (_typeId, idx) => - proc.argModes.length === 0 || // all args are `in` - proc.argModes[idx] === "i" || // this arg is `in` - proc.argModes[idx] === "b" // this arg is `inout` - ).length; - const nonOptionalArgumentsCount = inputArgsCount - proc.argDefaultsNum; - if (nonOptionalArgumentsCount > 1) { - return memo; - } // Must return a scalar or an array if (proc.returnsSet) return memo; @@ -75,11 +66,25 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( if (isVoid) return memo; // Looks good + const { argNames, argTypes, pseudoColumnName } = computedColumnDetails; const fieldName = inflection.computedColumn( pseudoColumnName, proc, table ); + + const args: { name: string; type: PgType }[] = []; + // The first argument is of table type. It is not exposed to the schema. + for (let i = 1; i < argNames.length; i++) { + args.push({ + name: camelCase(argNames[i]), + type: argTypes[i], + }); + } + + computedColumnNames.push(pseudoColumnName); + argumentLists.push(args); + memo = build.extend(memo, { [fieldName]: proc }); return memo; }, {}); @@ -87,27 +92,50 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( const operatorsTypeNameByFieldName: { [fieldName: string]: string } = {}; const procFields = Object.entries(procByFieldName).reduce( - (memo, [fieldName, proc]) => { + (memo, [fieldName, proc], index) => { + const hasArgsField: boolean = argumentLists[index].length >= 1; + + const computedColumnWithArgsDetails = hasArgsField + ? { + name: computedColumnNames[index], + arguments: argumentLists[index], + } + : undefined; + const OperatorsType = connectionFilterOperatorsType( newWithHooks, proc.returnTypeId, - null + null, + computedColumnWithArgsDetails ); if (!OperatorsType) { return memo; } operatorsTypeNameByFieldName[fieldName] = OperatorsType.name; + + const createdField = fieldWithHooks( + fieldName, + { + description: `Filter by the object’s \`${fieldName}\` field.`, + type: OperatorsType, + }, + { + isPgConnectionFilterField: true, + } + ); + + if (hasArgsField) { + // The args field resolver doesn't do anything. The args are + // handled in the resolver of the computed column (below). + connectionFilterRegisterResolver( + createdField.type.name, + "args", + () => null + ); + } + return extend(memo, { - [fieldName]: fieldWithHooks( - fieldName, - { - description: `Filter by the object’s \`${fieldName}\` field.`, - type: OperatorsType, - }, - { - isPgConnectionFilterField: true, - } - ), + [fieldName]: createdField, }); }, {} @@ -121,10 +149,29 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( }) => { if (fieldValue == null) return null; + const queryParameters: { [key: string]: any } = fieldValue; + const providedArgs = queryParameters["args"]; + const proc = procByFieldName[fieldName]; + + // Collect arguments of the computed column and add it + // to the sql function arguments. + let sqlFunctionArguments = [sql.fragment`${sourceAlias}`]; + // The first function argument (table type) is already set above. + for (let i = 1; i < proc.argNames.length; i++) { + const nameOfArgument = camelCase(proc.argNames[i]); + const providedArgValue = providedArgs?.[nameOfArgument]; + if (providedArgValue === undefined) + throw new Error( + `The value for argument ${nameOfArgument} is missing.` + ); + + sqlFunctionArguments.push(sql.fragment`${sql.value(providedArgValue)}`); + } + const sqlIdentifier = sql.query`${sql.identifier( proc.namespace.name - )}.${sql.identifier(proc.name)}(${sourceAlias})`; + )}.${sql.identifier(proc.name)}(${sql.join(sqlFunctionArguments, ",")})`; const pgType = introspectionResultsByKind.typeById[proc.returnTypeId]; const pgTypeModifier = null; const filterTypeName = operatorsTypeNameByFieldName[fieldName]; @@ -175,8 +222,9 @@ const PgConnectionArgFilterComputedColumnsPlugin: Plugin = ( return null; } + const argNames = proc.argNames; const pseudoColumnName = proc.name.substr(table.name.length + 1); - return { argTypes, pseudoColumnName }; + return { argNames, argTypes, pseudoColumnName }; } }; diff --git a/src/PgConnectionArgFilterPlugin.ts b/src/PgConnectionArgFilterPlugin.ts index 89699fb..1e65b43 100644 --- a/src/PgConnectionArgFilterPlugin.ts +++ b/src/PgConnectionArgFilterPlugin.ts @@ -138,7 +138,12 @@ const PgConnectionArgFilterPlugin: Plugin = ( builder.hook("build", (build) => { const { extend, - graphql: { getNamedType, GraphQLInputObjectType, GraphQLList }, + graphql: { + getNamedType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + }, inflection, pgIntrospectionResultsByKind: introspectionResultsByKind, pgGetGqlInputTypeByTypeIdAndModifier, @@ -230,7 +235,14 @@ const PgConnectionArgFilterPlugin: Plugin = ( const connectionFilterOperatorsType = ( newWithHooks: any, pgTypeId: number, - pgTypeModifier: number + pgTypeModifier: number, + computedColumnWithArgsDetails?: { + name: string; + arguments: { + name: string; + type: PgType; + }[]; + } ) => { const pgType = introspectionResultsByKind.typeById[pgTypeId]; @@ -349,7 +361,10 @@ const PgConnectionArgFilterPlugin: Plugin = ( : null; const isListType = fieldType instanceof GraphQLList; - const operatorsTypeName = isListType + + const operatorsTypeName = computedColumnWithArgsDetails + ? `ComputedColumnWithArgs_${computedColumnWithArgsDetails.name}` + : isListType ? inflection.filterFieldListType(namedType.name) : inflection.filterFieldType(namedType.name); @@ -367,14 +382,54 @@ const PgConnectionArgFilterPlugin: Plugin = ( // fully defined with fields, so return it return existingType; } + + let connectionFilterOperatorsTypeConfig: { + name: string; + description?: string; + fields?: { + args: { + type: any; + }; + }; + } = { + name: operatorsTypeName, + description: `A filter to be used against ${namedType.name}${ + isListType ? " List" : "" + } fields. All fields are combined with a logical ‘and.’`, + }; + + if (computedColumnWithArgsDetails) { + // Create a type for the args field + let argFields: { [key: string]: any } = {}; + computedColumnWithArgsDetails.arguments.forEach((argument) => { + argFields[argument.name] = { + type: new GraphQLNonNull( + pgGetGqlInputTypeByTypeIdAndModifier(argument.type.id) + ), + }; + }); + + const argsType = newWithHooks( + GraphQLInputObjectType, + { + name: `${operatorsTypeName}_Arguments`, + fields: argFields, + }, + {}, + true + ); + + // Add the args field to the filter-operator type + connectionFilterOperatorsTypeConfig.fields = { + args: { + type: new GraphQLNonNull(argsType), + }, + }; + } + return newWithHooks( GraphQLInputObjectType, - { - name: operatorsTypeName, - description: `A filter to be used against ${namedType.name}${ - isListType ? " List" : "" - } fields. All fields are combined with a logical ‘and.’`, - }, + connectionFilterOperatorsTypeConfig, { isPgConnectionFilterOperators: true, pgConnectionFilterOperatorsCategory,