Concepts, idées, stratégies
Concepts, idées, stratégiesCapacités de scripting via les méta-directives

Capacités de scripting via les méta-directives

Supposons que nous ayons une directive @strTitleCase qui peut être appliquée sur le champ dans la requête, transformant sa valeur de "hello world!" en "Hello World!", il est donc logique de l'appliquer uniquement sur des champs de type String.

En exécutant cette requête :

{
  post(by: { id: 1 }) {
    title @strTitleCase
  }
}

...elle produira :

{
  "data": {
    "post": {
      "title": "Hello World!"
    }
  }
}

Maintenant, supposons que le type du champ soit [String] (ou [String!]), comme dans ce cas :

type Post {
  categoryNames: [String!]
}

Que doit-il se passer lorsque l'on applique la directive @strTitleCase sur le champ categoryNames en exécutant cette requête ?

{
  post(by: { id: 1 }) {
    categoryNames @strTitleCase
  }
}

Idéalement, la réponse sera une transformation de chaque valeur String à l'intérieur du tableau :

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Web Development",
        "Mobile App"
      ]
    }
  }
}

Pour que cela se produise, le resolver de la directive @strTitleCase devra vérifier si l'entrée est un tableau, et procéder en conséquence (ce code PHP est un exemple, la méthode réelle dans le plugin est différente) :

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }
 
  // Convert the String value to title case
  return ucwords($value);
}

Ce n'est pas très difficile. Mais alors, que se passerait-il si le champ est un tableau de tableau de String, c'est-à-dire [[String]] ? Même si c'est un peu plus difficile, la directive peut également le gérer :

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to title case
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(ucwords(...), $array),
      $value
    );
  }
 
  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }
 
  // Convert the String value to title case
  return ucwords($value);
}

Et ensuite, que faire si c'est un [[[String]]] ou [[[[String]]]] ? Cela commence à devenir difficile à implémenter.

Pire encore, ce code boilerplate supplémentaire devrait être implémenté pour toute directive susceptible d'être appliquée sur des tableaux. Par exemple, pour implémenter une directive @strUpperCase, cette logique supplémentaire sera également nécessaire :

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to uppercase
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(strtoupper(...), $array),
      $value
    );
  }
 
  // Convert each item in an array to uppercase
  if ($schemaDef['isArray']) {
    return array_map(strtoupper(...), $value);
  }
 
  // Convert the String value to uppercase
  return strtoupper($value);
}

Ce n'est pas très élégant, n'est-ce pas ?

Solution : modifier l'entrée d'une directive via une autre directive

C'est là qu'appliquer une directive pour modifier le comportement d'une autre directive peut s'avérer utile.

Plutôt que de traiter chaque exposant possible de tableaux pour le champ (c'est-à-dire String, [String], [[String]], [[[String]]], etc.), @strTitleCase peut simplement traiter le cas de base String :

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // The input will always be `String`
  // Convert the String value to title case
  return ucwords($value);
}

Et ensuite, une autre directive @underEachArrayItem peut modifier son comportement, en :

  1. Convertissant l'entrée unique de type [String] en un tableau d'entrées de type String
  2. Itérant les éléments de ce tableau et, pour chacun, en invoquant et appliquant la directive suivante (@strTitleCase), qui recevra alors une entrée de type String
  3. Reconvertissant le tableau de valeurs String en une valeur [String] unique

Nous pouvons alors exécuter cette requête :

{
  post(by: { id: 1 }) {
    categoryNames @underEachArrayItem @strTitleCase
  }
}

Ce gif montre @underEachArrayItem en action :

Ajout de @underEachArrayItem pour modifier une autre directive

La beauté de cette solution est qu'elle découple la profondeur du tableau de l'implémentation de la directive. Si l'entrée est de type [[String]], il suffit d'ajouter un @underEachArrayItem supplémentaire, qui modifiera le @underEachArrayItem qui modifie la directive souhaitée :

{
  customerAllNames @underEachArrayItem @underEachArrayItem @strTitleCase
}

...produisant :

{
  "data": {
    "customerAllNames": [
      [
        "John",
        "Edward",
        "Stevenson"
      ],
      [
        "Samantha",
        "Perkins"
      ],
      [
        "Michael",
        "Edward",
        "Higgs"
      ]
    ]
  }
}

Ainsi, comme nous pouvons le constater, une directive modifiant une directive peut également se produire dans un pipeline de directives, où l'une d'elles affecte une directive en aval, et où elles sont elles-mêmes modifiées par une directive en amont.

Nous appelons @underEachArrayItem une « méta-directive » : une directive qui modifie le comportement d'une autre directive. Ce faisant, elle donne au développeur des capacités de « méta-scripting », pour ajouter de la logique de programmation à l'intérieur de la requête GraphQL.

Mise en forme de la requête GraphQL

Étant donné que les espaces blancs n'ajoutent pas de valeur sémantique, nous pouvons mettre en forme la requête et le SDL pour mieux exprimer l'imbrication :

{
  customerAllNames
    @underEachArrayItem
      @underEachArrayItem
        @strTitleCase
}

Définir un pipeline de directives imbriquées

Comment @underEachArrayItem sait-il qu'il doit modifier le comportement de @strTitleCase ? Dans l'exemple précédent, c'est parce qu'elle était placée juste avant. Mais que doit-il se passer lorsque nous avons encore une autre directive juste après elles ?

Par exemple, dans cette requête :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
        @strTranslate(to: "es")
  }
}

...@underEachArrayItem devrait également modifier le comportement de la directive @strTranslate, car cette directive doit également être appliquée à un String, produisant cette réponse :

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Desarrollo web",
        "Aplicación movil"
      ]
    }
  }
}

Cependant, une directive placée ensuite pourrait également avoir besoin d'être appliquée au tableau, et non à la valeur String individuelle. Par exemple, la directive @arrayPad ci-dessous ajoute des entrées manquantes dans un tableau avec des valeurs par défaut, elle ne doit donc pas être affectée par @underEachArrayItem :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

...produisant cette réponse :

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Web Development",
        "Mobile App",
        "undefined",
        "undefined"
      ]
    }
  }
}

Afin de distinguer les deux situations, nous introduisons l'argument affectDirectivesUnderPos dans @underEachArrayItem, qui définit la position relative des directives qui doivent être affectées, sous la forme d'un tableau d'Int.

Dans la requête ci-dessous, @underEachArrayItem sait qu'elle doit être appliquée à @strTitleCase et @strTranslate, car elles sont placées aux positions relatives 1 et 2 par rapport à elle :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1, 2])
        @strTitleCase
        @strTranslate(to: "es")
  }
}

Dans cette autre requête, @underEachArrayItem est appliquée uniquement à @strTitleCase (position relative 1) mais pas à @arrayPad :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1])
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

La valeur par défaut de affectDirectivesUnderPos est [1], donc si elle n'est pas spécifiée, la directive sera toujours appliquée à la directive qui la suit immédiatement. La requête ci-dessus est alors équivalente à celle-ci :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

Nous pouvons définir n'importe quelle combinaison de directives affectées par la méta-directive, et d'autres non :

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1, 2])
        @strTitleCase
        @strTranslate(to: "es")
      @arrayPad(length: 5, value: "undefined")
  }
}