-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fragment Arguments Reference Implementation #3835
Fragment Arguments Reference Implementation #3835
Conversation
✅ Deploy Preview for compassionate-pike-271cb3 ready!
To edit notification comments on pull requests, go to your Netlify site settings. |
Hi @mjmahone, I'm @github-actions bot happy to help you with this PR 👋 Supported commandsPlease post this commands in separate comments and only one per comment:
|
This comment has been minimized.
This comment has been minimized.
@mjmahone Please, see benchmark results here: https://github.com/graphql/graphql-js/actions/runs/4118969873/jobs/7112119320#step:6:1 |
1fd2b9b
to
02db413
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is really exciting! Nuanced behavior here that's found a sweet spot of value.
I dropped in a mix of tactical and high level feedback.
The one overarching piece of feedback is to be careful with naming things to get consistency. This adds both "fragment variable definitions" and "fragment spread arguments". I don't have a strong opinion about what we call the whole RFC'd feature, but we should be consistent about calling the things you provide to fragment spreads "arguments" and the things you define at the top of a fragment "variable definitions". I noticed some swapping of this terminology in a confusing way, as well as a few others like "local" vs "fragment".
@@ -10,7 +10,7 @@ query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery { | |||
...frag @onFragmentSpread | |||
} | |||
} | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unrelated?
const printedArguments: Array<string> = fragmentArguments | ||
.map(print) | ||
.sort((a, b) => a.localeCompare(b)); | ||
return fragmentName + '(' + printedArguments.join(',') + ')'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are the tests which exercise these keys being skipped correctly?
I imagine this wants to allow the following to not skip over the second call to Frag
with different arguments
{
...Frag(arg: "A")
...Frag(arg: "B")
}
Should this just be strictly disallowed in the first place similar to how repeated fields need to have equivalent arguments?
Or if we do allow this, it seems like there might be examples where this would cause two calls to a fragment that are visually similar but do different things to produce the wrong result?
query ($var: Boolean = false) {
...AorB(which: $var)
...Shadow
}
fragment AorB($which: Boolean) {
a: echo(s: "A") @include(if: $which)
b: echo(s: "B") @skip(unless: $which)
}
fragment Shadow($var: Boolean = true) {
...AorB(which: $var)
}
By proposed spec I'd this to produce:
{
a: "A",
b: "B",
}
However I think this keying algorithm will produce AorB(which: $var)
as the key for both, and they're used within the same selection set, so same sweep of CollectFields, so I think the second would be skipped over and instead produce:
{
a: "A",
}
Is this right? If so, then we'd need to know the result of fragmentArgumentSubstitutions
as an input to determining this key.
I see you're using these keys to identify fragments during validation as well, which is before we have runtime values, so maybe there's something to be done to swap in the argument values (which may be variables themselves) to ensure these keys represent the correctly unique sources of runtime values in that case?
In that case the first instance of AorB
should key to AorB(which: $var)
since the runtime operation variable $var
is what would provide the source, and the second would be AorB(which: true)
since the fragment variable's default value got applied since no argument was provided. Now they're different and you'll see both during collectFields.
However at runtime, there is a chance that the $var
is provided the value true
then since you know that information during execution you could skip the second one - it would be okay if they keyed the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this just be strictly disallowed in the first place similar to how repeated fields need to have equivalent arguments?
Yes, IMO: we should be treating fragment spreads with the same name exactly like fields with the same name that need to be able to merge. If we don't it greatly increases the complexity of field merging validation, and likely of the executor.
If you're only changing a variable that isn't actually used within one spread's context, technically the executor ought to be able to handle it. But I think the devex is worse to allow that case than to just make it invalid.
If so, then we'd need to know the result of fragmentArgumentSubstitutions as an input to determining this key. ...AorB discussion...
This is a good test case to add for OverlappingFieldsCanBeMergedRule, and it should fail (but likely doesn't as-is, great catch!). I think we might need to add a local-variable-scope indicator to the key for validation (though at execution time we should always have actualized values). Good call that the keying function can't quite be shared as-is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh! I really like this. It's constraining, but in a way that's far more simplifying than restricting. I think if you apply this then you don't need "fragment key" as a concept at all!
I imagine the validation rule either akin to or part of OverlappingFieldsCanBeMergedRule
which ensures any two fragment spreads of the same name in the same scope would always produce exactly the same set of fragment variable values (aka: always has the same source of values). I think most of the logic you added is approaching that, but I had just misunderstood that.
In that case, you can assume that for a valid query you only need to spread the first instance of any fragment with a given name since the above rule guarantees that any fragment you encounter later with the same name would provide identical results.
Because you can assume that, you should be able to just keep the existing logic of using the fragment name instead of computing a key during collectFields
); | ||
} | ||
break; | ||
} | ||
case Kind.FRAGMENT_SPREAD: { | ||
const fragName = selection.name.value; | ||
const fragmentKey = keyForFragmentSpread(selection); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick feedback: lets move this closer to where its used so the earlier short-circuits can be checked first.
Could this be a potential performance concern since collectFields
is a highly called function? Is there any opportunity to compute these keys lazily - only doing so if there's first a simple name collision?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's likely worth testing the performance benchmarks by making this lazy, but my suspicion is it will be unlikely to make a difference in most cases and/or the complexity of keeping an additional layer of cache (today it's key => fragment, after it would be name => (first fragment, spreads-to-merge, computed-keys-if-multiple-spreads => fragment)).
Because we don't have a real scope, we do want to cache the spreads' fragment result, as we could hit ...profilePicture(size: 30)
many times and we don't really want to visit profilePicture
and replace $size
each time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+100 - I got too far into solutioning here and you are correct that we should try to measure some degenerate cases to understand it first. See the other thread about validation which might side-step all of this by removing the need for it in the first place
src/language/kinds.ts
Outdated
@@ -22,6 +22,7 @@ export enum Kind { | |||
FRAGMENT_SPREAD = 'FragmentSpread', | |||
INLINE_FRAGMENT = 'InlineFragment', | |||
FRAGMENT_DEFINITION = 'FragmentDefinition', | |||
FRAGMENT_ARGUMENT_DEFINITION = 'FragmentArgumentDefinition', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see this used elsewhere
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oooh sorry leftover from when I had a new AST node for the variable defs.
src/language/printer.ts
Outdated
let argsLine = prefix + wrap('(', join(args, ', '), ')'); | ||
|
||
if (argsLine.length > MAX_LINE_LENGTH) { | ||
argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is the same as Field:
opportunity to capture the repeat logic in a function at the bottom of the file?
return visit(def.selectionSet, { | ||
Variable(node) { | ||
return argumentValues[node.name.value]; | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought the draft spec behavior was to introduce limited scope such that during collection you create a new set of variable definitions for these fragment spreads, rather than having to do a visit/replace.
I'm a bit worried about this visit creating a performance issue, but more importantly that these edits could potentially lead to confusingly printed error messages if an error occurred at a field/arg/variable level where the visited replacement was printed rather than the original.
There may be other options, but the alternative I can imagine is that fragmentVariableValues
has to be collected alongside fields as they are collected such that during execution you have access to it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I flip-flopped. The current spec draft uses the replacement algorithm because adding actual scope infected the algorithms a lot more. The problem is you need to basically replace type VariableDefinitions = Map<string, [VariableDefinition, VariableValue]>
everywhere with type VariableScope = { localVariables: VariableDefinitions, operationVariables: VariableDefinitions }
. It's certainly worth trying to get the scoped version out before shipping the spec change, but it's not the smallest change possible, which is likely to lead to some mistakes.
Whereas if we can get all the test cases described, working and solidified with the smaller-change replacement option, we'll have a lot of confidence in switching to scoped variables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough. That makes me think we should call this "Fragment Templates" and mirror some of the C++ nomenclature. I don't think I like it, but it's closer to what we're describing :)
// To remove this hack, we need to either: | ||
// - include fragment argument scope when evaluating fields | ||
// - make unset fragment arguments invalid | ||
// Requiring the spread to pass all non-default-defined arguments is nice, | ||
// but makes field argument default values impossible to use. | ||
substitutions[argumentName] = { | ||
kind: Kind.VARIABLE, | ||
name: { kind: Kind.NAME, value: '__UNSET' }, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// To remove this hack, we need to either: // - include fragment argument scope when evaluating fields
I think probably yes, that. Is that too crazy? This hack is not ideal.
Also, query ($__UNSET: String = "SomeDefault") {...
is valid and while most query authors wouldn't write this, this seems like some kind of security vector or something
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I should just replace it with $0__UNSET
and then it's not valid :)
Is that too crazy?
I spent a week or so of full-time work trying to get the spec algorithms and graphql-js to have full scope. It was just a much more pervasive change. It's not too crazy, but the replacement strategy is certainly a smaller change for both.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would an equivalent behavior be to also visit each InputValue on leave and if its unset to remove it? In other words - rather than putting in a hacked variable AST node, leave the AST that would be the equivalent to an unset var
@@ -199,16 +202,24 @@ export class ValidationContext extends ASTValidationContext { | |||
let usages = this._variableUsages.get(node); | |||
if (!usages) { | |||
const newUsages: Array<VariableUsage> = []; | |||
const typeInfo = new TypeInfo(this._schema); | |||
const typeInfo = this._typeInfo; | |||
const localVariableDefinitions = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fragmentVariableDefinitions
@@ -219,9 +222,13 @@ describe('Validate: Known directives', () => { | |||
message: 'Directive "@onQuery" may not be used on FIELD.', | |||
locations: [{ column: 19, line: 16 }], | |||
}, | |||
{ | |||
message: 'Directive "@onField" may not be used on VARIABLE_DEFINITION.', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not blocking for this PR, but we may want to consider this directive location name being different - maybe to FRAGMENT_VARIABLE_DEFINITION
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it's weird: if they're their own AST node then absolutely. If not... maybe? I think probably?
This is a small change and another thing I flip-flopped on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DirectiveLocations and AST nodes are not a perfect 1:1
See getDirectiveLocationForASTPath()
for the mapping, there's already a few bits of logic there to consider extending in a similar way.
I think keeping the AST minimally changed is great, so it makes sense to me to have the VARIABLE_DEFINITION
AST node map to one of two directive locations based on the parent AST node being a fragment or not.
const usages = context.getRecursiveVariableUsages(operation); | ||
const variableNameUsed = new Set<string>( | ||
usages.map(({ node }) => node.name.value), | ||
usages | ||
// Skip variables used as fragment arguments | ||
.filter(({ fragmentVarDef }) => !fragmentVarDef) | ||
.map(({ node }) => node.name.value), | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed these get skipped over in NoUndefinedFragmentVariableRule, maybe the better place to edit is changing getRecursiveVariableUsages()
to something like getOperationVariableUsages()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're OK making a breaking change. But Meta has custom validation rules that use getRecursiveVariableUsages
so I imagine other people likely do too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or leave the existing method behind (optionally deprecate it if its not used in core anymore?) and introduce this new one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given our discussion at the APAC WG: if we're going to have a flag, I think it makes sense to split this PR into a series of dependent PRs. We can probably also pair graphql-spec PRs with each:
- AST + parse + print changes (I don't think there's much controversy on syntax, maybe just directive location being the one thing)
- Validation rule changes (a little more controversial, specifically with how shadowing must work)
- Executor changes
This sequencing would help us unblock the tooling changes we need while still making forward progress on the gnarlier reviews and choices.
); | ||
} | ||
break; | ||
} | ||
case Kind.FRAGMENT_SPREAD: { | ||
const fragName = selection.name.value; | ||
const fragmentKey = keyForFragmentSpread(selection); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's likely worth testing the performance benchmarks by making this lazy, but my suspicion is it will be unlikely to make a difference in most cases and/or the complexity of keeping an additional layer of cache (today it's key => fragment, after it would be name => (first fragment, spreads-to-merge, computed-keys-if-multiple-spreads => fragment)).
Because we don't have a real scope, we do want to cache the spreads' fragment result, as we could hit ...profilePicture(size: 30)
many times and we don't really want to visit profilePicture
and replace $size
each time.
src/language/kinds.ts
Outdated
@@ -22,6 +22,7 @@ export enum Kind { | |||
FRAGMENT_SPREAD = 'FragmentSpread', | |||
INLINE_FRAGMENT = 'InlineFragment', | |||
FRAGMENT_DEFINITION = 'FragmentDefinition', | |||
FRAGMENT_ARGUMENT_DEFINITION = 'FragmentArgumentDefinition', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oooh sorry leftover from when I had a new AST node for the variable defs.
// Minor hack: transform the FragmentArgDef | ||
// into a schema Argument definition, to | ||
// enable visiting identically to field/directive args |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not 100% sure this really is a hack: it's semantically correct, in that we are inside a GraphQLArgument, it's just that argument is not defined by the schema but by some other definition. It's not really different from defining a type extension in the same document you use it.
What would be nicer would be if we had a concept of fragment signatures: that's kind of what I'm doing in my first-pass loop over all definitions. The signatures would probably be composed of GraphQLSchema pieces, in which case we'd have a GraphQLArgument without the "upcasting" from AST here. This is what Relay does in their IR.
const printedArguments: Array<string> = fragmentArguments | ||
.map(print) | ||
.sort((a, b) => a.localeCompare(b)); | ||
return fragmentName + '(' + printedArguments.join(',') + ')'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this just be strictly disallowed in the first place similar to how repeated fields need to have equivalent arguments?
Yes, IMO: we should be treating fragment spreads with the same name exactly like fields with the same name that need to be able to merge. If we don't it greatly increases the complexity of field merging validation, and likely of the executor.
If you're only changing a variable that isn't actually used within one spread's context, technically the executor ought to be able to handle it. But I think the devex is worse to allow that case than to just make it invalid.
If so, then we'd need to know the result of fragmentArgumentSubstitutions as an input to determining this key. ...AorB discussion...
This is a good test case to add for OverlappingFieldsCanBeMergedRule, and it should fail (but likely doesn't as-is, great catch!). I think we might need to add a local-variable-scope indicator to the key for validation (though at execution time we should always have actualized values). Good call that the keying function can't quite be shared as-is.
def: FragmentDefinitionNode, | ||
fragmentSpread: FragmentSpreadNode, | ||
): SelectionSetNode { | ||
const argumentDefinitions = def.variableDefinitions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree it's not super clean. I likely need to do another pass but there is some logic here: argumentDefinitions
are the definitions for the arguments we need to look up based on the spread, not the variable definitions that the spread currently has access to.
return visit(def.selectionSet, { | ||
Variable(node) { | ||
return argumentValues[node.name.value]; | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I flip-flopped. The current spec draft uses the replacement algorithm because adding actual scope infected the algorithms a lot more. The problem is you need to basically replace type VariableDefinitions = Map<string, [VariableDefinition, VariableValue]>
everywhere with type VariableScope = { localVariables: VariableDefinitions, operationVariables: VariableDefinitions }
. It's certainly worth trying to get the scoped version out before shipping the spec change, but it's not the smallest change possible, which is likely to lead to some mistakes.
Whereas if we can get all the test cases described, working and solidified with the smaller-change replacement option, we'll have a lot of confidence in switching to scoped variables.
// To remove this hack, we need to either: | ||
// - include fragment argument scope when evaluating fields | ||
// - make unset fragment arguments invalid | ||
// Requiring the spread to pass all non-default-defined arguments is nice, | ||
// but makes field argument default values impossible to use. | ||
substitutions[argumentName] = { | ||
kind: Kind.VARIABLE, | ||
name: { kind: Kind.NAME, value: '__UNSET' }, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I should just replace it with $0__UNSET
and then it's not valid :)
Is that too crazy?
I spent a week or so of full-time work trying to get the spec algorithms and graphql-js to have full scope. It was just a much more pervasive change. It's not too crazy, but the replacement strategy is certainly a smaller change for both.
@@ -219,9 +222,13 @@ describe('Validate: Known directives', () => { | |||
message: 'Directive "@onQuery" may not be used on FIELD.', | |||
locations: [{ column: 19, line: 16 }], | |||
}, | |||
{ | |||
message: 'Directive "@onField" may not be used on VARIABLE_DEFINITION.', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it's weird: if they're their own AST node then absolutely. If not... maybe? I think probably?
This is a small change and another thing I flip-flopped on.
const usages = context.getRecursiveVariableUsages(operation); | ||
const variableNameUsed = new Set<string>( | ||
usages.map(({ node }) => node.name.value), | ||
usages | ||
// Skip variables used as fragment arguments | ||
.filter(({ fragmentVarDef }) => !fragmentVarDef) | ||
.map(({ node }) => node.name.value), | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're OK making a breaking change. But Meta has custom validation rules that use getRecursiveVariableUsages
so I imagine other people likely do too?
@mjmahone I support this. I think we need exactly two:
|
650a99f
to
59c89ef
Compare
c845d38
to
2033b21
Compare
59c89ef
to
05c5ee3
Compare
05c5ee3
to
7fe0733
Compare
This implements Fragment Arguments, as described by the spec changes in graphql/graphql-spec#1010.
We have:
I am creating a clean commit rather than updating #3152, as the discussion from the previous PR was confusing newcomers with old historical details that we've decided to abandon.
Please use this PR for reviewing and verifying the accuracy of the PR, with inline comments when possible.
For discussions about Fragment Arguments as an idea, missing features or open questions, please open or respond to a thread in the graphql-wg discussion space: graphql/graphql-wg#1239