postgraphile-upsert-plugin
Version:
add postgres upsert mutations to postgraphile
284 lines • 15.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PgMutationUpsertPlugin = void 0;
const assert_1 = __importDefault(require("assert"));
const PgMutationUpsertPlugin = (builder) => {
builder.hook("GraphQLObjectType:fields", (fields, build, context) => {
const { extend, pgGetGqlInputTypeByTypeIdAndModifier, pgGetGqlTypeByTypeIdAndModifier, pgIntrospectionResultsByKind, pgOmit: omit, } = build;
const { scope: { isRootMutation }, } = context;
if (!isRootMutation)
return fields;
const allUniqueConstraints = pgIntrospectionResultsByKind.constraint.filter((con) => con.type === "u" || con.type === "p");
const upsertFieldsByName = pgIntrospectionResultsByKind.class
.filter((table) => {
const hasUniqueConstraint = allUniqueConstraints.some((c) => c.classId === table.id);
return !!table.namespace &&
(!!table.primaryKeyConstraint || hasUniqueConstraint) &&
!omit(table, "upsert") &&
table.isSelectable &&
table.isInsertable &&
table.isUpdatable;
})
.reduce((fnsByName, table) => {
const gqlTable = pgGetGqlTypeByTypeIdAndModifier(table.type.id, null);
if (!gqlTable)
return fnsByName;
const gqlTableInput = pgGetGqlInputTypeByTypeIdAndModifier(table.type.id, null);
if (!gqlTableInput)
return fnsByName;
const { fn, upsertFnName } = createUpsertField({
allUniqueConstraints,
table,
build,
context,
gqlTable,
gqlTableInput,
});
fnsByName[upsertFnName] = fn;
return fnsByName;
}, {});
return extend(fields, upsertFieldsByName);
});
};
exports.PgMutationUpsertPlugin = PgMutationUpsertPlugin;
exports.PgMutationUpsertPlugin.displayName = "upsert";
const hasOwnProperty = (x, key) => Object.prototype.hasOwnProperty.call(x, key);
function createUpsertField({ allUniqueConstraints, build, context, gqlTable, gqlTableInput, table, }) {
const { gql2pg, graphql: { GraphQLObjectType, GraphQLInputObjectType, GraphQLNonNull, GraphQLString, }, inflection, newWithHooks, parseResolveInfo, pgGetGqlInputTypeByTypeIdAndModifier, pgIntrospectionResultsByKind, pgQueryFromResolveData: queryFromResolveData, pgSql: sql, pgViaTemporaryTable: viaTemporaryTable, pgField, pgOmit: omit, } = build;
const { fieldWithHooks } = context;
const tableTypeName = inflection.tableType(table);
const uniqueConstraints = allUniqueConstraints.filter((con) => con.classId === table.id);
const attributes = pgIntrospectionResultsByKind.attribute
.filter((attr) => attr.classId === table.id)
.sort((a, b) => a.num - b.num);
/**
* The upsert's WhereType needs to be a combination of TableCondition
* but with the constraints of a uniqueConstraint
* so find the query generator for an allTable query
* but filter by the uniqueConstraints above
*
* See also:
* PgRowByUniqueConstraint
* PgConnectionArgCondition
* PgAllRows
*/
// For each unique constraint we gather all of the fields into an
// InputType. Technically, we probably want to have **each**
// uniqueConstraint create it's own type and then union these, but
// YOLO
const gqlInputTypesByFieldName = uniqueConstraints.reduce((acc, constraint) => {
const keys = constraint.keyAttributeNums.map((num) => attributes.find((attr) => attr.num === num));
if (keys.some((key) => omit(key, "read"))) {
return acc;
}
else if (!keys.every((_) => _)) {
throw new Error("Consistency error: could not find an attribute!");
}
keys.forEach((key) => {
const fieldName = inflection.camelCase(key.name);
const InputType = pgGetGqlInputTypeByTypeIdAndModifier(key.typeId, key.typeModifier);
if (!InputType) {
throw new Error(`Could not find input type for key '${key.name}' on type '${tableTypeName}'`);
}
acc[fieldName] = { type: InputType };
});
return acc;
}, {});
// Unique Where conditions
const WhereType = newWithHooks(GraphQLInputObjectType, {
name: `Upsert${tableTypeName}Where`,
description: `Where conditions for the upsert \`${tableTypeName}\` mutation.`,
fields: gqlInputTypesByFieldName,
}, {
isPgCreateInputType: false,
pgInflection: table,
});
// Standard input type that 'create' uses
const InputType = newWithHooks(GraphQLInputObjectType, {
name: `Upsert${tableTypeName}Input`,
description: `All input for the upsert \`${tableTypeName}\` mutation.`,
fields: {
clientMutationId: {
description: "An arbitrary string value with no semantic meaning. Will be included in the payload verbatim. May be used to track mutations by the client.",
type: GraphQLString,
},
...(gqlTableInput
? {
[inflection.tableFieldName(table)]: {
description: `The \`${tableTypeName}\` to be upserted by this mutation.`,
type: new GraphQLNonNull(gqlTableInput),
},
}
: null),
},
}, {
isPgCreateInputType: false,
pgInflection: table,
});
// Standard payload type that 'create' uses
const PayloadType = newWithHooks(GraphQLObjectType, {
name: `Upsert${tableTypeName}Payload`,
description: `The output of our upsert \`${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 upserted by this mutation.`,
type: gqlTable,
}),
};
},
}, {
isMutationPayload: true,
isPgCreatePayloadType: false,
pgIntrospection: table,
});
const upsertFnName = `upsert${tableTypeName}`;
return {
upsertFnName,
fn: fieldWithHooks(upsertFnName, (context) => {
const { getDataFromParsedResolveInfoFragment } = context;
return {
description: `Upserts a single \`${tableTypeName}\`.`,
type: PayloadType,
args: {
where: {
type: WhereType,
},
input: {
type: new GraphQLNonNull(InputType),
},
},
async resolve(_data, { where: whereRaw, input }, { pgClient }, resolveInfo) {
const where = whereRaw;
const parsedResolveInfoFragment = parseResolveInfo(resolveInfo);
const resolveData = getDataFromParsedResolveInfoFragment(parsedResolveInfoFragment, PayloadType);
const insertedRowAlias = sql.identifier(Symbol());
const query = queryFromResolveData(insertedRowAlias, insertedRowAlias, resolveData, {});
const sqlColumns = [];
const conflictOnlyColumns = [];
const sqlValues = [];
const inputData = input[inflection.tableFieldName(table)];
// Find the unique constraints
const uniqueConstraints = allUniqueConstraints.filter((con) => con.classId === table.id);
// Store attributes (columns) for easy access
const attributes = pgIntrospectionResultsByKind.attribute.filter((attr) => attr.classId === table.id);
// Figure out which columns the unique constraints belong to
const columnsByConstraintName = uniqueConstraints.reduce((acc, constraint) => ({
...acc,
[constraint.name]: new Set(constraint.keyAttributeNums.map((num) => {
const match = attributes.find((attr) => attr.num === num);
(0, assert_1.default)(match, `no attribute found for ${num}`);
return match;
})),
}), {});
const fieldToAttributeMap = attributes.reduce((acc, attr) => ({
...acc,
[inflection.camelCase(attr.name)]: attr,
}), {});
// Pre-process our primary key constraint
const primaryKeyConstraint = uniqueConstraints.find((con) => con.type === "p");
const primaryKeyConstraintCols = new Set(primaryKeyConstraint?.keyAttributes.map(({ name }) => name) ?? []);
// Pre-process our data inputs from the payload (what was manually passed in)
const inputDataKeys = new Set(Object.keys(inputData));
const inputDataColumns = new Set([...inputDataKeys].map((key) => fieldToAttributeMap[key].name));
// Construct a super-set of fields passed up plus columns with default values
// (as these can be set before the constraint kicks into place)
const inputDataColumnsWithDefaults = new Set([
...inputDataColumns,
...attributes
.filter((a) => a.hasDefault && !primaryKeyConstraintCols.has(a.name))
.map(({ name }) => name),
]);
/**
* Depending on whether a where clause was passed, we want to determine which
* constraint to use in the upsert ON CONFLICT cause.
* Decision flow:
* 1. if we have a where clause, attempt to find matching constraint
* 2. attempt to find a matching constraint given our data input
* 3. attempt to find a matching constraint given our data input + defaulted columns
* 4. else, use the primary key constraint if it exists
*/
const matchingConstraint = (where
? Object.entries(columnsByConstraintName).find(([, columns]) => [...columns].every((col) => inflection.camelCase(col.name) in where))
: Object.entries(columnsByConstraintName).find(([, columns]) => [...columns].every((col) => inputDataColumns.has(col.name)))) ??
Object.entries(columnsByConstraintName).find(([, columns]) => [...columns].every((col) => inputDataColumnsWithDefaults.has(col.name))) ??
Object.entries(columnsByConstraintName).find(([key]) => key === primaryKeyConstraint?.name);
if (!matchingConstraint) {
throw new Error(`Unable to determine upsert unique constraint for given upserted columns: ${[
...inputDataKeys,
].join(", ")}`);
}
(0, assert_1.default)(table.namespace, "expected table namespace");
const [constraintName] = matchingConstraint;
const columnNamesSkippingUpdate = new Set();
// Loop thru columns and "SQLify" them
attributes.forEach((attr) => {
// where clause should override unknown "input" for the matching column to be a true upsert
let hasWhereClauseValue = false;
let whereClauseValue;
if (where &&
hasOwnProperty(where, inflection.camelCase(attr.name))) {
whereClauseValue = where[inflection.camelCase(attr.name)];
hasWhereClauseValue = true;
}
if (omit(attr, "updateOnConflict")) {
columnNamesSkippingUpdate.add(attr.name);
}
// Do we have a value for the field in input?
const fieldName = inflection.column(attr);
if (hasOwnProperty(inputData, fieldName)) {
const val = inputData[fieldName];
// The user passed a where clause condition value that does not match the upsert input value for the same property
if (hasWhereClauseValue && whereClauseValue !== val) {
throw new Error(`Value passed in the input for ${fieldName} does not match the where clause value.`);
}
sqlColumns.push(sql.identifier(attr.name));
sqlValues.push(gql2pg(val, attr.type, attr.typeModifier));
}
else if (hasWhereClauseValue) {
// If it was ommitted in the input, we should add it
sqlColumns.push(sql.identifier(attr.name));
sqlValues.push(gql2pg(whereClauseValue, attr.type, attr.typeModifier));
}
});
// Construct a array in case we need to do an update on conflict
const conflictUpdateArray = conflictOnlyColumns
.concat(sqlColumns)
.filter((col) => !columnNamesSkippingUpdate.has(col.names[0]))
.map((col) => sql.query `${sql.identifier(col.names[0])} = excluded.${sql.identifier(col.names[0])}`);
// SQL query for upsert mutations
// see: http://www.postgresqltutorial.com/postgresql-upsert/
const conflictAction = conflictUpdateArray.length === 0
? sql.fragment `do nothing`
: sql.fragment `on constraint ${sql.identifier(constraintName)}
do update set ${sql.join(conflictUpdateArray, ", ")}`;
const mutationQuery = sql.query `
insert into ${sql.identifier(table.namespace.name, table.name)}
${sqlColumns.length
? sql.fragment `(${sql.join(sqlColumns, ", ")})
values (${sql.join(sqlValues, ", ")})
on conflict ${conflictAction}`
: sql.fragment `default values`} returning *`;
const rows = await viaTemporaryTable(pgClient, sql.identifier(table.namespace.name, table.name), mutationQuery, insertedRowAlias, query);
return {
clientMutationId: input.clientMutationId,
data: rows[0],
};
},
};
}, {
pgFieldIntrospection: table,
isPgCreateMutationField: false,
isPgUpsertMutationField: true,
}),
};
}
//# sourceMappingURL=postgraphile-upsert.js.map