Description
We can use directives to modify the value of a field. For instance, let's say that the date
field from type Post
returns the value in "YYYY-mm-dd"
format:
query {
posts {
date
}
}
... and we want to format it as "d/m/Y"
. This could be done through a field arg, if the field supports it:
query {
posts {
date:date(format: "d/m/Y")
}
}
If it does not, then we can create a custom directive:
query {
posts {
date@format(format: "d/m/Y")
}
}
So far, this is good enough. But then we have new requirements:
- If the year of the post is the current year, use
"d/m"
format instead - But, if the language of the post is Chinese, use
"Y-m-d"
Here we run into a few problems:
- The directive will start accumulating custom code, that possibly can't be reused for other projects/clients/etc, leading to plenty of bloat down the road
- The directive will need to reference other fields (such as field
lang
to get the post's language) which are not needed on the client-side; we may need to add it to the query just to feed it into the directive, which is a hack and goes against the GraphQL philosophy of querying only what is needed
Hence, directives are not good enough to satisfy all possible use cases.
Alternative: code in client-side
We can also modify the field values on the application on the client-side. However, this approach produces a few problems:
- Logic will be duplicated across the different clients (website, iOS app, Android app, etc), forcing the developer to keep them in sync, adding bureaucracy and increasing the chance of introducing bugs
- Conditional logic needs to retrieve all values for all conditions to be processed on the client-side, including the
false
ones - It makes the user's device (eg: an old smartphone) run additional runtime code, making the application slower
Proposed solution: composable fields
It would be better to modify the field's value already at the GraphQL server, and have the final result already in the response to the query, without having to resort to custom coding. For this, I suggest using fields that can compose other fields, i.e. the result of a field can be an input value to another field.
Using the same example as before, we could have a new field format
, like this:
query {
posts {
date:format(date: date, format: "d/m/Y")
}
}
As it can be seen, field format
must receive 2 inputs: the date and the format (they are both strings). The date string is itself the response from the date
field. Hence, field format
is composing field date
(to make it more evident, we can add ()
to the composed field: format(date: date(), format: "d/m/Y")
).
To be precise, format
should not be called a field, but an operator, since its only purpose is to modify some other value, and it must necessarily be given the input values. This can be discussed. For the time being I call it a field, because:
- The implementation between a field and an operator is exactly the same: through a resolver
- It is a generalization. For instance, a field
time
returning the current time is not an operator, and not a property from the object either. In order to avoid establishing names for all different use cases (fields, operators, helpers, etc) we just call them all as fields.
Now, having the composable fields if
, equals
, year
, currentYear
and format
, we can satisfy the previous requirement. For the sake of clarity, I have split the field into several lines; this may also need be part of the RFC, since having a very extensive logic in a single line is not easy to read (also discussable):
query {
posts {
date: format(date: date, format: if(
condition: equals(
value1: lang,
value2: "ZH"
),
then: "Y-m-d",
else: if(
condition: equals(
value1: year(
date: date
),
value2: currentYear
),
then: "d/M",
else: "d/M/Y"
)
))
}
}
Please notice the following:
- Fields
if
,equals
,year
andcurrentYear
are global fields, which can be used under any type - Fields
lang
anddate
belong to thePost
type
This will certainly impact the architecture of the server, in the following manner:
From 1., we can either add these fields to every single type, which is not a nice solution, or declare them once as "global fields", and have the types accept two sets of fields: the ones for that type plus the global ones.
From 2., we need to be able to evaluate a field against the object, and bubble this result upwards to its composing field, which will itself be evaluated and bubble this result upwards to its composing field, and so on with unbound levels deep.
Added benefit: dynamic @skip/include
directives
Please notice the additional benefit to the last item: @skip/include
can be evaluated against the object.
Currently, directives @skip
and @include
are pretty static, simply including the result or not if a variable is either true
or false
. However, with this proposal, the value to execute against can be a property of the same object, making it much more dynamic.
For instance, we could execute the following query (assuming that date
doesn't produce an error from being defined twice):
query {
posts {
date:format(date:date,format:"Y-m-d") @include(if:equals(
value1:lang,
value2:"ZH"
)
date:format(date:date,format:"d/m/Y") @skip(if:equals(
value1:lang,
value2:"ZH"
)
}
}
Demonstration of this solution
I have already implemented this solution for this GraphQL server in PHP. Since the GraphQL syntax doesn't support this feature, I have so far used this alternative syntax.
Example of global fields: check the ones listed under property globalFields
here.
Example of a query with composable fields (view response):
/?
format=Y-m-d&
query=
posts.
if (
hasComments(),
sprintf(
"This post has %s comment(s) and title '%s'", [
commentsCount(),
title()
]
),
sprintf(
"This post was created on %s and has no comments", [
date(format: if(not(empty($format)), $format, d/m/Y))
]
)
)@postDesc
Example of a query with composable fields used with @skip/include
directives(view response):
/?
format=Y-m-d&
query=
posts.
sprintf(
"This post has %s comment(s) and title '%s'", [
commentsCount(),
title()
]
)@postDesc<include(if:hasComments())>|
sprintf(
"This post was created on %s and has no comments", [
date(format: if(not(empty($format)), $format, d/m/Y))
]
)@postDesc<include(if:not(hasComments()))>