UNPKG

postgraphile-upsert-plugin

Version:

add postgres upsert mutations to postgraphile

284 lines 15.9 kB
"use strict"; 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