UNPKG

graphile-build-pg

Version:

Build a GraphQL schema by reflection over a PostgreSQL schema. Easy to customize since it's built with plugins on graphile-build

882 lines (861 loc) 30.4 kB
// @flow const nullableIf = (GraphQLNonNull, condition, Type) => condition ? Type : new GraphQLNonNull(Type); import type { Build, FieldWithHooksFunction } from "graphile-build"; import type { PgProc, PgType } from "./PgIntrospectionPlugin"; import type { SQL } from "pg-sql2"; import debugSql from "./debugSql"; import chalk from "chalk"; type ProcFieldOptions = { fieldWithHooks: FieldWithHooksFunction, computed?: boolean, isMutation?: boolean, isRootQuery?: boolean, forceList?: boolean, aggregateWrapper?: null | ((sql: SQL) => SQL), description?: string, pgTypeAndModifierModifier?: | null | (( pgType: PgType, pgTypeModifier: null | string | number ) => [PgType, null | string | number]), }; const firstValue = obj => { let firstKey; for (const k in obj) { if (k[0] !== "_" && k[1] !== "_") { firstKey = k; } } return obj[firstKey]; }; export function procFieldDetails( proc: PgProc, build: {| ...Build |}, options: { computed?: boolean, isMutation?: boolean, } ) { const { computed = false, isMutation = false } = options; const { pgIntrospectionResultsByKind: introspectionResultsByKind, pgGetGqlInputTypeByTypeIdAndModifier, pgSql: sql, gql2pg, pgStrictFunctions: strictFunctions, graphql: { GraphQLNonNull }, inflection, describePgEntity, sqlCommentByAddingTags, } = build; if (computed && isMutation) { throw new Error("Mutation procedure cannot be computed"); } const sliceAmount = computed ? 1 : 0; const argNames = proc.argTypeIds.reduce((prev, _, idx) => { if ( idx >= sliceAmount && // Was a .slice() call (proc.argModes.length === 0 || // all args are `in` proc.argModes[idx] === "i" || // this arg is `in` proc.argModes[idx] === "b") // this arg is `inout` ) { prev.push(proc.argNames[idx] || ""); } return prev; }, []); const argTypes = proc.argTypeIds.reduce((prev, typeId, idx) => { if ( idx >= sliceAmount && // Was a .slice() call (proc.argModes.length === 0 || // all args are `in` proc.argModes[idx] === "i" || // this arg is `in` proc.argModes[idx] === "b") // this arg is `inout` ) { prev.push(introspectionResultsByKind.typeById[typeId]); } return prev; }, []); const argModesWithOutput = [ "o", // OUT, "b", // INOUT "t", // TABLE ]; const outputArgNames = proc.argTypeIds.reduce((prev, _, idx) => { if (argModesWithOutput.includes(proc.argModes[idx])) { prev.push(proc.argNames[idx] || ""); } return prev; }, []); const outputArgTypes = proc.argTypeIds.reduce((prev, typeId, idx) => { if (argModesWithOutput.includes(proc.argModes[idx])) { prev.push(introspectionResultsByKind.typeById[typeId]); } return prev; }, []); const requiredArgCount = Math.max(0, argNames.length - proc.argDefaultsNum); const variantFromName = name => { if (name.match(/(_p|P)atch$/)) { return "patch"; } return null; }; const variantFromTags = (tags, idx) => { const variant = tags[`arg${idx}variant`]; if (variant && variant.match && variant.match(/^[0-9]+$/)) { return parseInt(variant, 10); } return variant; }; const notNullArgCount = proc.isStrict || strictFunctions ? requiredArgCount : 0; const argGqlTypes = argTypes.map((type, idx) => { // TODO: PG10 doesn't support the equivalent of pg_attribute.atttypemod on function return values, but maybe a later version might const variant = variantFromTags(proc.tags, idx) || variantFromName(argNames[idx]); const Type = pgGetGqlInputTypeByTypeIdAndModifier(type.id, variant); if (!Type) { const hint = type.class ? `; this might be because no INSERT column privileges are granted on ${describePgEntity( type.class )}. You can use 'Smart Comments' to tell PostGraphile to instead use the "${chalk.bold.green( "base" )}" input type which includes all columns:\n\n ${sqlCommentByAddingTags( proc, { [`arg${idx}variant`]: "base", } )}\n` : ""; throw new Error( `Could not determine type for argument ${idx} ('${ argNames[idx] }') of function ${describePgEntity(proc)}${hint}` ); } if (idx >= notNullArgCount) { return Type; } else { return new GraphQLNonNull(Type); } }); // This wants to be compatible with both being field arguments and being // input object fields (e.g. for the aggregates plugin). const inputs = argNames.reduce((memo, argName, argIndex) => { const gqlArgName = inflection.argument(argName, argIndex); memo[gqlArgName] = { type: argGqlTypes[argIndex], }; return memo; }, {}); function makeSqlFunctionCall( rawArgs = {}, { implicitArgs = [], unnest = false } = {} ): SQL { const args = isMutation ? rawArgs.input : rawArgs; const sqlArgValues = []; let haveNames = true; for (let argIndex = argNames.length - 1; argIndex >= 0; argIndex--) { const argName = argNames[argIndex]; const gqlArgName = inflection.argument(argName, argIndex); const value = args[gqlArgName]; const variant = variantFromTags(proc.tags, argIndex) || variantFromName(argNames[argIndex]); const sqlValue = gql2pg(value, argTypes[argIndex], variant); if (argIndex + 1 > requiredArgCount && haveNames && value == null) { // No need to pass argument to function continue; } else if (argIndex + 1 > requiredArgCount && haveNames) { const sqlArgName = argName ? sql.identifier(argName) : null; if (sqlArgName) { sqlArgValues.unshift(sql.fragment`${sqlArgName} := ${sqlValue}`); } else { haveNames = false; sqlArgValues.unshift(sqlValue); } } else { sqlArgValues.unshift(sqlValue); } } const functionCall = sql.fragment`${sql.identifier( proc.namespace.name, proc.name )}(${sql.join([...implicitArgs, ...sqlArgValues], ", ")})`; return unnest ? sql.fragment`unnest(${functionCall})` : functionCall; } return { inputs, makeSqlFunctionCall, outputArgNames, outputArgTypes, }; } export default function makeProcField( fieldName: string, proc: PgProc, build: {| ...Build |}, options: ProcFieldOptions ) { const { fieldWithHooks, computed = false, isMutation = false, isRootQuery = false, forceList = false, aggregateWrapper = null, description: overrideDescription = null, pgTypeAndModifierModifier = null, } = options; const { pgIntrospectionResultsByKind: introspectionResultsByKind, pgGetGqlTypeByTypeIdAndModifier, getTypeByName, pgSql: sql, parseResolveInfo, getSafeAliasFromResolveInfo, getSafeAliasFromAlias, pg2gql, newWithHooks, pgTweakFragmentForTypeAndModifier, graphql: { GraphQLNonNull, GraphQLList, GraphQLString, GraphQLObjectType, GraphQLInputObjectType, getNamedType, isCompositeType, }, inflection, pgQueryFromResolveData: queryFromResolveData, pgAddStartEndCursor: addStartEndCursor, pgViaTemporaryTable: viaTemporaryTable, describePgEntity, sqlCommentByAddingTags, pgField, options: { subscriptions = false, pgForbidSetofFunctionsToReturnNull = false, }, pgPrepareAndRun, } = build; const { inputs, makeSqlFunctionCall, outputArgNames, outputArgTypes } = procFieldDetails(proc, build, options); let args = inputs; /** * This is the return type the function claims to have; we * should not use it anywhere but these next few lines. */ const baseReturnType = introspectionResultsByKind.typeById[proc.returnTypeId]; /** * This is the return type we treat it as having, e.g. in * case it was modified by wrapping it in an aggregate or * similar. */ const rawReturnType = pgTypeAndModifierModifier ? pgTypeAndModifierModifier(baseReturnType, null)[0] : baseReturnType; /** * This is the type without the array wrapper. */ const returnType = rawReturnType.isPgArray ? rawReturnType.arrayItemType : rawReturnType; const returnTypeTable = introspectionResultsByKind.classById[returnType.classId]; if (!returnType) { throw new Error( `Could not determine return type for function '${proc.name}'` ); } let type; const fieldScope = {}; const payloadTypeScope = {}; fieldScope.pgFieldIntrospection = proc; payloadTypeScope.pgIntrospection = proc; let returnFirstValueAsValue = false; const TableType = returnTypeTable && pgGetGqlTypeByTypeIdAndModifier(returnTypeTable.type.id, null); const isTableLike: boolean = (TableType && isCompositeType(TableType)) || false; const isRecordLike = returnType.id === "2249"; if (isTableLike) { if (proc.returnsSet) { if (isMutation) { const innerType = pgForbidSetofFunctionsToReturnNull ? new GraphQLNonNull(TableType) : TableType; type = new GraphQLList(innerType); } else if (forceList) { const innerType = pgForbidSetofFunctionsToReturnNull ? new GraphQLNonNull(TableType) : TableType; type = new GraphQLList(innerType); fieldScope.isPgFieldSimpleCollection = true; } else { const ConnectionType = getTypeByName( inflection.connection(TableType.name) ); if (!ConnectionType) { throw new Error( `Do not have a connection type '${inflection.connection( TableType.name )}' for '${TableType.name}' so cannot create procedure field` ); } type = ConnectionType; fieldScope.isPgFieldConnection = true; } fieldScope.pgFieldIntrospectionTable = returnTypeTable; payloadTypeScope.pgIntrospectionTable = returnTypeTable; } else { type = TableType; if (rawReturnType.isPgArray) { // Not implementing pgForbidSetofFunctionsToReturnNull here because it's not a set type = new GraphQLList(type); } fieldScope.pgFieldIntrospectionTable = returnTypeTable; payloadTypeScope.pgIntrospectionTable = returnTypeTable; } } else if (isRecordLike) { const RecordType = getTypeByName(inflection.recordFunctionReturnType(proc)); if (!RecordType) { throw new Error( `Do not have a record type '${inflection.recordFunctionReturnType( proc )}' for '${proc.name}' so cannot create procedure field` ); } if (proc.returnsSet) { if (isMutation) { type = new GraphQLList(RecordType); } else if (forceList) { type = new GraphQLList(RecordType); fieldScope.isPgFieldSimpleCollection = true; } else { const ConnectionType = getTypeByName( inflection.recordFunctionConnection(proc) ); if (!ConnectionType) { throw new Error( `Do not have a connection type '${inflection.recordFunctionConnection( proc )}' for '${RecordType.name}' so cannot create procedure field` ); } type = ConnectionType; fieldScope.isPgFieldConnection = true; } } else { type = RecordType; if (rawReturnType.isPgArray) { type = new GraphQLList(type); } } } else { // TODO: PG10 doesn't support the equivalent of pg_attribute.atttypemod on function return values, but maybe a later version might const Type = pgGetGqlTypeByTypeIdAndModifier(returnType.id, null) || GraphQLString; if (proc.returnsSet) { const connectionTypeName = inflection.scalarFunctionConnection(proc); const ConnectionType = getTypeByName(connectionTypeName); if (isMutation) { // Cannot return a connection because it would have to run the mutation again type = new GraphQLList(Type); returnFirstValueAsValue = true; } else if (forceList || !ConnectionType) { type = new GraphQLList(Type); returnFirstValueAsValue = true; fieldScope.isPgFieldSimpleCollection = true; } else { type = ConnectionType; fieldScope.isPgFieldConnection = true; // We don't return the first value as the value here because it gets // sent down into PgScalarFunctionConnectionPlugin so the relevant // EdgeType can return cursor / node; i.e. we might want to add an // `__cursor` field so we can't just use a scalar. } } else { returnFirstValueAsValue = true; type = Type; if (rawReturnType.isPgArray) { type = new GraphQLList(type); } } } return fieldWithHooks( fieldName, ({ addDataGenerator, getDataFromParsedResolveInfoFragment, addArgDataGenerator, }) => { if ( proc.returnsSet && !isTableLike && !returnFirstValueAsValue && !isMutation ) { // Natural ordering addArgDataGenerator(function addPgCursorPrefix() { return { pgCursorPrefix: sql.literal("natural"), }; }); } function makeQuery( parsedResolveInfoFragment, ReturnType, sqlMutationQuery, functionAlias, parentQueryBuilder, resolveContext, resolveInfo ) { const resolveData = getDataFromParsedResolveInfoFragment( parsedResolveInfoFragment, ReturnType ); const isConnection = !forceList && !isMutation && proc.returnsSet; const query = queryFromResolveData( sqlMutationQuery, functionAlias, resolveData, { useAsterisk: !isMutation && (isTableLike || isRecordLike) && (forceList || proc.returnsSet || rawReturnType.isPgArray) && // only bother with lists proc.language !== "sql", // sql functions can be inlined, so GRANTs still apply withPagination: isConnection, withPaginationAsFields: isConnection && !computed, asJson: computed && (forceList || (!proc.returnsSet && !returnFirstValueAsValue)), asJsonAggregate: computed && (forceList || (!proc.returnsSet && rawReturnType.isPgArray)), addNullCase: !proc.returnsSet && !rawReturnType.isPgArray && (isTableLike || isRecordLike), }, innerQueryBuilder => { innerQueryBuilder.parentQueryBuilder = parentQueryBuilder; if (!isTableLike) { if (returnTypeTable) { innerQueryBuilder.select( pgTweakFragmentForTypeAndModifier( sql.fragment`${functionAlias}`, returnTypeTable.type, null, resolveData ), "value" ); } else { innerQueryBuilder.select( pgTweakFragmentForTypeAndModifier( sql.fragment`${functionAlias}`, returnType, null, // We can't determine a type modifier for functions resolveData ), "value" ); } } else if ( subscriptions && returnTypeTable && !isConnection && returnTypeTable.primaryKeyConstraint ) { innerQueryBuilder.selectIdentifiers(returnTypeTable); } }, parentQueryBuilder ? parentQueryBuilder.context : resolveContext, parentQueryBuilder ? parentQueryBuilder.rootValue : resolveInfo && resolveInfo.rootValue ); return query; } if (computed) { addDataGenerator((parsedResolveInfoFragment, ReturnType) => { return { pgQuery: queryBuilder => { queryBuilder.select(() => { const parentTableAlias = queryBuilder.getTableAlias(); const functionAlias = sql.identifier(Symbol()); const sqlFunctionCall = makeSqlFunctionCall( parsedResolveInfoFragment.args, { implicitArgs: [parentTableAlias], unnest: rawReturnType.isPgArray, } ); if (aggregateWrapper) { return aggregateWrapper(sqlFunctionCall); } else { const query = makeQuery( parsedResolveInfoFragment, ReturnType, sqlFunctionCall, functionAlias, queryBuilder ); return sql.fragment`(${query})`; } }, getSafeAliasFromAlias(parsedResolveInfoFragment.alias)); }, }; }); } let ReturnType = type; let PayloadType; if (isMutation) { const resultFieldName = inflection.functionMutationResultFieldName( proc, getNamedType(type), proc.returnsSet || rawReturnType.isPgArray, outputArgNames ); const isNotVoid = String(returnType.id) !== "2278"; // If set then plural name PayloadType = newWithHooks( GraphQLObjectType, { name: inflection.functionPayloadType(proc), description: build.wrapDescription( `The output of our \`${inflection.functionMutationName( proc )}\` mutation.`, "type" ), fields: ({ fieldWithHooks }) => { return Object.assign( {}, { clientMutationId: { type: GraphQLString, }, }, isNotVoid ? { [resultFieldName]: pgField( build, fieldWithHooks, resultFieldName, { type: type, ...(returnFirstValueAsValue ? { resolve(data) { return data.data; }, } : null), }, {}, false, { pgType: returnType, } ), // Result } : null ); }, }, { __origin: `Adding mutation function payload type for ${describePgEntity( proc )}. You can rename the function's GraphQL field (and its dependent types) via a 'Smart Comment':\n\n ${sqlCommentByAddingTags( proc, { name: "newNameHere", } )}`, isMutationPayload: true, ...payloadTypeScope, } ); ReturnType = PayloadType; const InputType = newWithHooks( GraphQLInputObjectType, { name: inflection.functionInputType(proc), description: build.wrapDescription( `All input for the \`${inflection.functionMutationName( proc )}\` mutation.`, "type" ), fields: { clientMutationId: { type: GraphQLString, }, ...args, }, }, { __origin: `Adding mutation function input type for ${describePgEntity( proc )}. You can rename the function's GraphQL field (and its dependent types) via a 'Smart Comment':\n\n ${sqlCommentByAddingTags( proc, { name: "newNameHere", } )}`, isMutationInput: true, } ); args = { input: { type: new GraphQLNonNull(InputType), }, }; } // If this is a table we can process it directly; but if it's a scalar // setof function we must dereference '.value' from it, because this // makes space for '__cursor' to exist alongside it (whereas on a table // the '__cursor' can just be on the table object itself) const scalarAwarePg2gql = v => isTableLike ? pg2gql(v, returnType) : { ...v, value: pg2gql(v.value, returnType), }; return { description: overrideDescription ? overrideDescription : proc.description ? proc.description : isMutation ? null : isTableLike && proc.returnsSet ? build.wrapDescription( `Reads and enables pagination through a set of \`${TableType.name}\`.`, "field" ) : null, type: nullableIf( GraphQLNonNull, !proc.tags.notNull && (!fieldScope.isPgFieldConnection || isMutation || isRootQuery), ReturnType ), args: args, resolve: computed ? (data, _args, resolveContext, resolveInfo) => { const liveRecord = resolveInfo.rootValue && resolveInfo.rootValue.liveRecord; const safeAlias = getSafeAliasFromResolveInfo(resolveInfo); const value = data[safeAlias]; if (returnFirstValueAsValue) { // Is not table like; is not record like. if (proc.returnsSet && !forceList) { // EITHER `isMutation` is true, or `ConnectionType` does not // exist - either way, we're not returning a connection. return value.data.map(v => pg2gql(firstValue(v), returnType)); } else if (proc.returnsSet || rawReturnType.isPgArray) { return value.map(v => pg2gql(firstValue(v), returnType)); } else { return pg2gql(value, returnType); } } else { const makeRecordLive = subscriptions && isTableLike && returnTypeTable && liveRecord ? record => { if (record) { liveRecord( "pg", returnTypeTable, record.__identifiers ); } } : _record => {}; if (proc.returnsSet && !isMutation && !forceList) { // Connection - do not make live (the connection will handle this) return addStartEndCursor({ ...value, data: value.data ? value.data.map(scalarAwarePg2gql) : null, }); } else if (proc.returnsSet || rawReturnType.isPgArray) { // List const records = value.map(v => { makeRecordLive(v); return pg2gql(v, returnType); }); return records; } else { // Object if (value) { makeRecordLive(value); } return pg2gql(value, returnType); } } } : async (data, args, resolveContext, resolveInfo) => { const { pgClient } = resolveContext; const liveRecord = resolveInfo.rootValue && resolveInfo.rootValue.liveRecord; const parsedResolveInfoFragment = parseResolveInfo(resolveInfo); parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin const functionAlias = sql.identifier(Symbol()); const sqlFunctionCall = makeSqlFunctionCall( parsedResolveInfoFragment.args, { unnest: rawReturnType.isPgArray, } ); let queryResultRows; if (isMutation) { const query = makeQuery( parsedResolveInfoFragment, resolveInfo.returnType, functionAlias, functionAlias, null, resolveContext, resolveInfo ); const intermediateIdentifier = sql.identifier(Symbol()); const isVoid = returnType.id === "2278"; const isPgRecord = returnType.id === "2249"; const isPgClass = !isPgRecord && (!returnFirstValueAsValue || returnTypeTable || false); try { await pgClient.query("SAVEPOINT graphql_mutation"); queryResultRows = await viaTemporaryTable( pgClient, isVoid ? null : sql.identifier( returnType.namespaceName, returnType.name ), sql.query`select ${ isPgClass ? sql.query`${intermediateIdentifier}.*` : isPgRecord ? sql.query`${intermediateIdentifier}.*` : sql.query`${intermediateIdentifier} as ${functionAlias}` } from ${sqlFunctionCall} ${intermediateIdentifier}`, functionAlias, query, isPgClass, isPgRecord ? { outputArgTypes, outputArgNames, } : null ); await pgClient.query("RELEASE SAVEPOINT graphql_mutation"); } catch (e) { await pgClient.query( "ROLLBACK TO SAVEPOINT graphql_mutation" ); throw e; } } else { const query = makeQuery( parsedResolveInfoFragment, resolveInfo.returnType, sqlFunctionCall, functionAlias, null, resolveContext, resolveInfo ); const { text, values } = sql.compile(query); if (debugSql.enabled) debugSql(text); const queryResult = await pgPrepareAndRun( pgClient, text, values ); queryResultRows = queryResult.rows; } const rows = queryResultRows; const [row] = rows; const result = (() => { const makeRecordLive = subscriptions && isTableLike && returnTypeTable && liveRecord ? record => { if (record) { liveRecord( "pg", returnTypeTable, record.__identifiers ); } } : _record => {}; if (returnFirstValueAsValue) { // `returnFirstValueAsValue` implies either `isMutation` is // true, or `ConnectionType` does not exist - either way, // we're not returning a connection. if (proc.returnsSet && !isMutation && !forceList) { return row.data.map(v => { const fv = firstValue(v); makeRecordLive(fv); return pg2gql(fv, returnType); }); } else if (proc.returnsSet || rawReturnType.isPgArray) { return rows.map(v => { const fv = firstValue(v); makeRecordLive(fv); return pg2gql(fv, returnType); }); } else { const fv = firstValue(row); makeRecordLive(fv); const record = pg2gql(fv, returnType); return record; } } else { if (proc.returnsSet && !isMutation && !forceList) { // Connection const data = row.data ? row.data.map(scalarAwarePg2gql) : null; return addStartEndCursor({ ...row, data, }); } else if (proc.returnsSet || rawReturnType.isPgArray) { // List return rows.map(row => { makeRecordLive(row); return pg2gql(row, returnType); }); } else { // Object makeRecordLive(row); return pg2gql(row, returnType); } } })(); if (isMutation) { return { clientMutationId: args.input.clientMutationId, data: result, }; } else { return result; } }, }; }, fieldScope ); }