Konzepte, Ideen, Strategien
Konzepte, Ideen, StrategienScripting-Fähigkeiten über Meta-Direktiven

Scripting-Fähigkeiten über Meta-Direktiven

Angenommen, wir haben eine Direktive @strTitleCase, die auf ein Feld in der Query angewendet werden kann und dessen Wert von "hello world!" in "Hello World!" umwandelt – es macht also Sinn, sie nur auf Felder vom Typ String anzuwenden.

Wenn wir diese Query ausführen:

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

...wird sie folgendes ausgeben:

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

Angenommen, der Feldtyp ist [String] (oder [String!]), wie in diesem Fall:

type Post {
  categoryNames: [String!]
}

Was sollte passieren, wenn die Direktive @strTitleCase auf das Feld categoryNames angewendet wird, wenn diese Query ausgeführt wird?

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

Idealerweise ist die Antwort eine Transformation jedes String-Werts innerhalb des Arrays:

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

Damit das funktioniert, muss der Direktiven-Resolver für @strTitleCase prüfen, ob die Eingabe ein Array ist, und entsprechend vorgehen (dieser PHP-Code ist ein Beispiel, die eigentliche Methode im Plugin ist anders):

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

Das ist nicht sehr schwierig. Aber was würde passieren, wenn das Feld ein Array von Arrays von String ist, also [[String]]? Auch wenn es etwas schwieriger ist, kann die Direktive damit auch umgehen:

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

Und was dann, wenn es [[[String]]] oder [[[[String]]]] ist? Das wird immer schwieriger zu implementieren.

Noch schlimmer: Dieser zusätzliche Boilerplate-Code müsste für jede Direktive implementiert werden, die auf Arrays angewendet werden kann. Um beispielsweise eine Direktive @strUpperCase zu implementieren, wird diese extra Logik ebenfalls benötigt:

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

Das sieht nicht sehr elegant aus, oder?

Lösung: die Eingabe einer Direktive über eine andere Direktive ändern

Genau hier kann es nützlich sein, eine Direktive anzuwenden, um das Verhalten einer anderen Direktive zu ändern.

Anstatt jeden möglichen Array-Exponenten für das Feld zu behandeln (d. h. String, [String], [[String]], [[[String]]], usw.), kann @strTitleCase einfach den Basisfall String behandeln:

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

Und dann kann eine andere Direktive @underEachArrayItem ihr Verhalten ändern, indem sie:

  1. Die einzelne Eingabe vom Typ [String] in ein Array von Eingaben vom Typ String umwandelt
  2. Die Elemente dieses Arrays durchläuft und für jedes die nachgelagerte Direktive (@strTitleCase) aufruft und anwendet, die dann eine Eingabe vom Typ String erhält
  3. Das Array von String-Werten wieder in einen einzelnen [String]-Wert zurückkonvertiert

Wir können dann diese Query ausführen:

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

Diese GIF zeigt @underEachArrayItem in Aktion:

Hinzufügen von @underEachArrayItem zum Ändern einer anderen Direktive

Das Schöne an dieser Lösung ist, dass sie die Tiefe des Arrays von der Implementierung der Direktive entkoppelt. Wenn die Eingabe vom Typ [[String]] ist, müssen wir lediglich ein zusätzliches @underEachArrayItem hinzufügen, das das @underEachArrayItem modifiziert, das die gewünschte Direktive modifiziert:

{
  customerAllNames @underEachArrayItem @underEachArrayItem @strTitleCase
}

...und produziert:

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

Wie wir also sehen können, kann eine Direktive, die eine Direktive modifiziert, auch in einer Pipeline von Direktiven auftreten, wobei eine von ihnen eine nachgelagerte Direktive beeinflusst und sie selbst von einer vorgelagerten Direktive modifiziert werden.

Wir nennen @underEachArrayItem eine „Meta-Direktive": eine Direktive, die das Verhalten einer anderen Direktive modifiziert. Damit verleiht sie dem Entwickler „Meta-Scripting"-Fähigkeiten, um Programmierlogik innerhalb der GraphQL-Query hinzuzufügen.

Die GraphQL-Query formatieren

Da Leerzeichen keinen semantischen Wert haben, können wir die Query und das SDL so formatieren, dass die Verschachtelung besser zum Ausdruck kommt:

{
  customerAllNames
    @underEachArrayItem
      @underEachArrayItem
        @strTitleCase
}

Eine Pipeline aus verschachtelten Direktiven definieren

Woher weiß @underEachArrayItem, dass es das Verhalten von @strTitleCase ändern muss? Im vorherigen Beispiel lag das daran, dass es direkt davor platziert war. Aber was sollte passieren, wenn wir eine weitere Direktive direkt danach haben?

In dieser Query beispielsweise:

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

...sollte @underEachArrayItem auch das Verhalten der Direktive @strTranslate ändern, da diese Direktive ebenfalls auf einen String angewendet werden muss, und folgende Antwort produzieren:

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

Eine danach platzierte Direktive könnte jedoch auch auf das Array angewendet werden müssen und nicht auf den einzelnen String-Wert. Die Direktive @arrayPad unten fügt beispielsweise fehlende Einträge in einem Array mit Standardwerten hinzu, sodass sie nicht von @underEachArrayItem beeinflusst werden sollte:

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

...und produziert diese Antwort:

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

Um zwischen den beiden Situationen zu unterscheiden, führen wir das Argument affectDirectivesUnderPos für @underEachArrayItem ein, das die relative Position der zu beeinflussenden Direktiven als Array von Int definiert.

In der folgenden Query weiß @underEachArrayItem, dass es auf @strTitleCase und @strTranslate angewendet werden muss, da sie sich an den relativen Positionen 1 und 2 von ihm befinden:

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

In dieser anderen Query wird @underEachArrayItem nur auf @strTitleCase (relative Position 1) angewendet, nicht aber auf @arrayPad:

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

Der Standardwert für affectDirectivesUnderPos ist [1]. Wenn er nicht angegeben wird, wird die Direktive immer auf die direkt folgende Direktive angewendet. Die obige Query ist daher äquivalent zu dieser:

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

Wir können jede beliebige Kombination von Direktiven definieren, die von der Meta-Direktive beeinflusst werden, und anderen, die es nicht werden:

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