diff --git a/docs/guide.md b/docs/guide.md index 259099a..e0aacef 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -588,7 +588,7 @@ query { ## Mutations -In GraphQL mutations is the operation type for when you need to modify data. Super Graph supports the `insert`, `update` and `delete` database operations. Here are some examples. +In GraphQL mutations is the operation type for when you need to modify data. Super Graph supports the `insert`, `update`, `upsert` and `delete` database operations. Here are some examples. When using mutations the data must be passed as variables since Super Graphs compiles the query into an prepared statement in the database for maximum speed. Prepared statements are are functions in your code when called they accept arguments and your variables are passed in as those arguments. @@ -721,6 +721,56 @@ mutation { } ``` +### Upsert + +```json +{ + "data": { + "id": 5, + "name": "Art of Computer Programming", + "description": "The Art of Computer Programming (TAOCP) is a comprehensive monograph written by computer scientist Donald Knuth", + "price": 30.5 + } +} +``` + +```graphql +mutation { + product(upsert: $data) { + id + name + } +} +``` + +### Bulk upsert + +```json +{ + "data": [{ + "id": 5, + "name": "Art of Computer Programming", + "description": "The Art of Computer Programming (TAOCP) is a comprehensive monograph written by computer scientist Donald Knuth", + "price": 30.5 + }, + { + "id": 6, + "name": "Compilers: Principles, Techniques, and Tools", + "description": "Known to professors, students, and developers worldwide as the 'Dragon Book' is available in a new edition", + "price": 93.74 + }] +} +``` + +```graphql +mutation { + product(upsert: $data) { + id + name + } +} +``` + ### Using variables Variables (`$product_id`) and their values (`"product_id": 5`) can be passed along side the GraphQL query. Using variables makes for better client side code as well as improved server side SQL query caching. The build-in web-ui also supports setting variables. Not having to manipulate your GraphQL query string to insert values into it makes for cleaner diff --git a/migrate/migrate_test.go b/migrate/migrate_test.go index bd66642..ad12a00 100644 --- a/migrate/migrate_test.go +++ b/migrate/migrate_test.go @@ -1,10 +1,10 @@ package migrate_test +/* import ( . "gopkg.in/check.v1" ) -/* type MigrateSuite struct { conn *pgx.Conn diff --git a/psql/insert.go b/psql/insert.go index 87ab4b0..bb66d7e 100644 --- a/psql/insert.go +++ b/psql/insert.go @@ -20,19 +20,33 @@ func (co *Compiler) compileMutation(qc *qcode.QCode, w *bytes.Buffer, vars Varia c := &compilerContext{w, qc.Selects, co} root := &qc.Selects[0] + ti, err := c.schema.GetTable(root.Table) + if err != nil { + return 0, err + } + + c.w.WriteString(`WITH `) + quoted(c.w, ti.Name) + c.w.WriteString(` AS `) + switch root.Action { case qcode.ActionInsert: - if _, err := c.renderInsert(qc, w, vars); err != nil { + if _, err := c.renderInsert(qc, w, vars, ti); err != nil { return 0, err } case qcode.ActionUpdate: - if _, err := c.renderUpdate(qc, w, vars); err != nil { + if _, err := c.renderUpdate(qc, w, vars, ti); err != nil { + return 0, err + } + + case qcode.ActionUpsert: + if _, err := c.renderUpsert(qc, w, vars, ti); err != nil { return 0, err } case qcode.ActionDelete: - if _, err := c.renderDelete(qc, w, vars); err != nil { + if _, err := c.renderDelete(qc, w, vars, ti); err != nil { return 0, err } @@ -40,6 +54,8 @@ func (co *Compiler) compileMutation(qc *qcode.QCode, w *bytes.Buffer, vars Varia return 0, errors.New("valid mutations are 'insert' and 'update'") } + io.WriteString(c.w, ` RETURNING *) `) + root.Paging = zeroPaging root.DistinctOn = root.DistinctOn[:] root.OrderBy = root.OrderBy[:] @@ -49,7 +65,8 @@ func (co *Compiler) compileMutation(qc *qcode.QCode, w *bytes.Buffer, vars Varia return c.compileQuery(qc, w) } -func (c *compilerContext) renderInsert(qc *qcode.QCode, w *bytes.Buffer, vars Variables) (uint32, error) { +func (c *compilerContext) renderInsert(qc *qcode.QCode, w *bytes.Buffer, + vars Variables, ti *DBTableInfo) (uint32, error) { root := &qc.Selects[0] insert, ok := vars[root.ActionVar] @@ -57,23 +74,15 @@ func (c *compilerContext) renderInsert(qc *qcode.QCode, w *bytes.Buffer, vars Va return 0, fmt.Errorf("Variable '%s' not defined", root.ActionVar) } - ti, err := c.schema.GetTable(root.Table) - if err != nil { - return 0, err - } - jt, array, err := jsn.Tree(insert) if err != nil { return 0, err } - c.w.WriteString(`WITH `) - quoted(c.w, ti.Name) - - c.w.WriteString(` AS (WITH "input" AS (SELECT {{`) + c.w.WriteString(`(WITH "input" AS (SELECT {{`) c.w.WriteString(root.ActionVar) c.w.WriteString(`}}::json AS j) INSERT INTO `) - c.w.WriteString(ti.Name) + quoted(c.w, ti.Name) io.WriteString(c.w, ` (`) c.renderInsertUpdateColumns(qc, w, jt, ti) io.WriteString(c.w, `)`) @@ -90,7 +99,7 @@ func (c *compilerContext) renderInsert(qc *qcode.QCode, w *bytes.Buffer, vars Va c.w.WriteString(`(NULL::`) c.w.WriteString(ti.Name) - c.w.WriteString(`, i.j) t RETURNING *) `) + c.w.WriteString(`, i.j) t`) return 0, nil } @@ -113,7 +122,8 @@ func (c *compilerContext) renderInsertUpdateColumns(qc *qcode.QCode, w *bytes.Bu return 0, nil } -func (c *compilerContext) renderUpdate(qc *qcode.QCode, w *bytes.Buffer, vars Variables) (uint32, error) { +func (c *compilerContext) renderUpdate(qc *qcode.QCode, w *bytes.Buffer, + vars Variables, ti *DBTableInfo) (uint32, error) { root := &qc.Selects[0] update, ok := vars[root.ActionVar] @@ -121,23 +131,15 @@ func (c *compilerContext) renderUpdate(qc *qcode.QCode, w *bytes.Buffer, vars Va return 0, fmt.Errorf("Variable '%s' not defined", root.ActionVar) } - ti, err := c.schema.GetTable(root.Table) - if err != nil { - return 0, err - } - jt, array, err := jsn.Tree(update) if err != nil { return 0, err } - c.w.WriteString(`WITH `) - quoted(c.w, ti.Name) - - c.w.WriteString(` AS (WITH "input" AS (SELECT {{`) + c.w.WriteString(`(WITH "input" AS (SELECT {{`) c.w.WriteString(root.ActionVar) c.w.WriteString(`}}::json AS j) UPDATE `) - c.w.WriteString(ti.Name) + quoted(c.w, ti.Name) io.WriteString(c.w, ` SET (`) c.renderInsertUpdateColumns(qc, w, jt, ti) @@ -161,31 +163,81 @@ func (c *compilerContext) renderUpdate(qc *qcode.QCode, w *bytes.Buffer, vars Va return 0, err } - io.WriteString(c.w, ` RETURNING *) `) - return 0, nil } -func (c *compilerContext) renderDelete(qc *qcode.QCode, w *bytes.Buffer, vars Variables) (uint32, error) { +func (c *compilerContext) renderDelete(qc *qcode.QCode, w *bytes.Buffer, + vars Variables, ti *DBTableInfo) (uint32, error) { root := &qc.Selects[0] - ti, err := c.schema.GetTable(root.Table) - if err != nil { - return 0, err - } - - c.w.WriteString(`WITH `) + c.w.WriteString(`(DELETE FROM `) quoted(c.w, ti.Name) - - c.w.WriteString(` AS (DELETE FROM `) - c.w.WriteString(ti.Name) io.WriteString(c.w, ` WHERE `) if err := c.renderWhere(root, ti); err != nil { return 0, err } - io.WriteString(c.w, ` RETURNING *) `) + return 0, nil +} + +func (c *compilerContext) renderUpsert(qc *qcode.QCode, w *bytes.Buffer, + vars Variables, ti *DBTableInfo) (uint32, error) { + root := &qc.Selects[0] + + upsert, ok := vars[root.ActionVar] + if !ok { + return 0, fmt.Errorf("Variable '%s' not defined", root.ActionVar) + } + + jt, _, err := jsn.Tree(upsert) + if err != nil { + return 0, err + } + + if _, err := c.renderInsert(qc, w, vars, ti); err != nil { + return 0, err + } + + c.w.WriteString(` ON CONFLICT DO (`) + i := 0 + + for _, cn := range ti.ColumnNames { + if _, ok := jt[cn]; !ok { + continue + } + + if col, ok := ti.Columns[cn]; !ok || !(col.UniqueKey || col.PrimaryKey) { + continue + } + + if i != 0 { + io.WriteString(c.w, `, `) + } + c.w.WriteString(cn) + i++ + } + if i == 0 { + c.w.WriteString(ti.PrimaryCol) + } + c.w.WriteString(`) DO `) + + c.w.WriteString(`UPDATE `) + io.WriteString(c.w, ` SET `) + + i = 0 + for _, cn := range ti.ColumnNames { + if _, ok := jt[cn]; !ok { + continue + } + if i != 0 { + io.WriteString(c.w, `, `) + } + c.w.WriteString(cn) + io.WriteString(c.w, ` = EXCLUDED.`) + c.w.WriteString(cn) + i++ + } return 0, nil } diff --git a/psql/insert_test.go b/psql/insert_test.go index 8f66398..a9f866c 100644 --- a/psql/insert_test.go +++ b/psql/insert_test.go @@ -12,7 +12,7 @@ func simpleInsert(t *testing.T) { } }` - sql := `WITH "users" AS (WITH "input" AS (SELECT {{data}}::json AS j) INSERT INTO users (full_name, email) SELECT full_name, email FROM input i, json_populate_record(NULL::users, i.j) t RETURNING *) SELECT json_object_agg('user', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "users_0"."id" AS "id") AS "sel_0")) AS "sel_json_0" FROM (SELECT "users"."id" FROM "users") AS "users_0") AS "done_1337";` + sql := `WITH "users" AS (WITH "input" AS (SELECT {{data}}::json AS j) INSERT INTO "users" (full_name, email) SELECT full_name, email FROM input i, json_populate_record(NULL::users, i.j) t RETURNING *) SELECT json_object_agg('user', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "users_0"."id" AS "id") AS "sel_0")) AS "sel_json_0" FROM (SELECT "users"."id" FROM "users") AS "users_0") AS "done_1337";` vars := map[string]json.RawMessage{ "data": json.RawMessage(`{"email": "reannagreenholt@orn.com", "full_name": "Flo Barton"}`), @@ -36,7 +36,7 @@ func singleInsert(t *testing.T) { } }` - sql := `WITH "products" AS (WITH "input" AS (SELECT {{insert}}::json AS j) INSERT INTO products (name, description) SELECT name, description FROM input i, json_populate_record(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + sql := `WITH "products" AS (WITH "input" AS (SELECT {{insert}}::json AS j) INSERT INTO "products" (name, description) SELECT name, description FROM input i, json_populate_record(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` vars := map[string]json.RawMessage{ "insert": json.RawMessage(` { "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }`), @@ -60,7 +60,7 @@ func bulkInsert(t *testing.T) { } }` - sql := `WITH "products" AS (WITH "input" AS (SELECT {{insert}}::json AS j) INSERT INTO products (name, description) SELECT name, description FROM input i, json_populate_recordset(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + sql := `WITH "products" AS (WITH "input" AS (SELECT {{insert}}::json AS j) INSERT INTO "products" (name, description) SELECT name, description FROM input i, json_populate_recordset(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` vars := map[string]json.RawMessage{ "insert": json.RawMessage(` [{ "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }]`), @@ -76,6 +76,54 @@ func bulkInsert(t *testing.T) { } } +func singleUpsert(t *testing.T) { + gql := `mutation { + product(id: 15, upsert: $upsert) { + id + name + } + }` + + sql := `WITH "products" AS (WITH "input" AS (SELECT {{upsert}}::json AS j) INSERT INTO "products" (name, description) SELECT name, description FROM input i, json_populate_record(NULL::products, i.j) t ON CONFLICT DO (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + + vars := map[string]json.RawMessage{ + "upsert": json.RawMessage(` { "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }`), + } + + resSQL, err := compileGQLToPSQL(gql, vars) + if err != nil { + t.Fatal(err) + } + + if string(resSQL) != sql { + t.Fatal(errNotExpected) + } +} + +func bulkUpsert(t *testing.T) { + gql := `mutation { + product(id: 15, upsert: $upsert) { + id + name + } + }` + + sql := `WITH "products" AS (WITH "input" AS (SELECT {{upsert}}::json AS j) INSERT INTO "products" (name, description) SELECT name, description FROM input i, json_populate_recordset(NULL::products, i.j) t ON CONFLICT DO (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + + vars := map[string]json.RawMessage{ + "upsert": json.RawMessage(` [{ "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }]`), + } + + resSQL, err := compileGQLToPSQL(gql, vars) + if err != nil { + t.Fatal(err) + } + + if string(resSQL) != sql { + t.Fatal(errNotExpected) + } +} + func singleUpdate(t *testing.T) { gql := `mutation { product(id: 15, update: $update, where: { id: { eq: 1 } }) { @@ -84,7 +132,7 @@ func singleUpdate(t *testing.T) { } }` - sql := `WITH "products" AS (WITH "input" AS (SELECT {{update}}::json AS j) UPDATE products SET (name, description) = (SELECT name, description FROM input i, json_populate_record(NULL::products, i.j) t) WHERE (("products"."price") > 0) AND (("products"."price") < 8) AND (("products"."id") = 1) AND (("products"."id") = 15) RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + sql := `WITH "products" AS (WITH "input" AS (SELECT {{update}}::json AS j) UPDATE "products" SET (name, description) = (SELECT name, description FROM input i, json_populate_record(NULL::products, i.j) t) WHERE (("products"."price") > 0) AND (("products"."price") < 8) AND (("products"."id") = 1) AND (("products"."id") = 15) RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` vars := map[string]json.RawMessage{ "update": json.RawMessage(` { "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }`), @@ -108,7 +156,7 @@ func delete(t *testing.T) { } }` - sql := `WITH "products" AS (DELETE FROM products WHERE (("products"."price") > 0) AND (("products"."price") < 8) AND (("products"."id") = 1) RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` + sql := `WITH "products" AS (DELETE FROM "products" WHERE (("products"."price") > 0) AND (("products"."price") < 8) AND (("products"."id") = 1) RETURNING *) SELECT json_object_agg('product', sel_json_0) FROM (SELECT row_to_json((SELECT "sel_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "sel_0")) AS "sel_json_0" FROM (SELECT "products"."id", "products"."name" FROM "products") AS "products_0") AS "done_1337";` vars := map[string]json.RawMessage{ "update": json.RawMessage(` { "name": "my_name", "woo": { "hoo": "goo" }, "description": "my_desc" }`), @@ -129,5 +177,8 @@ func TestCompileInsert(t *testing.T) { t.Run("singleInsert", singleInsert) t.Run("bulkInsert", bulkInsert) t.Run("singleUpdate", singleUpdate) + t.Run("singleUpsert", singleUpsert) + t.Run("bulkUpsert", bulkUpsert) + t.Run("delete", delete) } diff --git a/qcode/qcode.go b/qcode/qcode.go index b1171bc..5b5b081 100644 --- a/qcode/qcode.go +++ b/qcode/qcode.go @@ -22,6 +22,7 @@ const ( ActionInsert Action = iota + 1 ActionUpdate ActionDelete + ActionUpsert ) type QCode struct { @@ -373,6 +374,9 @@ func (com *Compiler) compileArgs(sel *Select, args []Arg) error { case "update": sel.Action = ActionUpdate err = com.compileArgAction(sel, arg) + case "upsert": + sel.Action = ActionUpsert + err = com.compileArgAction(sel, arg) case "delete": sel.Action = ActionDelete err = com.compileArgAction(sel, arg)