|
| 1 | +## RFC: Fragment Arguments |
| 2 | + |
| 3 | +# Problem: Variable Modularity |
| 4 | + |
| 5 | +GraphQL fragments are designed to allow a client's data requirements to compose. |
| 6 | +Two different screens can use the same underlying UI component. If that |
| 7 | +component has a corresponding fragment, then each of those screens can include |
| 8 | +exactly the data required by having each query spread the child component's |
| 9 | +fragment. |
| 10 | + |
| 11 | +This modularity begins to break down for variables. As an example, let's imagine |
| 12 | +a FriendsList component that shows a variable number of friends. We would have a |
| 13 | +fragment for that component like so: |
| 14 | + |
| 15 | +``` |
| 16 | +fragment FriendsList on User { |
| 17 | + friends(first: $nFriends) { |
| 18 | + name |
| 19 | + } |
| 20 | +} |
| 21 | +``` |
| 22 | + |
| 23 | +In one use, we might want to show some screen-supplied number of friends, and in |
| 24 | +another the top 10. For example: |
| 25 | + |
| 26 | +``` |
| 27 | +fragment AnySizedFriendsList on User { |
| 28 | + name |
| 29 | + ...FriendsList |
| 30 | +} |
| 31 | +
|
| 32 | +fragment TopFriendsUserProfile on User { |
| 33 | + name |
| 34 | + profile_picture { uri } |
| 35 | + ...FriendsList |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +Even though every usage of `TopFriendsUserProfile` should be setting `$nFriends` |
| 40 | +to `10`, the only way to enforce that is by manually walking all callers of |
| 41 | +`TopFriendsUserProfile`, recursively, until you arrive at the operation |
| 42 | +definition and verify the variable is defined like `$nFriends: Int = 10`. |
| 43 | + |
| 44 | +This causes a few major usability problems: |
| 45 | + |
| 46 | +- If I ever want to change the number of items `TopFriendsUserProfile` includes, |
| 47 | + I now need to update every _operation_ that includes it. This could be dozens |
| 48 | + or hundreds of individual locations. |
| 49 | +- Even if the component for `TopFriendsUserProfile` is only able to display 10 |
| 50 | + friends, in most clients at runtime the user can override the default value, |
| 51 | + enabling a mismatch between the data required and the data asked for. |
| 52 | + |
| 53 | +# Existing Solution: Relay's `@arguments`/`@argumentDefinitions` |
| 54 | + |
| 55 | +Relay has a solution for this problem by using a custom, non-spec-compliant pair |
| 56 | +of directives, |
| 57 | +[`@arguments`/`@argumentDefinitions`](https://relay.dev/docs/api-reference/graphql-and-directives/#arguments). |
| 58 | + |
| 59 | +These directives live only in user-facing GraphQL definitions, and are compiled |
| 60 | +away prior to making a server request. |
| 61 | + |
| 62 | +Following the above example, if we were using Relay we'd be able to write: |
| 63 | + |
| 64 | +``` |
| 65 | +fragment FriendsList on User @argumentDefinitions(nFriends: {type: "Int!"}) { |
| 66 | + friends(first: $nFriends) { |
| 67 | + name |
| 68 | + } |
| 69 | +} |
| 70 | +
|
| 71 | +fragment AnySizedFriendsList on User { |
| 72 | + name |
| 73 | + ...FriendsList @arguments(nFriends: $operationProvidedFriendCount) |
| 74 | +} |
| 75 | +
|
| 76 | +fragment TopFriendsUserProfile on User { |
| 77 | + name |
| 78 | + profile_picture { uri } |
| 79 | + ...FriendsList @arguments(nFriends: 10) |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +Before sending a query to the server, Relay compiles away these directives so |
| 84 | +the server, when running an operation using `TopFriendsUserProfile`, sees: |
| 85 | + |
| 86 | +``` |
| 87 | +fragment FriendsList on User { |
| 88 | + friends(first: 10) { |
| 89 | + name |
| 90 | + } |
| 91 | +} |
| 92 | +
|
| 93 | +fragment TopFriendsUserProfile on User { |
| 94 | + name |
| 95 | + profile_picture { uri } |
| 96 | + ...FriendsList |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +The exact mechanics of how Relay rewrites the user-written operation based on |
| 101 | +`@arguments` supplied is not the focus of this RFC. |
| 102 | + |
| 103 | +However, even to enable this client-compile-time operation transformation, Relay |
| 104 | +had to introduce _non-compliant directives_: each argument to `@arguments` |
| 105 | +changes based on the fragment the directive is applied to. While syntactically |
| 106 | +valid, this fails the |
| 107 | +[Argument Names validation](https://spec.graphql.org/draft/#sec-Argument-Names). |
| 108 | + |
| 109 | +Additionally, the `@argumentDefinitions` directive gets very verbose and unsafe, |
| 110 | +using strings to represent variable type declarations. |
| 111 | + |
| 112 | +Relay has supported `@arguments` in its current form since |
| 113 | +[v2.0](https://github.com/facebook/relay/releases/tag/v2.0.0), released in |
| 114 | +January 2019. There's now a large body of evidence that allowing fragments to |
| 115 | +define arguments that can be passed into fragment spreads is a significant |
| 116 | +usability improvement, and valuable to the wider GraphQL community. However, if |
| 117 | +we are to introduce this notion more broadly, we should make sure the ergonomics |
| 118 | +of it conform to users' expectations. |
| 119 | + |
| 120 | +# Proposal: Introduce Fragment Argument Definitions, which allow using arguments on Fragment Spreads |
| 121 | + |
| 122 | +Relay's `@arguments`/`@argumentDefinitions` concepts provide value, and can be |
| 123 | +applied against GraphQL written for existing GraphQL servers so long as there is |
| 124 | +a pre-server compiler which transforms the concept away. |
| 125 | + |
| 126 | +## New Fragment Argument Definition syntax |
| 127 | + |
| 128 | +For the `@argumentDefinitions` concept, we can allow fragments to share the same |
| 129 | +syntax as operation level definitions. Going back to the previous example, this |
| 130 | +would look like: |
| 131 | + |
| 132 | +``` |
| 133 | +fragment FriendsList($nFriends: Int!) on User { |
| 134 | + friends(first: $nFriends) { |
| 135 | + name |
| 136 | + } |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +The syntax re-uses the concepts from Variable Definitions, so that when you |
| 141 | +_define_ and _use_ the argument, it preserves the same appearance (`$` + name). |
| 142 | + |
| 143 | +## New Fragment Spread Argument syntax |
| 144 | + |
| 145 | +For the `@arguments` concept, we can allow fragment spreads to share the same |
| 146 | +syntax as field and directive arguments. |
| 147 | + |
| 148 | +``` |
| 149 | +fragment AnySizedFriendsList on User { |
| 150 | + name |
| 151 | + ...FriendsList(nFriends: $operationProvidedFriendCount) |
| 152 | +} |
| 153 | +
|
| 154 | +fragment TopFriendsUserProfile on User { |
| 155 | + name |
| 156 | + profile_picture { uri } |
| 157 | + ...FriendsList(nFriends: 10) |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +This may feel a little weird: for fields, arguments are defined as |
| 162 | +`argName: Type` and then used like `...Foo(argName: $variable)`. The |
| 163 | +alternatives here are: |
| 164 | + |
| 165 | +- Define `argName: Type` for fragment arguments |
| 166 | + - This has the disadvantage of seeing both the argument definition and the |
| 167 | + argument usage in the same fragment with different styles. |
| 168 | +- Call `...Foo($argName: $variable)` |
| 169 | + - This feels incredibly confusing: `$` typically means "replace this token |
| 170 | + with a value", and that's not what's happening. |
| 171 | + |
| 172 | +Notably, this proposed syntax, of using `$name` at the definition and usage |
| 173 | +site, and `name:` when calling the Fragment/Function, is the convention that PHP |
| 174 | +uses for |
| 175 | +[Named Arguments](https://www.php.net/manual/en/functions.arguments.php#functions.named-arguments). |
| 176 | +Given GraphQL was designed with many PHP-isms, it seems like we should re-use |
| 177 | +the conventions chosen there when there's no clear reason not to. |
| 178 | + |
| 179 | +## Scope: Local |
| 180 | + |
| 181 | +Fragment Arguments should always have local scope. This gets us closer to the |
| 182 | +idea that while operations are "global", fragments behave more like well-scoped |
| 183 | +functions. |
| 184 | + |
| 185 | +This has a bunch of beneficial side effects: |
| 186 | + |
| 187 | +- For validation, we don't need ot check for fragment argument definition |
| 188 | + clashes |
| 189 | +- For composability, I can use the same argument on many fragments and not worry |
| 190 | + about unrelated fragments. |
| 191 | +- For ease-of-understanding, you don't need to keep track of how all child |
| 192 | + fragments use a fragment argument to understand how changing something like |
| 193 | + the default value will modify the results. |
| 194 | +- Makes it easy to update Variables In Allowed Positions, as we don't need to |
| 195 | + hunt the definition of a variable across many potential parent fragments. |
| 196 | + |
| 197 | +The other scoping options are: |
| 198 | + |
| 199 | +- Global, i.e. a fragment argument is just syntactic sugar for an operation |
| 200 | + variable. |
| 201 | + - This is what we implemented at Meta, and it was terrible for all the reasons |
| 202 | + you can think of. |
| 203 | +- Recursively local, i.e. the variable takes on any parent fragment argument |
| 204 | + definition |
| 205 | + - This preserves the concept of the value being some sort of recursively |
| 206 | + scoped variable. |
| 207 | + - However, as explained above, keeping track of what's happening, and |
| 208 | + preventing fragment conflicts, becomes really difficult. |
| 209 | + |
| 210 | +We're choosing to explicitly _allow_ overriding operation variables, as the |
| 211 | +local scope means you can clearly see whether a variable is scoped to the |
| 212 | +fragment or operation. |
| 213 | + |
| 214 | +## New Validation Rule: Fragment Argument Definitions Used in Fragment |
| 215 | + |
| 216 | +With local scope, this rule is very simple. |
| 217 | + |
| 218 | +``` |
| 219 | +fragment Foo($x: Int) on User { |
| 220 | + name |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +would be invalid. |
| 225 | + |
| 226 | +Additionally, |
| 227 | + |
| 228 | +``` |
| 229 | +fragment Foo($x: Int!) on User { |
| 230 | + ...Bar |
| 231 | +} |
| 232 | +
|
| 233 | +fragment Bar { |
| 234 | + number(x: $x) |
| 235 | +} |
| 236 | +``` |
| 237 | + |
| 238 | +would also be invalid: even though `$x` is used underneath Foo, it is used |
| 239 | +outside of Foo's explicit definition. In this context, `$x` in Bar is actually |
| 240 | +an operation variable. |
| 241 | + |
| 242 | +However, this would be valid: |
| 243 | + |
| 244 | +``` |
| 245 | +fragment Foo($x: Int!) on User { |
| 246 | + ...Bar(x: $x) |
| 247 | +} |
| 248 | +
|
| 249 | +fragment Bar($x: Int) { |
| 250 | + number(x: $x) |
| 251 | +} |
| 252 | +``` |
| 253 | + |
| 254 | +### Consideration: how strict should this rule be? |
| 255 | + |
| 256 | +As an initial RFC, I'd advocate for encouraging the _strictest_ version of this |
| 257 | +rule possible: any argument defined on a fragment must be explicitly used by |
| 258 | +that same fragment. It would be easy to relax the rule later, but very difficult |
| 259 | +to do the reverse. |
| 260 | + |
| 261 | +It's clearly more composable if, when changing a child fragment, you don't need |
| 262 | +to worry about modifying argument definitions on parent fragments callsites. |
| 263 | +However, we could in the future allow annotating argument definitions with |
| 264 | +`@deprecated`. |
| 265 | + |
| 266 | +## Updated Validation Rule: Required Arguments are Provided |
| 267 | + |
| 268 | +We update |
| 269 | +[Required Arguments](https://spec.graphql.org/draft/#sec-Required-Arguments) to |
| 270 | +include fragment spreads. This makes the validation's first two bullets: |
| 271 | + |
| 272 | +- For each Field, **Fragment Spread** or Directive in the document. |
| 273 | +- Let _arguments_ be the set of argument definitions of that Field, **Fragment** |
| 274 | + or Directive. |
| 275 | + |
| 276 | +With this rule, the below example is invalid, even if the argument |
| 277 | +`User.number(x:)` is nullable in the schema. |
| 278 | + |
| 279 | +``` |
| 280 | +fragment Foo on User { |
| 281 | + ...Bar |
| 282 | +} |
| 283 | +
|
| 284 | +fragment Bar($x: Int!) on User { |
| 285 | + number(x: $x) |
| 286 | +} |
| 287 | +``` |
| 288 | + |
| 289 | +### Potential Alternative: Default Value indicates _required or not_, and `!` indicates _non-null or nullable_. |
| 290 | + |
| 291 | +If we were writing the language from scratch, I'd advocate for making _all_ |
| 292 | +argument definitions without a default value to be required, regardless of their |
| 293 | +nullability. If you want to make a nullable argument optional, you do so by |
| 294 | +adding a `= null` to its definition. |
| 295 | + |
| 296 | +In short, if I define: |
| 297 | + |
| 298 | +``` |
| 299 | +fragment Bar($x: Int) { number(x: $x) } |
| 300 | +``` |
| 301 | + |
| 302 | +then `...Bar` would be **invalid**. |
| 303 | + |
| 304 | +However, that's not how operation variables, field arguments, directive |
| 305 | +arguments or input object fields work today, no matter how much I might wish it. |
| 306 | +For this RFC, I'm making the meaning of "required" and "nullable" for fragment |
| 307 | +spread arguments the same as all other inputs, because doing something |
| 308 | +_different_ would be more confusing IMO, even if that difference would lead to |
| 309 | +unvalidated fewer foot guns. |
| 310 | + |
| 311 | +## Updated Validation: Overlapping Fields Can Be Merged |
| 312 | + |
| 313 | +Previously, fragment spreads didn't have to be considered as unique selections |
| 314 | +in the overlapping field merging algorithm. However, in practice the algorithm, |
| 315 | +but not the spec, still de-duplicated common fragment spreads. |
| 316 | + |
| 317 | +With this change, we can just treat deduplicated fragment spreads as being keyed |
| 318 | +by (name, arguments) rather than just by name. When visiting child selections, |
| 319 | +we need to apply any fragment argument values (basically replace them with |
| 320 | +either variable or const values), and then any time we encounter duplicated |
| 321 | +fragment spreads with different arguments within merging selections, we consider |
| 322 | +that invalid. |
| 323 | + |
| 324 | +We _could_ just allow field merging rules to apply, but stopping the validation |
| 325 | +when same-named fragment spreads with different args are discovered helps |
| 326 | +provide much better error messaging and root-causes the issue: the issue isn't |
| 327 | +that you reached the same field in the same fragment twice, but rather than you |
| 328 | +reached the same fragment spread with different arguments, which will induce |
| 329 | +those two usages to be merging the same field with different arguments. |
| 330 | + |
| 331 | +## WILL NOT IMPLEMENT Validation Rule: Document Argument Uniqueness |
| 332 | + |
| 333 | +If the client pre-server compiler rewrites an operation, it's possible to end up |
| 334 | +with a selection set that violates |
| 335 | +[Field Selection Merging](https://spec.graphql.org/draft/#sec-Field-Selection-Merging) |
| 336 | +validation. Additionally, we have no mechanism on servers today to handle the |
| 337 | +same fragment having different variable values depending on that fragment's |
| 338 | +location in an operation. |
| 339 | + |
| 340 | +Therefore, any Fragment Spread for the same Fragment in an Operation could be |
| 341 | +required to have non-conflicting argument values passed in. |
| 342 | + |
| 343 | +As an example, this is invalid: |
| 344 | + |
| 345 | +``` |
| 346 | +query { |
| 347 | + user { |
| 348 | + best_friend { |
| 349 | + ...UserProfile(imageSize: 100) |
| 350 | + } |
| 351 | + ...UserProfile(imageSize: 200) |
| 352 | + } |
| 353 | +} |
| 354 | +``` |
| 355 | + |
| 356 | +Note: today Relay's compiler handles this ambiguity. In an extreme |
| 357 | +simplification, this is done by producing two unique versions of `UserProfile`, |
| 358 | +where in `UserProfile_0` `$imageSize` is replaced with `100`, and in |
| 359 | +`UserProfile_1` `$imageSize` is replaced with `200`. However, there exist client |
| 360 | +implementations that are unable to have multiple applications of the same |
| 361 | +fragment within a single operation (the clients I work on cannot use Relay's |
| 362 | +trick). |
| 363 | + |
| 364 | +This validation rule is more strict than necessary: the graphql-js |
| 365 | +implementation did not require it, given the Overlapping Fields Can Be Merged |
| 366 | +changes that protect against mis-merged fields. |
| 367 | + |
| 368 | +This validation rule may end up being more strict than required, but it would be easier to relax the rule than make it more strict later. |
| 369 | + |
| 370 | +# Implementation |
| 371 | + |
| 372 | +This proposal is implemented completely in |
| 373 | +[graphql-js](https://github.com/graphql/graphql-js/pull/3152) |
| 374 | + |
| 375 | +## Initial Implementation |
| 376 | + |
| 377 | +In the initial implementation, I tried to change as little as possible. This |
| 378 | +means I only added a single new validation rule. Additionally, there may be some |
| 379 | +weirdness around internal grammar and AST node naming/usage, but the actual |
| 380 | +behavior should be feature complete. |
0 commit comments