Skip to content
This repository has been archived by the owner on Sep 2, 2022. It is now read-only.

Improved API for nested mutations #1280

Closed
1 task
sorenbs opened this issue Nov 14, 2017 · 22 comments
Closed
1 task

Improved API for nested mutations #1280

sorenbs opened this issue Nov 14, 2017 · 22 comments
Assignees
Labels
Milestone

Comments

@sorenbs
Copy link
Member

sorenbs commented Nov 14, 2017

Nested mutations is a powerful way to manipulate relational data. This proposal will add significant power to the existing nested mutations API.

One-relations

Given the following schema

type User {
  id: ID! @unique
  name: String!
  city: City @relation(name: "Hometown")
}

type City {
  id: ID! @unique
  name: String! @unique
  inhabitants: User @relation(name: "Hometown")
}

Create

createUser(
  data: {
    name: "Soren"
    city: {
      create: {
        name: "Berlin"
      }
      connect: {
        name: "Berlin"
      }
    }
  }
)

note that name on City is unique and can be used to select an existing node as described in #1267

Update

updateUser(
  where: {id: "..."}
  data: {
    city: {
      create: {
        name: "Berlin"
      }
      connect: {
        name: "Berlin"
      }
      update: {
        name: "Berlin"
      }
      upsert: {
        create: {
          name: "Berlin"
        }
        update: {
          name: "Berlin"
        } 
      }
      delete: true
      disconnect: true
    }
  }
)
  • connect: Find existing City node by a unique field and connect to the User
  • update: Update the City node currently connected to the User
  • create: Create a new City node and connect to the User
  • upsert: Update related node if exists, otherwise create. Only available if the relation field is optional
  • delete: Delete the associated node. Only available if the relation field is optional
  • disconnect: remove the associated node from relation. Only available if the relation field is optional

Only a single argument may be provided at a time. If two or more are provided, and error is returned.

note: If combinations turns out to be beneficial in the future we can introduce explicit combinations similar to upsert. I could imagine connectAndUpdate would be interesting.

Many-relations

Given the following schema

type User {
  id: ID! @unique
  name: String!
  cities: [City!]! @relation(name: "Towns")
}

type City {
  id: ID! @unique
  name: String! @unique
  inhabitants: User @relation(name: "Towns")
}

Create

createUser(
  data: {
    name: "Soren"
    cities: {
      create: [ {name: "Berlin"} ]
      connect: [ {name: "Berlin"} ]
    }
  }
)

Create and connect can be provided at the same time. If they are, create mutations will be executed before connect mutations.

Update

updateUser(
  where: {id: "..."}
  data: {
    cities: {
      create: [{ name: "Copenhagen" }]
      connect: [{ name: "Copenhagen" }]
      update: [{ where: { name: "Berlin" } data: {name: "Das Berlin" }}]
      upsert: [{ where: { name: "Berlin" } create: {name: "Das Berlin" } update: {name: "Das Berlin" }}]
      disconnect: [{ name: "Old Town" }]
      delete: [{ name: "Old Town" }]
    }
  }
)
  • connect: Find existing City nodes by a unique field and connect to the User
  • update: Update a City node currently connected to the User
  • create: Create a new City node and connect to the User
  • upsert: Update related node if exists, otherwise create
  • delete: Delete an associated node
  • disconnect: remove an associated node from relation

Note: we could introduce a way to "override" the relation. It could be called set. We would need to decide if it should disconnect or delete existing nodes in the relation. Maybe both should be available. Maybe it should also be possible to both connect and create nodes for the overriden relation. This needs more exploration and can be added later.

Relation mutations

With the introduction of more powerful nested mutations we no longer need the relation specific mutations for adding and removing nodes to a relation. Hence, we will remove these mutations to simplify the API.

Transactional Behaviour

Nested mutations are run in a transaction. If any part of the mutation fails the entire transaction is rolled back and the error is returned.

Note on the connect action

If you connect to a node that does not exist, that action will fail and the entire nested mutation will be rolled back.

Further thoughts

  • create-connect "upsert"
@kbrandwijk
Copy link
Contributor

I would look at the terminology used compared to that of scalar lists. I'd try to keep it as similar as possible, as for most use cases, it shouldn't really matter if you are working with scalar list, nested types, or relations. In this case, it applies to terminology for scalar list (push, set, remove) vs. nested mutations (connect, create, disconnect).

Also, the nested structure here really prevents an easy way of feeding an array of elements into the mutation, because they all need to be wrapped by create. For example, compare:

