Skip to content

Commit

Permalink
feat: smart tag support for ignoring columns on conflict (#423)
Browse files Browse the repository at this point in the history
* optionally ignore some column updates on conflict

* expanded onconflict handling

allow DO NOTHING
allow set timestamp columns

* updated docs

* switched from boolean pairs to enum

removing the ability to un-ignore on update
  if specified as an @omit smart tag in the schema

* simplified on conflict do update timestamps

they no longer have to be included in input

* added a smart tags section to the readme

* PR suggestions

* feat: opt-in functionality

* fix: duplicate creation

* feat: smart tag support for ignoring fields on conflict update

* chore: update readme

* fix: review comments

* chore: negate expression

Co-authored-by: Zack Behringer <zack@ndustrial.io>
  • Loading branch information
mgagliardo91 and zebehringer committed Aug 29, 2022
1 parent 28fc641 commit c06b8db
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 31 deletions.
8 changes: 6 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ create table bikes (
make varchar,
model varchar
serial_number varchar unique not null,
weight real,
weight real
)
```

An upsert would look like this:
A basic upsert would look like this:

```graphql
mutation {
Expand All @@ -75,6 +75,10 @@ mutation {
}
```

## [Smart Tags](https://www.graphile.org/postgraphile/smart-tags/) Support

- Add `@omit updateOnConflict` to column comments to prevent them from being modified on _existing_ rows in an upsert mutation.

## Credits

- This is a typescript-ified knock off of [the original upsert plugin](https://github.com/einarjegorov/graphile-upsert-plugin/blob/master/index.js)
71 changes: 54 additions & 17 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ type PluginExecutionContext = ExecutionContext<TestContext>;

const test = ava as TestFn<TestContext>;

const initializePostgraphile = async (
t: PluginExecutionContext,
options: Record<string, unknown> = {}
) => {
const middleware = postgraphile(t.context.client, "public", {
graphiql: true,
appendPlugins: [PgMutationUpsertPlugin],
exportGqlSchemaPath: "./postgraphile.graphql",
graphileBuildOptions: {
...options,
},
});
t.context.middleware = middleware;
const serverPort = await freeport();
t.context.serverPort = serverPort;
t.context.server = createServer(middleware).listen(serverPort);
};

test.beforeEach(async (t) => {
await container.setup(t.context);
t.context.client = await await pRetry(
Expand Down Expand Up @@ -55,20 +73,15 @@ test.beforeEach(async (t) => {
unique (project_name, title)
)
`);
await t.context.client.query(
`COMMENT ON COLUMN roles.rank IS E'@omit updateOnConflict'`
);
await t.context.client.query(`
create table no_primary_keys(
name text
)
`);
const middleware = postgraphile(t.context.client, "public", {
graphiql: true,
appendPlugins: [PgMutationUpsertPlugin],
exportGqlSchemaPath: "./postgraphile.graphql",
});
t.context.middleware = middleware;
const serverPort = await freeport();
t.context.serverPort = serverPort;
t.context.server = createServer(middleware).listen(serverPort);
await initializePostgraphile(t);
});

test.afterEach(async (t) => {
Expand All @@ -95,13 +108,29 @@ const execGqlOp = (t: PluginExecutionContext, query: () => string) =>
return json;
});

const fetchType = async (t: PluginExecutionContext, name: string) => {
const queryString = `
{
__type(name: "${name}") {
name
kind
}
}
`;
const query = nanographql(queryString);
return execGqlOp(t, query);
};

const fetchMutationTypes = async (t: PluginExecutionContext) => {
const query = nanographql`
query {
__type(name: "Mutation") {
name
fields {
name
args {
name
}
}
}
}
Expand Down Expand Up @@ -146,7 +175,7 @@ const fetchAllRoles = async (t: PluginExecutionContext) => {

const create = async (
t: PluginExecutionContext,
extraProperties: { [key: string]: unknown } = {}
extraProperties: Record<string, unknown> = {}
) => {
const defaultRecordFields = {
make: '"kona"',
Expand Down Expand Up @@ -335,19 +364,27 @@ test("upsert where clause", async (t) => {
await upsertDirector({ name: "jerry" });
const res = await fetchAllRoles(t);
t.is(res.data.allRoles.edges.length, 1);
t.is(res.data.allRoles.edges[0].node.projectName, "sales");
t.is(res.data.allRoles.edges[0].node.title, "director");
t.is(res.data.allRoles.edges[0].node.name, "jerry");
t.like(res.data.allRoles.edges[0], {
node: {
projectName: "sales",
title: "director",
name: "jerry",
},
});
}

{
// update director
await upsertDirector({ name: "frank", rank: 2 });
const res = await fetchAllRoles(t);
t.is(res.data.allRoles.edges[0].node.projectName, "sales");
t.is(res.data.allRoles.edges[0].node.title, "director");
t.is(res.data.allRoles.edges[0].node.name, "frank");
t.is(res.data.allRoles.edges[0].node.rank, 2);
t.like(res.data.allRoles.edges[0], {
node: {
projectName: "sales",
title: "director",
name: "frank",
rank: 1,
},
});

// assert only one record
t.is(res.data.allRoles.edges.length, 1);
Expand Down
35 changes: 23 additions & 12 deletions src/postgraphile-upsert.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Build, Context, Plugin } from "graphile-build";
import type { Attribute, Constraint, PgTable } from "./types";
import type {
import {
GraphQLFieldConfigMap,
GraphQLObjectType,
GraphQLScalarType,
Expand Down Expand Up @@ -255,6 +255,7 @@ function createUpsertField({
);

const sqlColumns: { names: string[] }[] = [];
const conflictOnlyColumns: { names: string[] }[] = [];
const sqlValues: unknown[] = [];
const inputData: Record<string, unknown> =
input[inflection.tableFieldName(table)];
Expand Down Expand Up @@ -318,8 +319,10 @@ function createUpsertField({
].join(", ")}`
);
}
assert(table.namespace, "expected table namespace");

const [constraintName] = matchingConstraint;
const columnNamesSkippingUpdate = new Set<string>();

// Loop thru columns and "SQLify" them
attributes.forEach((attr) => {
Expand All @@ -334,6 +337,10 @@ function createUpsertField({
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)) {
Expand All @@ -358,16 +365,23 @@ function createUpsertField({
});

// Construct a array in case we need to do an update on conflict
const conflictUpdateArray = sqlColumns.map(
(col) =>
sql.query`${sql.identifier(
col.names[0]
)} = excluded.${sql.identifier(col.names[0])}`
);
assert(table.namespace, "expected table namespace");
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,
Expand All @@ -377,10 +391,7 @@ function createUpsertField({
sqlColumns.length
? sql.fragment`(${sql.join(sqlColumns, ", ")})
values (${sql.join(sqlValues, ", ")})
on conflict on constraint ${sql.identifier(
constraintName
)}
do update set ${sql.join(conflictUpdateArray, ", ")}`
on conflict ${conflictAction}`
: sql.fragment`default values`
} returning *`;
const rows = await viaTemporaryTable(
Expand Down

0 comments on commit c06b8db

Please sign in to comment.