Skip to content

Latest commit

 

History

History
395 lines (332 loc) · 14.4 KB

mutation.md

File metadata and controls

395 lines (332 loc) · 14.4 KB

graphql-provider/Execute mutation

1. Basic usage

In the previous article, we discussed InputMapper, with the help of InputMapper, the implementation of mutation will be very simple.

Create a new package "com.example.demo.mutation", create a new class under it

package org.babyfish.graphql.provider.example.mutation

import org.babyfish.graphql.provider.ImplicitInput
import org.babyfish.graphql.provider.ImplicitInputs
import org.babyfish.graphql.provider.Mutation
import org.babyfish.graphql.provider.example.mapper.input.BookDeepTreeInputMapper
import org.babyfish.graphql.provider.example.mapper.input.BookInputMapper
import org.babyfish.graphql.provider.example.mapper.input.BookShallowTreeInputMapper
import org.babyfish.graphql.provider.example.model.Book
import org.babyfish.graphql.provider.runtime.R2dbcClient
import org.springframework.stereotype.Service

@Service // α
class BookMutation(
    private val r2dbcClient: R2dbcClient // β
) : Mutation {

    suspend fun saveBook(
        input: ImplicitInput<Book, BookInputMapper> // γ
    ): Int =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).totalAffectedRowCount

    suspend fun saveBooks(
        inputs: ImplicitInputs<Book, BookInputMapper> // δ
    ): List<Int> =
        r2dbcClient.save(inputs.entities, inputs.saveOptionsBlock).map { it.totalAffectedRowCount }

    suspend fun saveBookShallowTree(
        input: ImplicitInput<Book, BookShallowTreeInputMapper>
    ): Int =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).totalAffectedRowCount

    suspend fun saveBookDeepTree(
        input: ImplicitInput<Book, BookDeepTreeInputMapper>
    ): Int =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).totalAffectedRowCount
}
  • α:

    This object must be managed by spring

  • β:

    Inject org.babyfish.graphql.provider.runtime.R2dbcClient

  • γ:

    • org.babyfish.graphql.provider.ImplicitInput means an input object whose type is created by input mapper

    • org.babyfish.graphql.provider.ImplicitInput.entity can map input object to entity object.

  • δ:

    • org.babyfish.graphql.provider.ImplicitInputs means a list, each element is an input object whose type is created by input mapper

    • org.babyfish.graphql.provider.ImplicitInputs.entities can map input objects to entity objects.

This code shows that whether BookInput, BookShallowInput or BookDeepTreeInput in GraphQL Schema can be automatically mapped to Book by graphql-provider.

This is what the previous article said, graphql-provider does not require developers to face two similar but different objects (Entity and Input), and does not require developers to write unconstructive code and convert input to entity.