createUser(
  data: {
    name: "Soren"
    cities: [
      {
        create: {
          name: "Berlin"
        } 
      },
      {
        create: {
        name: "Wladiwostok"
      },
      {
        create: {
        name: "Taumatawhakatangi­hangakoauauotamatea­turipukakapikimaunga­horonukupokaiwhen­uakitanatahu"
      },
    }]
  }
)

to:

createUser(
  data: {
    name: "Soren"
    cities: [
      {
        create: [
          { name: "Berlin" },
          { name: "Wladiwostok" },
          { name: "Taumatawhakatangi­hangakoauauotamatea­turipukakapikimaunga­horonukupokaiwhen­uakitanatahu" }
        ]
      }
    ]
  }
)

@sorenbs
Copy link
Member Author

sorenbs commented Nov 15, 2017

Consider making upserts explicit

Note: Kims proposal could also support a replace operation

@btroia
Copy link

btroia commented Nov 29, 2017

@sorenbs agree that there should be an explicit upsert operation

@sorenbs
Copy link
Member Author

sorenbs commented Nov 30, 2017

@kbrandwijk I have incorporated your proposal!

@btroia I have incorporated explicit upsert. Would love your feedback :-)

@mlukaszczyk
Copy link

Hey @sorenbs do you think it will be possible in the near future to store relations with a custom order?

@quadsurf
Copy link

Connecting additional nodes with the new nested mutation API works fantastic, thanks!

@crtr0
Copy link

crtr0 commented Dec 12, 2017

Has this new API been shipped? I can't seem to get any of the examples above working in the Playground.

@sorenbs
Copy link
Member Author

sorenbs commented Dec 13, 2017

@crtr0 This will be part of Graphcool 1.0. We will release an early beta with partial support in a few days :-)

@tehfailsafe
Copy link

tehfailsafe commented Dec 13, 2017

Does this mean we will be able to pass in city: { create: and connect: } values so it would essentially create the city if there is none OR connect it if it does?

@sorenbs
Copy link
Member Author

sorenbs commented Dec 14, 2017

@tehfailsafe Currently the spec explicitly states that this is not valid. we can change this decision before the final 1.0 release, so it would be helpful if you can expand on the use case!

source:

Only a single argument may be provided at a time. If two or more are provided, and error is returned.

@marktani
Copy link
Contributor

marktani commented Dec 14, 2017

@tehfailsafe, would upsert cover your use case?

@sorenbs, the current spec doesn't list upsert as an option in the to-one create case. Is there a reason this shouldn't be supported:

createUser(
  data: {
    name: "Soren"
    city: {
      upsert: {
        create: {
          name: "Berlin"
        }
        update: {
          name: "Berlin"
        }
      }
    }
  }
)

This would

  • create a user
  • if the city Berlin exists, apply some update (actually it looks like the update should look differently to allow providing more fields that will be updated - maybe using where and data?)
  • if the city Berlin does not exist yet, create it
  • connect the new user to city Berlin

@tehfailsafe
Copy link

@marktani Maybe, but the wording sounds incorrect. I am not trying to update the value for the node, I am trying to connect it to the parent of the nested item if it exists, otherwise create. Upsert sounds like it will find "Berlin" and if I pass other fields it will update those?

@sorenbs The use case is quite common: Any time we are dealing with user input and don't know the result set of options and we need to group the data together. Take the city example above. What if we are using Algolia to sync our cities and because of Algolia's pricing model we don't want to pre-populate ALL the cities in the world (there's over 20,000 cities/towns in just the US!). So we allow the user to input a text field for their city. If it doesn't exist, create it so other people can also search for it, otherwise connect the user to the existing one.

Coming from rails this is the findOrCreate method and is extremely useful in many scenarios. Another good one is adding tags to post content with a comma separated input field. We want the tag values to be unique, and when creating the post it would be simple to just use a nested findOrCreate mutation to create the post, content, and it's tags. How else can we do that? Create an allTags query first so we can get back a list of tagIDs that match the inputs and then use them in the create mutation?

Maybe there is an easier way I just don't know about, so please let me know :)

My particular use case is uploading a CSV file, where a column is a relation and the row entries are results. I need to support uploading multiple CSVs which may or may not have the same column headers.

I am using a nested create, something like (names changed to just "rowField and column" to simplify)

createUser ( 
  rowField: {
    value: $rowValue
    column: {
      name: $columnValue
    }
  }
)

Here's the types:

type User @model {
	id: ID! @isUnique
	rowFields: [RowField!]! @relation(name: "UserAnswers")
	rowID: String! @isUnique
}

type Question @model {
	id: ID! @isUnique
	value: String! @isUnique
	answers: [Answer!]! @relation(name: "QuestionAnswers")
}

type Answer @model {
	id: ID! @isUnique
	value: String!
	question: Question! @relation(name: "QuestionAnswers")
	user: User! @relation(name: "UserAnswers")
}

Later I need to query for things like average the number of "X" answers to "Y", or more complicated things like for all users who answered X on Y, check some other boolean answer to question Z

The first time it works but the second time the column name field fails because it has a unique constraint.

@mavilein mavilein mentioned this issue Dec 14, 2017
42 tasks
@marktani marktani added this to the 1.0-beta1 milestone Dec 15, 2017
@do4gr
Copy link
Member

