postgraphile-plugin-many-create-update-delete
Version:
Postgraphile plugin that enables many create, update, & delete mutations in a single transaction.
470 lines (433 loc) • 15.4 kB
text/typescript
import * as T from './pluginTypes';
import debugFactory from 'debug';
const debug = debugFactory('graphile-build-pg');
const PostGraphileManyUpdatePlugin: T.Plugin = (
builder: T.SchemaBuilder,
options: any
) => {
if (options.pgDisableDefaultMutations) return;
/**
* Add a hook to create the new root level create mutation
*/
builder.hook(
// @ts-ignore
'GraphQLObjectType:fields',
GQLObjectFieldsHookHandlerFcn,
['PgMutationManyUpdate'], // hook provides
[], // hook before
['PgMutationUpdateDelete'] // hook after
);
/**
* Handles adding the new "many update" root level fields
*/
function GQLObjectFieldsHookHandlerFcn (
fields: any,
build: T.Build,
context: T.Context
) {
const {
extend,
newWithHooks,
getNodeIdForTypeAndIdentifiers,
getTypeAndIdentifiersFromNodeId,
nodeIdFieldName,
fieldDataGeneratorsByFieldNameByType,
parseResolveInfo,
getTypeByName,
gql2pg,
pgGetGqlTypeByTypeIdAndModifier,
pgGetGqlInputTypeByTypeIdAndModifier,
pgIntrospectionResultsByKind,
pgSql: sql,
graphql: {
GraphQLList,
GraphQLNonNull,
GraphQLInputObjectType,
GraphQLString,
GraphQLObjectType,
GraphQLID,
getNamedType
},
pgColumnFilter,
inflection,
pgQueryFromResolveData: queryFromResolveData,
pgOmit: omit,
pgViaTemporaryTable: viaTemporaryTable,
describePgEntity,
sqlCommentByAddingTags,
pgField
} = build;
const {
scope: { isRootMutation },
fieldWithHooks
} = context;
if (!isRootMutation || !pgColumnFilter) return fields;
let newFields = {},
i: number;
const noOfTables = pgIntrospectionResultsByKind.class.length;
for (i = 0; i < noOfTables; i++) {
handleAdditionsFromTableInfo(pgIntrospectionResultsByKind.class[i]);
}
function handleAdditionsFromTableInfo (table: T.PgClass) {
if (
!table.namespace ||
!table.isUpdatable ||
omit(table, 'update') ||
!table.tags.mncud
)
return;
const tableType: T.GraphQLType = pgGetGqlTypeByTypeIdAndModifier(
table.type.id,
null
);
if (!tableType) {
debug(
`There was no GQL Table Type for table '${table.namespace.name}.${table.name}',
so we're not generating a many update mutation for it.`
);
return;
}
const namedType = getNamedType(tableType);
const tablePatch = getTypeByName(inflection.patchType(namedType.name));
if (!tablePatch) {
throw new Error(
`Could not find TablePatch type for table '${table.name}'`
);
}
const tableTypeName = namedType.name;
const uniqueConstraints = table.constraints.filter(
con => con.type === 'p'
);
// Setup and add the GraphQL Payload type
const newPayloadHookType = GraphQLObjectType;
const newPayloadHookSpec = {
name: `mn${inflection.updatePayloadType(table)}`,
description: `The output of our update mn \`${tableTypeName}\` mutation.`,
fields: ({ fieldWithHooks }) => {
const tableName = inflection.tableFieldName(table);
return {
clientMutationId: {
description:
'The exact same `clientMutationId` that was provided in the mutation input,\
unchanged and unused. May be used by a client to track mutations.',
type: GraphQLString
},
[tableName]: pgField(
build,
fieldWithHooks,
tableName,
{
description: `The \`${tableTypeName}\` that was updated by this mutation.`,
type: tableType
},
{},
false
)
};
}
};
const newPayloadHookScope = {
__origin: `Adding table many update 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: true,
pgIntrospection: table
};
const PayloadType = newWithHooks(
newPayloadHookType,
newPayloadHookSpec,
newPayloadHookScope
);
if (!PayloadType) {
throw new Error(
`Failed to determine payload type on the mn\`${tableTypeName}\` mutation`
);
}
// Setup and add GQL Input Types for "Unique Constraint" based updates
// TODO: Look into adding updates via NodeId
uniqueConstraints.forEach(constraint => {
if (omit(constraint, 'update')) return;
const keys = constraint.keyAttributes;
if (!keys.every(_ => _)) {
throw new Error(
`Consistency error: could not find an attribute in the constraint when building the many\
update mutation for ${describePgEntity(table)}!`
);
}
if (keys.some(key => omit(key, 'read'))) return;
const fieldName = `mn${inflection.upperCamelCase(
inflection.updateByKeys(keys, table, constraint)
)}`;
const newInputHookType = GraphQLInputObjectType;
const patchName = inflection.patchField(
inflection.tableFieldName(table)
);
const newInputHookSpec = {
name: `mn${inflection.upperCamelCase(
inflection.updateByKeysInputType(keys, table, constraint)
)}`,
description: `All input for the update \`${fieldName}\` mutation.`,
fields: Object.assign(
{
clientMutationId: {
type: GraphQLString
}
},
{
[`mn${inflection.upperCamelCase(patchName)}`]: {
description: `The one or many \`${tableTypeName}\` to be updated.`,
// TODO: Add an actual type that has the PKs required
// instead of using the tablePatch in another file,
// and hook onto the input types to do so.
//@ts-ignore
type: new GraphQLList(new GraphQLNonNull(tablePatch!))
}
},
{}
)
};
const newInputHookScope = {
__origin: `Adding table many update 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: true,
isPgUpdateByKeysInputType: true,
isMutationInput: true,
pgInflection: table,
pgKeys: keys
};
const InputType = newWithHooks(
newInputHookType,
newInputHookSpec,
newInputHookScope
);
if (!InputType) {
throw new Error(
`Failed to determine input type for '${fieldName}' mutation`
);
}
// Define the new mutation field
function newFieldWithHooks (): T.FieldWithHooksFunction {
return fieldWithHooks(
fieldName,
context => {
context.table = table;
context.relevantAttributes = table.attributes.filter(
attr =>
pgColumnFilter(attr, build, context) && !omit(attr, 'update')
);
return {
description: `Updates one or many \`${tableTypeName}\` using a unique key and a patch.`,
type: PayloadType,
args: {
input: {
type: new GraphQLNonNull(InputType)
}
},
resolve: resolver.bind(context)
};
},
{
pgFieldIntrospection: table,
pgFieldConstraint: constraint,
isPgNodeMutation: false,
isPgUpdateMutationField: true
}
);
}
async function resolver (_data, args, resolveContext, resolveInfo) {
const { input } = args;
const {
table,
getDataFromParsedResolveInfoFragment,
relevantAttributes
}: {
table: T.PgClass;
getDataFromParsedResolveInfoFragment: any;
relevantAttributes: any;
// @ts-ignore
} = this;
const { pgClient } = resolveContext;
const parsedResolveInfoFragment = parseResolveInfo(resolveInfo);
// @ts-ignore
parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin
const resolveData = getDataFromParsedResolveInfoFragment(
parsedResolveInfoFragment,
PayloadType
);
const sqlColumns: T.SQL[] = [];
const sqlColumnTypes: T.SQL[] = [];
const allSQLColumns: T.SQL[] = [];
const inputData: Object[] =
input[
`mn${inflection.upperCamelCase(
inflection.patchField(inflection.tableFieldName(table))
)}`
];
if (!inputData || inputData.length === 0) return null;
const sqlValues: T.SQL[][] = Array(inputData.length).fill([]);
const usedSQLColumns: T.SQL[] = [];
const usedColSQLVals: T.SQL[][] = Array(inputData.length).fill([]);
let hasConstraintValue = true;
inputData.forEach((dataObj, i) => {
let setOfRcvdDataHasPKValue = false;
relevantAttributes.forEach((attr: T.PgAttribute) => {
const fieldName = inflection.column(attr);
const dataValue = dataObj[fieldName];
const isConstraintAttr = keys.some(key => key.name === attr.name);
// Store all attributes on the first run.
// Skip the primary keys, since we can't update those.
if (i === 0 && !isConstraintAttr) {
sqlColumns.push(sql.raw(attr.name));
usedSQLColumns.push(sql.raw('use_' + attr.name));
// Handle custom types
if (attr.type.namespaceName !== 'pg_catalog') {
sqlColumnTypes.push(sql.raw(attr.class.namespaceName + '.' + attr.type.name));
} else {
sqlColumnTypes.push(sql.raw(attr.type.name));
}
}
// Get all of the attributes
if (i === 0) {
allSQLColumns.push(sql.raw(attr.name));
}
// Push the data value if it exists, else push
// a dummy null value (which will not be used).
if (fieldName in dataObj) {
sqlValues[i] = [
...sqlValues[i],
gql2pg(dataValue, attr.type, attr.typeModifier)
];
if (!isConstraintAttr) {
usedColSQLVals[i] = [...usedColSQLVals[i], sql.raw('true')];
} else {
setOfRcvdDataHasPKValue = true;
}
} else {
sqlValues[i] = [...sqlValues[i], sql.raw('NULL')];
if (!isConstraintAttr) {
usedColSQLVals[i] = [...usedColSQLVals[i], sql.raw('false')];
}
}
});
if (!setOfRcvdDataHasPKValue) {
hasConstraintValue = false;
}
});
if (!hasConstraintValue) {
throw new Error(
`You must provide the primary key(s) in the updated data for updates on '${inflection.pluralize(
inflection._singularizedTableName(table)
)}'`
);
}
if (sqlColumns.length === 0) return null;
// https://stackoverflow.com/questions/63290696/update-multiple-rows-using-postgresql
const mutationQuery = sql.query`\
UPDATE ${sql.identifier(table.namespace.name, table.name)} t1 SET
${sql.join(
sqlColumns.map(
(col, i) =>
sql.fragment`"${col}" = (CASE WHEN t2."use_${col}" THEN t2."${col}"::${sqlColumnTypes[i]} ELSE t1."${col}" END)`
),
', '
)}
FROM (VALUES
(${sql.join(
sqlValues.map(
(dataGroup, i) =>
sql.fragment`${sql.join(
dataGroup.concat(usedColSQLVals[i]),
', '
)}`
),
'),('
)})
) t2(
${sql.join(
allSQLColumns
.map(col => sql.fragment`"${col}"`)
.concat(
usedSQLColumns.map(useCol => sql.fragment`"${useCol}"`)
),
', '
)}
)
WHERE ${sql.fragment`(${sql.join(
keys.map(
key =>
sql.fragment`t2.${sql.identifier(key.name)}::${sql.raw(
key.type.name
)} = t1.${sql.identifier(key.name)}`
),
') and ('
)})`}
RETURNING ${sql.join(
allSQLColumns.map(col => sql.fragment`t1."${col}"`),
', '
)}
`;
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,
sql.identifier(table.namespace.name, table.name),
mutationQuery,
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 updated in collection '${inflection.pluralize(
inflection._singularizedTableName(table)
)}' because no values you can update were found matching these criteria.`
);
}
return {
clientMutationId: input.clientMutationId,
data: row
};
}
newFields = extend(
newFields,
{
[fieldName]: newFieldWithHooks
},
`Adding mn update mutation for ${describePgEntity(constraint)}`
);
});
}
return extend(
fields,
newFields,
`Adding the many 'update' mutation to the root mutation`
);
}
};
export default PostGraphileManyUpdatePlugin;