Entity objects such as Book, BookStore and Author are kimmer objects, kimmer objects support dynamics (see https://github.com/babyfish-ct/kimmer/blob/main/doc/kimmer-core/dynamic.md to know more). Whether it is a partial object, a complete object, a shallow object tree, or a deep object tree, it can be expressed as an entity object (Book here). This is why all three Input objects can be automatically mapped to Book objects.

Whether an entity object is a partial object, a complete object, a shallow object tree, or a deep object tree, the R2dbcClient.save() function allows developers to save it in one sentence, this is why the implementation of mutation is so simple.

2. Decide the return type of mutation

For simple demonstration, the above code makes mutation return an integer. This is an overly simplistic extreme, now let's look at the other extreme and see what R2dbcClient.save() actually returns

Let's modify the saveBookDeepTree function, let it return the original result returned by the underlying kimmer-sql.

suspend fun saveBookDeepTree(
        input: ImplicitInput<Book, BookDeepTreeInputMapper>
    ): org.babyfish.kimmer.sql.EntityMutationResult =
        r2dbcClient.save(input.entity, input.saveOptionsBlock)

Start the app, access http://localhost:8080/graphiql, execute

mutation {
  saveBookDeepTree(input: {
    name: "NewBook",
    price: 80,
    store: {
      name: "New Store"
    }
    authors: [
      { 
        firstName: "NewFirstName1",
        lastName: "NewLastName1",
        gender: MALE,
      },
      { 
        firstName: "NewFirstName2",
        lastName: "NewLastName2",
        gender: FEMALE
      }
    ]
  }) {
    totalAffectedRowCount
    type
    affectedRowCount
    row
    associations {
      associationName
      totalAffectedRowCount
      middleTableInsertedRowCount
      middleTableDeletedRowCount
      targets {
        totalAffectedRowCount
        type
        affectedRowCount
        row
        middleTableChanged
      }
      detachedTargets {
        totalAffectedRowCount
      }
    }
  }
}

You will get a response message like this

{
  "data": {
    "saveBookDeepTree": {
      "totalAffectedRowCount": 6, // α
      "type": "INSERT", // β
      "affectedRowCount": 1, // β
      "row": "{\"authors\":[{\"firstName\":\"NewFirstName1\",\"gender\":\"MALE\",\"lastName\":\"NewLastName1\",\"id\":\"79939500-3f1f-4171-94ab-90e9c8cf0709\"},{\"firstName\":\"NewFirstName2\",\"gender\":\"FEMALE\",\"lastName\":\"NewLastName2\",\"id\":\"143cb40b-7afe-410c-9b12-247b90579dd1\"}],\"name\":\"NewBook\",\"price\":80,\"store\":{\"name\":\"New Store\",\"website\":null,\"id\":\"0326d933-7978-4d65-a21a-efc274b69c11\"},\"id\":\"5a794a1d-73aa-4b79-8ffb-3eeca5393eca\"}", // γ
      "associations": [ 
        {
          "associationName": "store", // δ
          "totalAffectedRowCount": 1,
          "middleTableInsertedRowCount": 0,
          "middleTableDeletedRowCount": 0,
          "targets": [ // ε
            {
              "totalAffectedRowCount": 1, 
              "type": "INSERT", // ζ
              "affectedRowCount": 1, // ζ
              "row": "{\"name\":\"New Store\",\"website\":null,\"id\":\"0326d933-7978-4d65-a21a-efc274b69c11\"}", // η
              "middleTableChanged": false
            }
          ],
          "detachedTargets": [] // θ
        },
        {
          "associationName": "authors", // ι
          "totalAffectedRowCount": 4, // κ
          "middleTableInsertedRowCount": 2, // λ
          "middleTableDeletedRowCount": 0,
          "targets": [ // μ
            {
              "totalAffectedRowCount": 2,
              "type": "INSERT",  // ν
              "affectedRowCount": 1, ν
              "row": "{\"firstName\":\"NewFirstName1\",\"gender\":\"MALE\",\"lastName\":\"NewLastName1\",\"id\":\"79939500-3f1f-4171-94ab-90e9c8cf0709\"}", // ξ
              "middleTableChanged": true // ο
            },
            {
              "totalAffectedRowCount": 2,
              "type": "INSERT", // π
              "affectedRowCount": 1, // π
              "row": "{\"firstName\":\"NewFirstName2\",\"gender\":\"FEMALE\",\"lastName\":\"NewLastName2\",\"id\":\"143cb40b-7afe-410c-9b12-247b90579dd1\"}", // ρ
              "middleTableChanged": true // σ
            }
          ],
          "detachedTargets": [] // τ
        }
      ]
    }
  }
}
  • α: Total affect row count is 6, 1 (BOOK) + 1 (BOOK_STORE) + 2 (AUTHOR) + 2 (BOOK_AUTHOR_MAPPING)
  • β: Root object is inserted, affected row count is 1
  • γ: The root object after mutation, note that all object ids are automatically assigned
  • δ: The mutation result about the association Book.store
  • ε: One object is retained by the association Book.store (inserted, updated or not changed)
  • ζ: The associated BookStore is inserted, affected row count is 1
  • η: The associated object of Book.store after mutation, note that its id is automatically assigned
  • θ: No associated object of Book.store is detached after mutation
  • ι: The mutation result about the association Book.authors
  • κ: The association Book.authors affect 4 rows: 2 (AUTHOR) + 2 (BOOK_AUTHOR_MAPPING)
  • λ: The data of middle table BOOK_AUTHOR_MAPPING of many-to-many association is modified, affected row count is 2
  • μ: Two objects are retained by the association Book.authors (inserted, updated or not changed)
  • ν: The first associated object of Book.authors is inserted, affected count is 1
  • ξ: The first associated object of Book.authors after mutation, note that its id is automatically assigned
  • ο: In order to save the first associated object of Book.authors, the middle table has been changed
  • π: The second associated object of Book.authors is inserted, affected count is 1
  • ρ: The second associated object of Book.authors after mutation, note that its id is automatically assigned
  • σ: In order to save the second associated object of Book.authors, the middle table has been changed
  • τ: No associated object of Book.author is detached after mutation

Although the information returned by the underlying kimmer-sql is very rich, it is unnecessary to return all this information to the client in the actual project.

In a real project, you should make the complexity of returning information somewhere between these two extremes. Typically, this should be the saved entity object. You should modify the code to look like this

@Service
class BookMutation(
    private val r2dbcClient: R2dbcClient
) : Mutation {

    suspend fun saveBook(
        input: ImplicitInput<Book, BookInputMapper>
    ): Book =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()
        
    suspend fun saveBooks(
        inputs: ImplicitInputs<Book, BookInputMapper>
    ): List<Book> =
        r2dbcClient.save(inputs.entities, inputs.saveOptionsBlock).entities()

    suspend fun saveBookShallowTree(
        input: ImplicitInput<Book, BookShallowTreeInputMapper>
    ): Book =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()

    suspend fun saveBookDeepTree(
        input: ImplicitInput<Book, BookDeepTreeInputMapper>
    ): Book =
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()
}

Start the app, access http://localhost:8080/graphiql, execute

mutation {
  saveBookDeepTree(input: {
    name: "NewBook",
    price: 80,
    store: {
      name: "New Store"
    }
    authors: [
      { 
        firstName: "NewFirstName1",
        lastName: "NewLastName1",
        gender: MALE,
      },
      { 
        firstName: "NewFirstName2",
        lastName: "NewLastName2",
        gender: FEMALE
      }
    ]
  }) {
    id
    store {
      id
    }
    authors {
      id
    }
  }
}

You will get a response message like this

{
  "data": {
    "saveBookDeepTree": {
      "id": "7cd13e03-3bea-457a-81af-ece67f21b8e9",
      "store": {
        "id": "49028b2b-e08d-4e81-bd8c-af826e77392f"
      },
      "authors": [
        {
          "id": "d314dad5-d4ee-48cf-afba-e54e6e9c180e"
        },
        {
          "id": "da1d8c34-e4ab-4a53-8ff4-1b4028de0f26"
        }
      ]
    }
  }
}

The client can easily access the id assigned to each object after the mutation is executed

In fact, many GraphQL-related web front-end technologies (eg: Apollo client, Relay, graphql-state) will require you to design mutation return values in this way

3. Add transaction

The internal implementation of R2dbcClient.save will execute multiple SQLs, in order to ensure that the entire mutation is fully complete or completely undone.

After experimenting, the @Transactional annotation seems to have no effect on suspend functions. So you can use the DSL to complete the transaction configuration

There are two ways to use the transaction DSL

  1. Class level

    @Service
    class MyMutation(): Mutation() {
    
        override fun MutationDSL.config() {
            transaction()
        }
        
        suspend fun field1(...argument...): ReturnType = runtime.mutate {
            ... async code here...
        }
        
        suspend fun field2(...argument...): ReturnType = runtime.mutate {
            ... async code here...
        }
    }

    Once class-level configuration is used (either transactions as discussed here, or security as explained in subsequent documentation), all functions need to be wrapped in "runtime.mutate"

  2. Function level

    @Service
    class MyMutation(): Mutation() {
        
        suspend fun field1(...argument...): ReturnType = runtime.mutateBy {
            transaction()
            async {
                ... async code here...
            }
        }
        
        suspend fun field2(...argument...): ReturnType = runtime.mutateBy {
            transaction()
            async {
                ... async code here...
            }
        }
    }

The two usages can be mixed, and the function-level configuration will override the class-level configuration

In this example we use class level configuration, the final code is

@Service
class BookMutation(
    private val r2dbcClient: R2dbcClient
) : Mutation {

    override fun MutationDSL.config() {
        transaction()
    }
    
    suspend fun saveBook(
        input: ImplicitInput<Book, BookInputMapper>
    ): Book = runtime.muate {
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()
    }

    suspend fun saveBooks(
        inputs: ImplicitInputs<Book, BookInputMapper>
    ): List<Book> = runtime.muate {
        r2dbcClient.save(inputs.entities, inputs.saveOptionsBlock).entities()
    }

    @Transactional
    suspend fun saveBookShallowTree(
        input: ImplicitInput<Book, BookShallowTreeInputMapper>
    ): Book = runtime.muate {
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()
    }

    @Transactional
    suspend fun saveBookDeepTree(
        input: ImplicitInput<Book, BookDeepTreeInputMapper>
    ): Book = runtime.muate {
        r2dbcClient.save(input.entity, input.saveOptionsBlock).entity()
    }
}

< Previous: Map inputs | Home