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

615 lines (598 loc) 25.9 kB
// @flow import type { Plugin } from "graphile-build"; import debugFactory from "debug"; const debug = debugFactory("graphile-build-pg"); export default (async function PgMutationUpdateDeletePlugin( builder, { pgDisableDefaultMutations } ) { if (pgDisableDefaultMutations) { return; } builder.hook( "GraphQLObjectType:fields", (fields, build, context) => { const { newWithHooks, getNodeIdForTypeAndIdentifiers, getTypeAndIdentifiersFromNodeId, nodeIdFieldName, fieldDataGeneratorsByType, extend, parseResolveInfo, getTypeByName, gql2pg, pgGetGqlTypeByTypeIdAndModifier, pgGetGqlInputTypeByTypeIdAndModifier, pgIntrospectionResultsByKind: introspectionResultsByKind, pgSql: sql, graphql: { GraphQLNonNull, GraphQLInputObjectType, GraphQLString, GraphQLObjectType, GraphQLID, }, pgColumnFilter, inflection, pgQueryFromResolveData: queryFromResolveData, pgOmit: omit, pgViaTemporaryTable: viaTemporaryTable, describePgEntity, sqlCommentByAddingTags, pgField, } = build; const { scope: { isRootMutation }, fieldWithHooks, } = context; if (!isRootMutation) { return fields; } return extend( fields, ["update", "delete"].reduce( (outerMemo, mode) => introspectionResultsByKind.class.reduce((memo, table) => { // PERFORMANCE: These used to be .filter(...) calls if (!table.namespace) return memo; const canUpdate = mode === "update" && table.isUpdatable && !omit(table, "update") && // Check at least one attribute is updatable table.attributes.find(attr => !omit(attr, "update")); const canDelete = mode === "delete" && table.isDeletable && !omit(table, "delete"); if (!canUpdate && !canDelete) return memo; const TableType = pgGetGqlTypeByTypeIdAndModifier( table.type.id, null ); if (!TableType) { return memo; } async function commonCodeRenameMe( pgClient, resolveInfo, getDataFromParsedResolveInfoFragment, PayloadType, args, condition, context, resolveContext ) { const { input } = args; const parsedResolveInfoFragment = parseResolveInfo(resolveInfo); parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin const resolveData = getDataFromParsedResolveInfoFragment( parsedResolveInfoFragment, PayloadType ); const sqlTypeIdentifier = sql.identifier( table.namespace.name, table.name ); let sqlMutationQuery; if (mode === "update") { const sqlColumns = []; const sqlValues = []; const inputData = input[ inflection.patchField(inflection.tableFieldName(table)) ]; table.attributes.forEach(attr => { // PERFORMANCE: These used to be .filter(...) calls if (!pgColumnFilter(attr, build, context)) return; if (omit(attr, "update")) return; const fieldName = inflection.column(attr); if ( fieldName in inputData /* Because we care about null! */ ) { const val = inputData[fieldName]; sqlColumns.push(sql.identifier(attr.name)); sqlValues.push(gql2pg(val, attr.type, attr.typeModifier)); } }); if (sqlColumns.length === 0) { return null; } sqlMutationQuery = sql.query`\ update ${sql.identifier(table.namespace.name, table.name)} set ${sql.join( sqlColumns.map( (col, i) => sql.fragment`${col} = ${sqlValues[i]}` ), ", " )} where ${condition} returning *`; } else { sqlMutationQuery = sql.query`\ delete from ${sql.identifier(table.namespace.name, table.name)} where ${condition} returning *`; } const modifiedRowAlias = sql.identifier(Symbol()); const query = queryFromResolveData( modifiedRowAlias, modifiedRowAlias, resolveData, {}, null, resolveContext, resolveInfo.rootValue ); let row; try { await pgClient.query("SAVEPOINT graphql_mutation"); const rows = await viaTemporaryTable( pgClient, sqlTypeIdentifier, sqlMutationQuery, modifiedRowAlias, query ); row = rows[0]; await pgClient.query("RELEASE SAVEPOINT graphql_mutation"); } catch (e) { await pgClient.query( "ROLLBACK TO SAVEPOINT graphql_mutation" ); throw e; } if (!row) { throw new Error( `No values were ${mode}d in collection '${inflection.pluralize( inflection._singularizedTableName(table) )}' because no values you can ${mode} were found matching these criteria.` ); } return { clientMutationId: input.clientMutationId, data: row, }; } if (TableType) { const uniqueConstraints = table.constraints.filter( con => con.type === "u" || con.type === "p" ); const Table = pgGetGqlTypeByTypeIdAndModifier( table.type.id, null ); const tableTypeName = Table.name; const TablePatch = getTypeByName( inflection.patchType(Table.name) ); if (mode === "update" && !TablePatch) { return memo; } const PayloadType = newWithHooks( GraphQLObjectType, { name: inflection[ mode === "delete" ? "deletePayloadType" : "updatePayloadType" ](table), description: build.wrapDescription( `The output of our ${mode} \`${tableTypeName}\` mutation.`, "type" ), fields: ({ fieldWithHooks }) => { const tableName = inflection.tableFieldName(table); // This should really be `-node-id` but for compatibility with PostGraphQL v3 we haven't made that change. const deletedNodeIdFieldName = inflection.deletedNodeId(table); return Object.assign( { clientMutationId: { description: build.wrapDescription( "The exact same `clientMutationId` that was provided in the mutation input, unchanged and unused. May be used by a client to track mutations.", "field" ), type: GraphQLString, }, [tableName]: pgField( build, fieldWithHooks, tableName, { description: build.wrapDescription( `The \`${tableTypeName}\` that was ${mode}d by this mutation.`, "field" ), type: Table, }, {}, false ), }, mode === "delete" ? { [deletedNodeIdFieldName]: fieldWithHooks( deletedNodeIdFieldName, ({ addDataGenerator }) => { const fieldDataGeneratorsByTableType = fieldDataGeneratorsByType.get(TableType); const gens = fieldDataGeneratorsByTableType && fieldDataGeneratorsByTableType[ nodeIdFieldName ]; if (gens) { gens.forEach(gen => addDataGenerator(gen)); } return { type: GraphQLID, resolve(data) { return ( data.data.__identifiers && getNodeIdForTypeAndIdentifiers( Table, ...data.data.__identifiers ) ); }, }; }, { isPgMutationPayloadDeletedNodeIdField: true, } ), } : null ); }, }, { __origin: `Adding table ${mode} mutation payload type for ${describePgEntity( table )}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags( table, { name: "newNameHere", } )}`, isMutationPayload: true, isPgUpdatePayloadType: mode === "update", isPgDeletePayloadType: mode === "delete", pgIntrospection: table, } ); // NodeId const primaryKeyConstraint = table.primaryKeyConstraint; if (nodeIdFieldName && primaryKeyConstraint) { const primaryKeys = primaryKeyConstraint && primaryKeyConstraint.keyAttributes; const fieldName = inflection[mode === "update" ? "updateNode" : "deleteNode"]( table ); const InputType = newWithHooks( GraphQLInputObjectType, { description: build.wrapDescription( `All input for the \`${fieldName}\` mutation.`, "type" ), name: inflection[ mode === "update" ? "updateNodeInputType" : "deleteNodeInputType" ](table), fields: Object.assign( { clientMutationId: { description: build.wrapDescription( "An arbitrary string value with no semantic meaning. Will be included in the payload verbatim. May be used to track mutations by the client.", "field" ), type: GraphQLString, }, [nodeIdFieldName]: { description: build.wrapDescription( `The globally unique \`ID\` which will identify a single \`${tableTypeName}\` to be ${mode}d.`, "field" ), type: new GraphQLNonNull(GraphQLID), }, }, mode === "update" && TablePatch ? { [inflection.patchField( inflection.tableFieldName(table) )]: { description: build.wrapDescription( `An object where the defined keys will be set on the \`${tableTypeName}\` being ${mode}d.`, "field" ), type: new GraphQLNonNull(TablePatch), }, } : null ), }, { __origin: `Adding table ${mode} (by node ID) mutation input type for ${describePgEntity( table )}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags( table, { name: "newNameHere", } )}`, isPgUpdateInputType: mode === "update", isPgUpdateNodeInputType: mode === "update", isPgDeleteInputType: mode === "delete", isPgDeleteNodeInputType: mode === "delete", pgInflection: table, // TODO:v5: remove - TYPO! pgIntrospection: table, isMutationInput: true, } ); memo = extend( memo, { [fieldName]: fieldWithHooks( fieldName, context => { const { getDataFromParsedResolveInfoFragment } = context; return { description: build.wrapDescription( mode === "update" ? `Updates a single \`${tableTypeName}\` using its globally unique id and a patch.` : `Deletes a single \`${tableTypeName}\` using its globally unique id.`, "field" ), type: PayloadType, args: { input: { type: new GraphQLNonNull(InputType), }, }, async resolve( parent, args, resolveContext, resolveInfo ) { const { input } = args; const { pgClient } = resolveContext; const nodeId = input[nodeIdFieldName]; try { const { Type, identifiers } = getTypeAndIdentifiersFromNodeId(nodeId); if (Type !== TableType) { throw new Error("Mismatched type"); } if (identifiers.length !== primaryKeys.length) { throw new Error("Invalid ID"); } return commonCodeRenameMe( pgClient, resolveInfo, getDataFromParsedResolveInfoFragment, PayloadType, args, sql.fragment`(${sql.join( primaryKeys.map( (key, idx) => sql.fragment`${sql.identifier( key.name )} = ${gql2pg( identifiers[idx], key.type, key.typeModifier )}` ), ") and (" )})`, context, resolveContext ); } catch (e) { debug(e); return null; } }, }; }, { isPgNodeMutation: true, pgFieldIntrospection: table, [mode === "update" ? "isPgUpdateMutationField" : "isPgDeleteMutationField"]: true, } ), }, "Adding ${mode} mutation for ${describePgEntity(table)}" ); } // Unique uniqueConstraints.forEach(constraint => { if (omit(constraint, mode)) { return; } const keys = constraint.keyAttributes; if (!keys.every(_ => _)) { throw new Error( `Consistency error: could not find an attribute in the constraint when building the ${mode} mutation for ${describePgEntity( table )}!` ); } if (keys.some(key => omit(key, "read"))) { return; } const fieldName = inflection[ mode === "update" ? "updateByKeys" : "deleteByKeys" ](keys, table, constraint); const InputType = newWithHooks( GraphQLInputObjectType, { description: build.wrapDescription( `All input for the \`${fieldName}\` mutation.`, "type" ), name: inflection[ mode === "update" ? "updateByKeysInputType" : "deleteByKeysInputType" ](keys, table, constraint), fields: Object.assign( { clientMutationId: { type: GraphQLString, }, }, mode === "update" && TablePatch ? { [inflection.patchField( inflection.tableFieldName(table) )]: { description: build.wrapDescription( `An object where the defined keys will be set on the \`${tableTypeName}\` being ${mode}d.`, "field" ), type: new GraphQLNonNull(TablePatch), }, } : null, keys.reduce((memo, key) => { memo[inflection.column(key)] = { description: key.description, type: new GraphQLNonNull( pgGetGqlInputTypeByTypeIdAndModifier( key.typeId, key.typeModifier ) ), }; return memo; }, {}) ), }, { __origin: `Adding table ${mode} mutation input type for ${describePgEntity( constraint )}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags( table, { name: "newNameHere", } )}`, isPgUpdateInputType: mode === "update", isPgUpdateByKeysInputType: mode === "update", isPgDeleteInputType: mode === "delete", isPgDeleteByKeysInputType: mode === "delete", pgInflection: table, // TODO:v5: remove - TYPO! pgIntrospection: table, pgKeys: keys, isMutationInput: true, } ); memo = extend( memo, { [fieldName]: fieldWithHooks( fieldName, context => { const { getDataFromParsedResolveInfoFragment } = context; return { description: build.wrapDescription( mode === "update" ? `Updates a single \`${tableTypeName}\` using a unique key and a patch.` : `Deletes a single \`${tableTypeName}\` using a unique key.`, "field" ), type: PayloadType, args: { input: { type: new GraphQLNonNull(InputType), }, }, async resolve( parent, args, resolveContext, resolveInfo ) { const { input } = args; const { pgClient } = resolveContext; return commonCodeRenameMe( pgClient, resolveInfo, getDataFromParsedResolveInfoFragment, PayloadType, args, sql.fragment`(${sql.join( keys.map( key => sql.fragment`${sql.identifier( key.name )} = ${gql2pg( input[inflection.column(key)], key.type, key.typeModifier )}` ), ") and (" )})`, context, resolveContext ); }, }; }, { isPgNodeMutation: false, pgFieldIntrospection: table, pgFieldConstraint: constraint, [mode === "update" ? "isPgUpdateMutationField" : "isPgDeleteMutationField"]: true, } ), }, `Adding ${mode} mutation for ${describePgEntity( constraint )}` ); }); } return memo; }, outerMemo), {} ), `Adding default update/delete mutations to root Mutation type` ); }, ["PgMutationUpdateDelete"] ); }: Plugin);