do4gr commented Dec 15, 2017

One issue that is not yet covered in the spec is the behavior of the api in case of failure. For example if no node for the where condition can be found, or the nodes are not connected.

Here is an example:

type top {
  id: ID! @unique
  uniqueTop: String! @unique
  bottom: Bottom @relation(name: "relation")
}

type bottom {
  id: ID! @unique
  uniqueBottom: String! @unique
  top: Top @relation(name: "relation")
}
mutation {
  deleteBottom(
    where: {
      id: "NOT A VALID BOTTOM ID"
      }
      ){
        uniqueBottom
      }
    }

This will return a '3002' error code and a message that no node with that id exists for 'Bottom'.

Currently the new nested mutations will not fail if no item can be found for the where condition
in the nested mutation or the two items are not connected. It will ignore the nested part of the mutation and normally return the values requested as response.

mutation {
  updateTop(
    where: {
      id: "TopId"
    }
    data:{
      bottom: {
        delete: {id: "NOT A VALID BOTTOM ID"}
      }
    }
  ){
    id
  }
}

The same situation can arise if the ids of top and bottom are valid but the two are not connected.
In this case we currently also silently ignore the nested mutation and do not error.

This hides failures from the user and necessitates extra checks by the user on the result to determine the outcome of the mutation. Asking for the changed value and checking them against the expected result for example.It also is a difference in behavior from the non-nested case that users might not expect.

One potential drawback is that users might expect behavior similar to SQL where this would not error.

But we propose, that nested mutations using a where clause should behave from an error-handling perspective as if they were non-nested mutations. Which means in this case erroring and returning a message that the id could not be found.

@marktani
Copy link
Contributor

But we propose, that nested mutations using a where clause should behave from an error-handling perspective as if they were non-nested mutations. Which means in this case erroring and returning a message that the id could not be found.

Returning an error or not is an independent decision of creating the outer node or node.
So in your proposal, will the outer note be created or not?

@marktani
Copy link
Contributor

Another common scenario for failure:

Consider the case of to-many and this mutation:

createUser(
  data: {
    name: "Soren"
    cities: [
      {
        create: [
          { name: "Berlin" },
          { name: "Wladiwostok" }
        ]
      }
    ]
  }
)

Let's say city Wladiwostok already exists. It can't be created again, based on the @unique constraint.

What happens? Potential outcomes:

  • No nodes are created (neither User nor City); error is returned
  • Only outer node is created (User); data & error is returned
  • Only outer node (User) and all other inner nodes (City, except the 'offending' ones); data & error is returned

@kbrandwijk
Copy link
Contributor

I would vote for all-succeed or all-fail approach. So I would go for:

No nodes are created (neither User nor City); error is returned

@do4gr
Copy link
Member

do4gr commented Dec 17, 2017

In my example the outer mutation is an update mutation whose whole effect it would be to delete the inner node. Since the inner mutation fails and the outer one would have no effect at all. In this case I would consider it failing too.

We should therefore error on the mutation as a whole when any of the nested parts fails due to an erroneous where clause.

Edit: I only saw @marktani's and @kbrandwijk's comments after leaving mine. And I agree with their conclusion to fail completely.

@marktani
Copy link
Contributor

The shape of upsert objects currently looks like this:

where
update
create

I prefer

update
  where
  data
create

This aligns with the shape of conventional update mutations.

@sorenbs
Copy link
Member Author

sorenbs commented Dec 28, 2017

Added a section on transactional behaviour that should mirror the consensus above.

@marktani I don't have a strong opinion on the structure of the upsert mutation. If you (or anyone else) can provide a compelling reason to change it we will. Otherwise it stays as is.

@do4gr
Copy link
Member

do4gr commented Dec 29, 2017

@marktani shouldn't the new shape then look like this?

update
   where
   data
create
   data

And also in the example of nested mutations like in my example further up

mutation a{
  updateUser(
    where: {
      name: "Paul"
    }
    data:{
      post: {
        delete: {title: "NOT A VALID BOTTOM ID"}
      }
    }
  ){
    id
  }
}

It feels a little weird that the outer mutation needs a where while the inner delete does not accept a where but goes straight to the unique fields. A non-nested delete also requires the where. The behavior also differs depending on the nested mutation. For delete and create we infer the data and where level because we can but for update and upsert the where and data levels remain required in the nested case.

It would probably be more intuitive if mutations have the same shape independent of whether they are nested or not. The drawback would of course be that the mutation would get one more level of nesting even in cases where we could infer it.

@Tapppi
Copy link

Tapppi commented May 5, 2018

Was this proposal abandoned and why? Transactional guarantees of some kind for mutations seems undocumented?

@marktani
Copy link
Contributor

marktani commented May 5, 2018

This proposal has been implemented. You can read more about the transactional properties of nested mutations here: https://www.prisma.io/docs/reference/prisma-api/concepts-utee3eiquo#transactional-mutations.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests