Comparando argumentos de campo e diretivas
A mesma funcionalidade para modificar o output de um campo em GraphQL frequentemente pode ser obtida por dois métodos diferentes:
- Argumentos de campo:
field(arg: value) - Diretivas de tipo query:
field @directive
(As diretivas de tipo query são aquelas aplicadas na query do lado do cliente, em contraste com as diretivas de tipo schema, que são aplicadas via SDL -Schema Definition Language- ao construir o schema no servidor. Como o Gato GraphQL cria o schema a partir de código PHP, e não de SDL, suas diretivas são todas do tipo query e são simplesmente referenciadas como "diretivas".)
Por exemplo, converter a resposta de um campo title para maiúsculas poderia ser obtido passando um field arg format com o valor enum UPPERCASE, assim:
{
posts {
title(format: UPPERCASE)
}
}ou aplicando uma diretiva @strUpperCase no campo, assim:
{
posts {
title @strUpperCase
}
}Em ambos os casos, a resposta do servidor GraphQL será a mesma:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}Quando devemos usar argumentos de campo e quando usar diretivas no lado da query? Existe alguma diferença entre os dois métodos, ou alguma situação em que uma opção é melhor que a outra?
Para que servem os argumentos de campo e as diretivas
Resolver um campo em GraphQL envolve duas operações distintas:
- buscar os dados solicitados da entidade consultada
- aplicar funcionalidades (como formatação) sobre os dados solicitados
Podemos chamar essas duas operações de "resolução de dados" e "aplicação de funcionalidades", ou, de forma resumida, "dados" e "funcionalidades" respectivamente.
A principal diferença entre argumentos de campo e diretivas é que os argumentos de campo podem ser usados tanto para "dados" quanto para "funcionalidades", mas as diretivas só podem ser usadas para "funcionalidades".
Vamos ver com um pouco mais de detalhe o que isso significa.
Resolução de dados via argumentos de campo
Os argumentos de campo são processados durante a resolução do campo, portanto podem ser usados para recuperar os dados reais, como decidir qual propriedade do objeto é acessada.
Por exemplo, este código do resolver mostra como o argumento size é usado para buscar uma ou outra fonte de imagem do 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);
}
// ...
}Os field args também podem ser usados para ajudar a decidir qual linha ou coluna da tabela do banco de dados deve ser consultada.
Nesta query, o argumento de campo id é usado para consultar uma entidade específica do tipo Post, que o resolver traduzirá em uma linha específica da tabela wp_posts do banco de dados do WordPress:
{
post(by: { id: 1 }) {
title
}
}A mesma tabela armazena a data do post em duas colunas diferentes, post_modified e post_modified_gmt (por razões de compatibilidade com versões anteriores). Nesta query, passar o argumento de campo gmt com true ou false se traduz em buscar o valor de uma ou da outra coluna:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Esses exemplos demonstram que os field args podem modificar a fonte dos dados durante a resolução do campo.
As diretivas não podem ser usadas para modificar a fonte dos dados, porque sua lógica é fornecida por meio de directive resolvers, que são invocados após o field resolver. Portanto, no momento em que a diretiva é aplicada, o valor do campo já deve ter sido recuperado.
Por exemplo, esta query nunca funcionará:
{
post @selectEntity(id: 1) {
title
}
}Neste exemplo, o campo post requer que o id da entidade seja fornecido, e como ele não é fornecido como argumento de campo, o servidor retornará um erro:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}Em conclusão, somente os argumentos de campo podem ajudar a recuperar os dados que resolvem o campo.
Aplicando funcionalidades via argumentos de campo ou diretivas
Após recuperar os dados para o campo, podemos querer manipular seu valor. Por exemplo, poderíamos:
- Formatar uma string, convertendo-a para maiúsculas ou minúsculas
- Formatar uma data representada por uma string, do formato padrão
YYYY-mm-ddparadd/mm/YYYY - Mascarar uma string, substituindo e-mails e números de telefone por
*** - Fornecer um valor padrão se for
nullou vazio - Arredondar floats para 2 dígitos
Qualquer uma dessas operações é uma manipulação dos dados já recuperados. Portanto, elas podem ser codificadas tanto no field resolver, logo após buscar os dados e antes de retorná-los, quanto no directive resolver, que receberá o valor do campo como entrada. Assim sendo, qualquer uma dessas operações pode ser implementada via argumentos de campo ou diretivas.
Por exemplo, o field resolver para Post.excerpt poderia fornecer um valor padrão via um field arg default, e então podemos personalizar o valor do arg default na query:
{
posts {
excerpt(default: "(No excerpt)")
}
}Também podemos criar uma diretiva @default, com um 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;
}
}
}
}Essas duas estratégias são igualmente adequadas? Vamos explorar essa questão com base em diferentes áreas de interesse.
Os argumentos de campo são mais bem cobertos pela especificação do GraphQL
A extensão em que as diretivas têm permissão para operar não está claramente definida na especificação do GraphQL, que diz:
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.
Essa definição permite o uso de diretivas como @include e @skip, que incluem e omitem condicionalmente um campo respectivamente, e @stream e @defer, que fornecem uma execução em tempo de execução diferente para recuperar dados do servidor.
No entanto, essa definição não é inequívoca com relação às diretivas que modificam o valor de um campo, como @strUpperCase, que transforma o valor de output "Hello world!" em "HELLO WORLD!".
Devido a essa ambiguidade, diferentes servidores, clientes e ferramentas GraphQL podem levar as diretivas em consideração em diferentes graus, criando conflitos entre eles.
Um exemplo disso é o Relay, que não leva as diretivas em consideração para o cache de valores de campos. Se primeiro consultar:
{
post(by: { id: 1 }) {
title
}
}...o Relay consultará e armazenará em cache o valor "Hello world!" para o post com ID 1. Se então executarmos esta query:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...a resposta deveria ser "HELLO WORLD!", porém o Relay retornará "Hello world!", que é o valor armazenado em seu cache para o post com ID 1, ignorando a diretiva aplicada no campo.
Se as diretivas têm ou não permissão para modificar o valor de output do campo está em uma zona cinzenta, pois não é explicitamente permitido nem proibido na especificação do GraphQL, mas existem indicadores para ambas as situações opostas.
Por um lado, a especificação do GraphQL parece conceder às diretivas total liberdade para aprimorar e personalizar o 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 outro lado, a especificação não leva as diretivas em consideração para a validação FieldsInSetCanMerge nem para o algoritmo CollectFields. A seguinte query GraphQL é válida, porém é incerto qual resposta o usuário obterá:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}Dependendo do comportamento do servidor GraphQL, a resposta para o campo name pode ser "Leo", "LEO" ou "leo"... não sabemos com antecedência, e isso é um problema.
O mesmo problema não ocorre com os argumentos de campo. Quando a seguinte query é executada:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...a especificação determina que o servidor GraphQL retorne um erro, então o valor de name será null. Seríamos então forçados a introduzir aliases para executar a query:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}As diretivas são melhores para modularidade e reutilização de código
Muitas das operações oferecidas pelas diretivas são agnósticas em relação à entidade e ao campo onde são aplicadas. Por exemplo, @strUpperCase funcionará em qualquer string, seja aplicada no título de um post, no nome de um usuário, no endereço de um local ou em qualquer outra coisa.
Como consequência, o código dessa diretiva é implementado apenas uma vez e em um único lugar, o directive resolver. Similar à programação orientada a aspectos (que aumenta a modularidade ao permitir a separação de preocupações transversais), as diretivas são aplicadas no campo sem afetar a lógica do campo.
Em contraste, implementar a mesma funcionalidade via um argumento de campo implica executar o mesmo código em todo o field resolver (e em diferentes 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 reduzir a quantidade de código nos resolvers, as diretivas são portanto mais adequadas do que os argumentos de campo.
As diretivas são melhores para o design do schema
Adicionar argumentos de campo acrescentará informações extras ao schema, possivelmente sobrecarregando-o e tornando-o inconsistente.
Por exemplo, um argumento de campo format precisará ser adicionado a todos os campos String e, se não tivermos cuidado, pode não ser homogêneo entre os campos, usando nomes diferentes, valores diferentes, valores padrão diferentes, ou até dividindo o argumento em várias 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
}As diretivas nos permitem manter o schema o mais enxuto possível:
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
}As diretivas podem ser mais eficientes do que os argumentos de campo
No momento da execução, um argumento de campo é acessado durante a resolução do campo, o que acontece campo a campo e objeto a objeto. Por exemplo, ao resolver os campos title e content em uma lista de posts, o resolver será invocado uma vez por post e por campo:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Imagine que queremos traduzir essas strings usando a API do Google Translate, para a qual adicionamos o 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 a lógica é naturalmente executada por combinação de campo e objeto, podemos acabar fazendo um grande número de conexões com a API externa, produzindo uma resposta lenta para resolver a query.
Além disso, executar as chamadas de forma independente umas das outras não permitirá associar seus dados, portanto a qualidade da tradução será inferior do que se todos os dados fossem enviados juntos em uma única chamada à API.
Por exemplo, um título de post "Power" pode ser melhor traduzido se o conteúdo do post, que torna evidente que essa palavra se refere a "energia elétrica", for enviado junto com ele.
O Gato GraphQL invoca uma diretiva apenas uma vez, passando todos os campos e objetos a serem aplicados como entrada. Ao receber todos os dados de uma vez, a diretiva @strTranslate pode executar uma única chamada ao Google Translate enviando todos os campos title e content para todos os objetos, como nesta query:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}As diretivas podem fornecer uma maneira mais eficiente de modificar o valor dos campos, especialmente ao interagir com APIs externas.