Comparando argumentos de campo y directivas
La misma funcionalidad para modificar la salida de un campo en GraphQL a menudo puede lograrse mediante dos métodos diferentes:
- Argumentos de campo:
field(arg: value) - Directivas de tipo consulta:
field @directive
(Las directivas de tipo consulta son aquellas que se aplican a la consulta en el lado cliente, en contraste con las directivas de tipo esquema, que se aplican mediante SDL -Schema Definition Language- al construir el esquema en el servidor. Como Gato GraphQL crea el esquema a partir de código PHP, no de SDL, sus directivas son todas del tipo consulta y simplemente se las menciona como "directivas".)
Por ejemplo, convertir la respuesta de un campo title a mayúsculas podría lograrse pasando un field arg format con un valor enum UPPERCASE, así:
{
posts {
title(format: UPPERCASE)
}
}o aplicando una directiva @strUpperCase sobre el campo, así:
{
posts {
title @strUpperCase
}
}En ambos casos, la respuesta del servidor GraphQL será la misma:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}¿Cuándo deberíamos usar argumentos de campo y cuándo directivas del lado consulta? ¿Hay alguna diferencia entre los dos métodos, o alguna situación en la que una opción sea mejor que la otra?
Para qué sirven los argumentos de campo y las directivas
Resolver un campo en GraphQL implica dos operaciones diferentes:
- obtener los datos solicitados de la entidad consultada
- aplicar funcionalidad (como formateo) a los datos solicitados
Podemos etiquetar estas dos operaciones como "resolución de datos" y "aplicación de funcionalidad", o, abreviando, como "datos" y "funcionalidad", respectivamente.
La principal diferencia entre los argumentos de campo y las directivas es que los argumentos de campo pueden usarse tanto para "datos" como para "funcionalidad", pero las directivas sólo pueden usarse para "funcionalidad".
Veamos un poco más en detalle qué significa esto.
Resolución de datos mediante argumentos de campo
Los argumentos de campo se procesan al resolver el campo, por lo que pueden utilizarse para recuperar los datos reales, como decidir qué propiedad del objeto se accede.
Por ejemplo, este código de resolver muestra cómo el argumento size se utiliza para obtener una u otra fuente de imagen del tipo de objeto Media:
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}Los field args también pueden usarse para ayudar a decidir qué fila o columna de la tabla de la BD debe consultarse.
En esta consulta, el argumento de campo id se usa para consultar una entidad específica de tipo Post, que el resolver traducirá a una fila concreta de la tabla wp_posts de la BD de WordPress:
{
post(by: { id: 1 }) {
title
}
}La misma tabla almacena la fecha de la entrada en dos columnas distintas, post_modified y post_modified_gmt (por razones de compatibilidad hacia atrás). En esta consulta, pasar el argumento de campo gmt con true o false se traduce en obtener el valor de una u otra columna:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Estos ejemplos demuestran que los field args pueden modificar la fuente de los datos al resolver el campo.
Las directivas no pueden usarse para modificar la fuente de los datos, porque su lógica se proporciona mediante directive resolvers, que se invocan después del field resolver. Por tanto, cuando se aplica la directiva, el valor del campo ya debe haberse recuperado.
Por ejemplo, esta consulta nunca funcionará:
{
post @selectEntity(id: 1) {
title
}
}En este ejemplo, el campo post requiere que se le proporcione el id de la entidad, y como no se proporciona como argumento de campo, el servidor devolverá un error:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}En conclusión, sólo los argumentos de campo pueden ayudar a recuperar los datos que resuelven el campo.
Aplicar funcionalidad mediante argumentos de campo o directivas
Una vez que recuperamos los datos para el campo, puede que queramos manipular su valor. Por ejemplo, podríamos:
- Formatear una cadena, convirtiéndola a mayúsculas o minúsculas
- Formatear una fecha representada con una cadena, del formato por defecto
YYYY-mm-ddadd/mm/YYYY - Enmascarar una cadena, reemplazando emails y números de teléfono con
*** - Proporcionar un valor por defecto si es
nullo está vacío - Redondear floats a 2 dígitos
Cualquiera de estas operaciones es una manipulación de datos ya recuperados. Por tanto, pueden codificarse tanto en el field resolver, justo después de obtener los datos y antes de devolverlos, como en el directive resolver, que recibirá el valor del campo como entrada. Como tal, cualquiera de estas operaciones puede implementarse mediante argumentos de campo o directivas.
Por ejemplo, el field resolver para Post.excerpt podría proporcionar un valor por defecto mediante un field arg default, y luego podemos personalizar el valor del arg default en la consulta:
{
posts {
excerpt(default: "(No excerpt)")
}
}También podemos crear una directiva @default, con un directive resolver como este:
/**
* Replace all the empty results with the default value
*/
function resolveDirective(
array $directiveArgs,
array $objectIDFields,
array $objectsByID,
array &$responseByObjectIDAndField
): void {
foreach ($objectIDFields as $id => $fields) {
$object = $objectsByID[$id];
$defaultValue = $directiveArgs['value'];
foreach ($fields as $field) {
if (empty($responseByObjectIDAndField[$id][$field])) {
$responseByObjectIDAndField[$id][$field] = $defaultValue;
}
}
}
}¿Son estas dos estrategias igualmente adecuadas? Exploremos esta cuestión basándonos en distintas áreas de interés.
Los argumentos de campo están mejor cubiertos por la especificación de GraphQL
El alcance hasta el que se permite operar a las directivas no está claramente definido en la especificación de GraphQL, que reza:
Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.
Esta definición consiente el uso de directivas como @include y @skip, que condicionalmente incluyen y omiten un campo respectivamente, y @stream y @defer, que proporcionan una ejecución en tiempo de ejecución distinta para recuperar datos del servidor.
Sin embargo, esta definición no es inequívoca respecto a las directivas que modifican el valor de un campo, como @strUpperCase, que transforma el valor de salida "Hello world!" en "HELLO WORLD!".
Debido a esta ambigüedad, distintos servidores, clientes y herramientas GraphQL pueden tener en cuenta las directivas en distinta medida, creando conflictos entre ellos.
Y un ejemplo de esto es Relay, que no tiene en cuenta las directivas a la hora de cachear los valores de los campos. Si primero consultamos:
{
post(by: { id: 1 }) {
title
}
}...Relay consultará y cacheará el valor "Hello world!" para la entrada con ID 1. Si luego ejecutamos esta consulta:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...la respuesta debería ser "HELLO WORLD!", sin embargo Relay devolverá "Hello world!", que es el valor almacenado en su caché para la entrada con ID 1, ignorando la directiva aplicada sobre el campo.
Si se permite o no que las directivas modifiquen el valor de salida del campo está en una zona gris, ya que ni se permite ni se prohíbe explícitamente en la especificación de GraphQL, pero hay indicadores para ambas situaciones opuestas.
Por un lado, la especificación de GraphQL parece conceder a las directivas carta blanca para mejorar y personalizar GraphQL:
As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.
Por otro lado, la especificación no tiene en cuenta las directivas para la validación FieldsInSetCanMerge ni para el algoritmo CollectFields. La siguiente consulta GraphQL es válida, sin embargo es incierto qué respuesta obtendrá el usuario:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}Dependiendo del comportamiento del servidor GraphQL, la respuesta para el campo name puede ser "Leo", "LEO" o "leo"... no lo sabemos de antemano, y eso es un problema.
El mismo problema no ocurre con los argumentos de campo. Cuando se ejecuta la siguiente consulta:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...la especificación dicta al servidor GraphQL que devuelva un error, por lo que el valor de name será null. Nos veríamos entonces obligados a introducir alias para ejecutar la consulta:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}Las directivas son mejores para la modularidad y la reutilización de código
Muchas de las operaciones ofrecidas por las directivas son agnósticas respecto a la entidad y campo donde se aplican. Por ejemplo, @strUpperCase funcionará sobre cualquier cadena, ya se aplique sobre el título de una entrada, el nombre de un usuario, la dirección de una ubicación o cualquier otra cosa.
Como consecuencia, el código de esta directiva se implementa una sola vez y en un único lugar, el directive resolver. De forma similar a la programación orientada a aspectos (que aumenta la modularidad al permitir la separación de cross-cutting concerns), las directivas se aplican sobre el campo sin afectar a la lógica del campo.
En contraste, implementar la misma funcionalidad mediante un argumento de campo implica ejecutar el mismo código a lo largo del field resolver (y de distintos field resolvers):
function formatString(string $string, string $format): string
{
if ($format === "UPPERCASE") {
return strtoupper($string);
}
if ($format === "LOWERCASE") {
return strtolower($string);;
}
return $string;
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$format = $fieldDataAccessor->getValue('format');
if ($fieldDataAccessor->getFieldName() === 'title') {
return formatString($post->post_title, $format);
}
if ($fieldDataAccessor->getFieldName() === 'excerpt') {
return formatString($post->post_excerpt, $format);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return formatString($post->post_content, $format);
}
// ...
}Para reducir la cantidad de código en los resolvers, entonces las directivas son más adecuadas que los argumentos de campo.
Las directivas son mejores para el diseño del esquema
Añadir argumentos de campo añadirá información extra al esquema, posiblemente sobrecargándolo y haciéndolo inconsistente.
Por ejemplo, un argumento de campo format deberá añadirse a todos los campos String y, si no tenemos cuidado, puede no ser homogéneo entre campos, como usar distintos nombres, distintos valores, distintos valores por defecto, o incluso dividir el argumento en varias entradas:
type Post {
# Input value is "uppercase" or "strLowerCase"
title(format: String): String
content(format: String): String
excerpt(format: String): String
}
type Category {
# Input name is "case" instead of "format"
# Input value is an enum StringCase with values UPPERCASE and LOWERCASE
name(case: StringCase): String
}
type Tag {
# Using a default value
name(format: String = "strLowerCase"): String
}
type User {
# Using multiple Boolean inputs
description(useUppercase: Boolean, useLowercase: Boolean): String
}Las directivas nos permiten mantener el esquema lo más esbelto posible:
directive @strUpperCase on FIELD
directive @strLowerCase on FIELD
type Post {
title: String
content: String
excerpt: String
}
type Category {
name: String
}
type Tag {
name: String
}
type User {
description: String
}Las directivas pueden ser más eficientes que los argumentos de campo
En tiempo de ejecución, se accederá a un argumento de campo al resolver el campo, lo que ocurre campo a campo y objeto a objeto. Por ejemplo, al resolver los campos title y content en una lista de entradas, el resolver se invocará una vez por cada entrada y campo:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Imagina que queremos traducir estas cadenas usando la API de Google Translate, para lo cual añadimos el argumento translateTo:
function executeGoogleTranslate(string $string, string $lang): string
{
// Execute against https://translation.googleapis.com
// ...
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$lang = $fieldDataAccessor->getValue('lang');
if ($fieldDataAccessor->getFieldName() === 'title') {
return executeGoogleTranslate($post->post_title, $lang);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return executeGoogleTranslate($post->post_content, $lang);
}
// ...
}Como la lógica se ejecuta naturalmente por combinación de campo y objeto, podemos terminar solicitando una gran cantidad de conexiones a la API externa, produciendo una respuesta lenta al resolver la consulta.
Además, ejecutar las llamadas de forma independiente unas de otras no permitirá asociar sus datos, por lo que la calidad de la traducción será inferior a si todos los datos se enviaran juntos en una única llamada a la API.
Por ejemplo, el título de una entrada "Power" puede traducirse mejor si el contenido de la entrada, que deja claro que esta palabra se refiere a "electrical power", se envía junto con él.
Gato GraphQL invoca una directiva una sola vez, pasando todos los campos y objetos a los que aplicar como entrada. Al recibir todos los datos juntos, la directiva @strTranslate puede ejecutar una única llamada a Google Translate pasando todos los campos title y content para todos los objetos, como en esta consulta:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}Las directivas pueden proporcionar una forma más eficiente de modificar el valor de los campos, como al interactuar con APIs externas.