Concetti, idee, strategie
Concetti, idee, strategieSpiegazione delle mutation annidate

Spiegazione delle mutation annidate

Le mutation sono operazioni che possono modificare i dati sul server GraphQL, ad esempio durante la creazione di un articolo, l'aggiornamento del nome di un utente, l'aggiunta di un commento a un articolo, o altro.

In GraphQL, le mutation sono esposte unicamente sotto il tipo MutationRoot, in questo modo:

type MutationRoot {
  createPost(id: ID!, title: String!, content: String): Post!
  updateUserName(userID: ID!, newName: String!): User!
  addCommentToPost(postID: ID!, comment: String!, userID: ID): Comment!
}

(Lo schema GraphQL in questa guida serve a illustrare gli esempi; è diverso dallo schema fornito dal plugin.)

Con questo schema, la modifica del nome dell'utente si effettua così:

mutation {
  updateUserName(userID: 37, newName: "Peter") {
    name
  }
}

Le mutation sono esposte unicamente nel mutation root object type per imporre che vengano eseguite in serie, come spiega la specifica GraphQL:

It is expected that the top level fields in a mutation operation perform side‐effects on the underlying data system. Serial execution of the provided mutations ensures against race conditions during these side‐effects.

Il termine "esecuzione seriale" si contrappone a "esecuzione parallela", che è peraltro il comportamento raccomandato per la risoluzione dei campi.

Ad esempio, nella query qui sotto, non importa quale campo (se name o email) il server GraphQL risolva per primo, e questi campi possono essere risolti in parallelo:

query {
  user(by: { id: 37 }) {
    name
    email
  }
}

Le mutation modificano i dati, però, quindi l'ordine in cui i campi vengono risolti è importante, e perciò devono essere eseguite in serie (altrimenti potrebbero provocare race conditions).

Ad esempio, le due query qui sotto produrranno risultati diversi:

# Query 1: dopo l'esecuzione, il nome dell'utente sarà "John"
mutation {
  updateUserName(userID: 37, newName: "Peter") {
    name
  }
  updateUserName(userID: 37, newName: "John") {
    name
  }
}
 
# Query 2: dopo l'esecuzione, il nome dell'utente sarà "Peter"
mutation {
  updateUserName(userID: 37, newName: "John") {
    name
  }
  updateUserName(userID: 37, newName: "Peter") {
    name
  }
}

La conseguenza dell'esporre le mutation unicamente tramite MutationRoot è che questo tipo diventa molto ingombrante, contenendo campi che non hanno nulla in comune tra loro se non il dover essere eseguiti in serie (il che è una questione tecnica, e non una decisione di progettazione dell'interfaccia).

Il caso a favore delle mutation annidate

Tra le mutation qui sopra, solo createPost vive veramente sotto il tipo MutationRoot, perché crea un nuovo elemento dal nulla. Le mutation updateUserName e addCommentToPost, invece, possono perfettamente avere operazioni equivalenti applicate su un'entità esistente di un altro tipo:

type User {
  updateName(newName: String!): User!
}
 
type Post {
  addComment(comment: String!, userID: ID): Comment!
}

Con questo schema, la modifica del nome dell'utente potrebbe essere realizzata così:

mutation {
  user(ID: 37) {
    updateName(newName: "Peter") {
      name
    }
  }
}

Questa funzionalità si chiama "nested mutations": applicare una mutation al risultato di un'altra operazione, che si tratti di una query o di una mutation.

Nota come l'utilizzo delle mutation annidate renda lo schema GraphQL più elegante:

  • Mentre l'operazione MutationRoot.updateUserName deve ricevere l'ID dell'utente, la sua operazione equivalente User.updateName non ne ha bisogno, perché viene già eseguita su un'entità utente
  • Il nome del campo viene abbreviato da updateUserName a updateName

Inoltre, il servizio GraphQL diventa più semplice e più comprensibile, perché possiamo navigare tra le entità del grafo per modificare i loro dati allo stesso modo in cui li interroghiamo.

Le mutation annidate possono scendere su più livelli. Ad esempio, possiamo aggiungere un commento a un articolo appena creato, il tutto in una singola query:

mutation {
  createPost(ID: 37, title: "Hello world!", content: "Just another post") {
    id
    addComment(comment: "Lovely post") {
      id
    }
  }
}

Da qui, le mutation annidate possono anche migliorare le prestazioni riducendo la latenza degli andata e ritorno, passando dall'esecuzione di più query per mutare diversi elementi all'esecuzione di una singola query.

Perché le mutation annidate non fanno parte della specifica

La specifica GraphQL è concepita per funzionare con tutte le implementazioni di server GraphQL per qualsiasi linguaggio. Tuttavia, la sua forza motrice è JavaScript tramite graphql-js, l'implementazione di riferimento.

In altre parole, qualsiasi funzionalità che non possa essere supportata da graphql-js non farà parte della specifica.

Poiché JavaScript supporta le promises, la risoluzione parallela dei campi era realizzabile, e il parallelismo è diventato uno dei principi fondamentali nella progettazione iniziale di graphql-js, come testimonia DataLoader (il livello di recupero dei dati), le cui funzioni di batching restituiscono JavaScript promises.

I vantaggi dell'esecuzione parallela per le prestazioni sono troppo numerosi, e le mutation annidate non possono funzionare con il parallelismo. È stato deciso che lo scambio dell'esecuzione parallela con le mutation annidate non ne varrebbe la pena.

Mutation annidate e prestazioni

Per il plugin Gato GraphQL, i campi vengono sempre risolti in serie, e l'ordine in cui vengono risolti è deterministico. (Questa caratteristica non incide sulle prestazioni di risoluzione della query, perché il server trasforma prima il grafo della query in un modello a componenti, che viene risolto in un tempo lineare ottimale).

Il che significa che il plugin può supportare le mutation annidate, beneficiando di tutti i loro vantaggi, senza subirne gli svantaggi.

Specifica GraphQL

Questa funzionalità attualmente non fa parte della specifica GraphQL, ma è stata richiesta in: