Conceptos, ideas, estrategias
Conceptos, ideas, estrategiasCapacidades de scripting mediante meta-directivas

Capacidades de scripting mediante meta-directivas

Supongamos que tenemos una directiva @strTitleCase que se puede aplicar sobre el campo en la consulta, transformando su valor de "hello world!" a "Hello World!", por lo que tiene sentido aplicarla únicamente sobre campos de tipo String.

Al ejecutar esta consulta:

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

...producirá:

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

Ahora, supongamos que el tipo de campo es [String] (o [String!]), como en este caso:

type Post {
  categoryNames: [String!]
}

¿Qué debería ocurrir al aplicar la directiva @strTitleCase sobre el campo categoryNames al ejecutar esta consulta?

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

Lo ideal sería que la respuesta fuera una transformación de cada valor String dentro del array:

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

Para que eso ocurra, el resolver de la directiva @strTitleCase deberá comprobar si la entrada es un array y proceder en consecuencia (este código PHP es un ejemplo, el método real en el plugin es distinto):

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);
}

Eso no es muy difícil. Pero entonces, ¿qué ocurriría si el campo es un array de array de String, es decir, [[String]]? Aunque algo más difícil, la directiva también puede manejarlo:

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);
}

Y entonces, ¿qué pasa si es un [[[String]]] o [[[[String]]]]? Empieza a resultar difícil de implementar.

Peor aún, este boilerplate adicional habría que implementarlo para cualquier directiva que pudiera aplicarse sobre arrays. Por ejemplo, para implementar una directiva @strUpperCase, también será necesaria esta lógica extra:

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);
}

No queda muy bonito, ¿verdad?

Solución: modificar la entrada a una directiva mediante otra directiva

Aquí es donde aplicar una directiva para modificar el comportamiento de otra directiva puede resultar útil.

En lugar de tratar con cada posible exponente de arrays para el campo (es decir, String, [String], [[String]], [[[String]]], etc.), @strTitleCase puede tratar únicamente el caso 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);
}

Y entonces, otra directiva @underEachArrayItem puede modificar su comportamiento, mediante:

  1. Convertir la única entrada de tipo [String] en un array de entradas de tipo String
  2. Iterar los elementos de este array y, para cada uno, invocar y aplicar la directiva siguiente (@strTitleCase), que entonces recibirá una entrada de tipo String
  3. Convertir de nuevo el array de valores String en un único valor [String]

Podemos entonces ejecutar esta consulta:

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

Este gif muestra @underEachArrayItem en acción:

Añadiendo @underEachArrayItem para modificar otra directiva

La belleza de esta solución es que desacopla la profundidad del array de la implementación de la directiva. Si la entrada es de tipo [[String]], solo necesitamos añadir un @underEachArrayItem adicional, que modificará al @underEachArrayItem que modifica a la directiva deseada:

{
  customerAllNames @underEachArrayItem @underEachArrayItem @strTitleCase
}

...produciendo:

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

Así pues, como podemos apreciar, una directiva que modifica a una directiva también puede darse en una pipeline de directivas, donde una de ellas afecta a una directiva siguiente, y a su vez son modificadas por una directiva previa.

Llamamos a @underEachArrayItem una "meta-directiva": una directiva que modifica el comportamiento de otra directiva. Al hacerlo, está dando al desarrollador capacidades de "meta-scripting", para añadir algo de lógica de programación dentro de la consulta GraphQL.

Dando formato a la consulta GraphQL

Dado que los espacios en blanco no añaden valor semántico, podemos dar formato a la consulta y al SDL para transmitir mejor el anidamiento:

{
  customerAllNames
    @underEachArrayItem
      @underEachArrayItem
        @strTitleCase
}

Definiendo una pipeline de directivas anidadas

¿Cómo sabe @underEachArrayItem que debe modificar el comportamiento de @strTitleCase? En el ejemplo anterior, fue porque estaba colocada justo antes. Pero ¿qué debería ocurrir cuando tenemos otra directiva más justo después?

Por ejemplo, en esta consulta:

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

...@underEachArrayItem también debería modificar el comportamiento de la directiva @strTranslate, ya que esta directiva también debe aplicarse sobre un String, produciendo esta respuesta:

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

Sin embargo, una directiva colocada después también podría necesitar aplicarse sobre el array, y no sobre el valor individual String. Por ejemplo, la directiva @arrayPad de abajo añade entradas que faltan en un array con valores por defecto, por lo que no debe verse afectada por @underEachArrayItem:

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

...produciendo esta respuesta:

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

Para distinguir entre las dos situaciones, introducimos el argumento affectDirectivesUnderPos en @underEachArrayItem, que define la posición relativa de las directivas que deben verse afectadas, como un array de Int.

En la consulta de abajo, @underEachArrayItem sabe que debe aplicarse sobre @strTitleCase y @strTranslate, ya que están colocadas en las posiciones relativas 1 y 2 respecto a ella:

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

En esta otra consulta, @underEachArrayItem se aplica solo sobre @strTitleCase (posición relativa 1) pero no sobre @arrayPad:

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

El valor por defecto de affectDirectivesUnderPos es [1], por lo que si no se especifica, la directiva siempre se aplicará a la directiva inmediatamente posterior. La consulta de arriba es entonces equivalente a esta:

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

Podemos definir cualquier combinación de directivas afectadas por la meta-directiva, y otras no:

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