Skip to content
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 variables: enforce fragment variable validation #1422

Closed
wants to merge 7 commits into from

Conversation

mjmahone
Copy link
Contributor

Treats experimental fragments with variable definitions as their own top-level
"executable definition": this means a query cannot include a variable
definition found solely beneath a fragment-variable definition, and
a fragment must include all variable definitions that exist under it,
excluding other variables defined by other fragments.

Has no repercussions outside of fragment variable definitions, besides allowing recursive fragment lookups. One note: I am not sure how the NoUnusedFragments validation is supposed to work: internally we don't use it, as we have many use cases for unused fragments. I suspect fragments with variable definitions are a different definition type, and should always be treated as their own self-contained definition-trees (so we should be doing the same thing as below inside NoUnusedFragments). But I'm not positive about that behavior, whereas I am positive about the behavior of unused/undefined variables.

Treats fragments with variable definitions as their own top-level
"executable definition": this means a query cannot include a variable
definition found solely beneath a fragment-variable definition, and
a fragment must include all variable definitions that exist under it,
excluding other variables defined by other fragments.
* NOTE: if experimentalFragmentVariables are used, then fragments with
* variables defined are considered independent "executable definitions".
* If a fragment defines at least 1 variable, it must define all recursively
* vused ariables, excluding other fragments with variables defined.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vused typo

// All OperationDefinitions, or FragmentDefinitions with variable definitions
isExecutableDefinitionWithVariables(
definition: ExecutableDefinitionNode,
): boolean {
Copy link
Member

@IvanGoncharov IvanGoncharov Jul 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't use this so it should be a standalone function, not a class method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe it worth to add %checks so Flow will have better type coverage.

},
leave(definition: ExecutableDefinitionNode) {
if (!context.isExecutableDefinitionWithVariables(definition)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just check that variableDefs is not empty since if it's empty it couldn't be any unused variables.

},
const executableDefinitionVisitor = {
enter(definition: ExecutableDefinitionNode) {
if (!context.isExecutableDefinitionWithVariables(definition)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed see leave.

enter(definition: ExecutableDefinitionNode) {
if (!context.isExecutableDefinitionWithVariables(definition)) {
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed since there is nothing bad in resetting value of variableNameDefined even on fragments without arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, because this function only cares about finding undefined variables, and if none are defined, it doesn't matter (for fragments). I think there might be a world in which FragmentWithVariableDefinitionNode is a different AST node than FragmentDefinitionNode, but even in that world this wouldn't be needed :)

variableNameDefined = Object.create(null);
},
leave(definition: ExecutableDefinitionNode) {
if (!context.isExecutableDefinitionWithVariables(definition)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand, variableNameDefined was added to not depend on variableDefinitions property at the same time isExecutableDefinitionWithVariables introduces such dependency.
Would be better to replace with:

if (definition.kind ===  Kind.FRAGMENT && Object.keys(variableNameDefined).length === 0)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, that would make sense. Will make the changes

if (fragment) {
if (
fragment &&
!this.isExecutableDefinitionWithVariables(fragment)
Copy link
Member

@IvanGoncharov IvanGoncharov Jul 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already know that it's fragment so it better to create a function that
check only fragments (isExperimentalFragment) or just add check inplace:

!fragment.variableDefinitions || fragment.variableDefinitions.length === 0

const errors = validate(schema, parse(queryString), rules);
const errors = validate(
schema,
parse(queryString, { experimentalFragmentVariables: true }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are maintainers of GraphQL 3rd-party implementations that use graphql-js test suite as the refence so I don't think we should enable the experimental feature by default for all tests.
It's better to just add options as 4th argument, it would make it more explicit and also allow to easily grep for all tests cases with the particular experimental feature.

* NOTE: if experimentalFragmentVariables are being used, it excludes all
* fragments with their own variable definitions: these are considered their
* own "root" executable definition.
*/
getRecursivelyReferencedFragments(
Copy link
Member

@IvanGoncharov IvanGoncharov Jul 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very concern about changes in this function since it's called getRecursivelyReferencedFragments but not return all recursive fragments and is part of public API.
+ it breaks NoUnusedFragments

An alternative solution would be to extract visitReferencedFragmentsRecursively that accept visitor function and if you return false it doesn't look inside the current fragment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It only breaks NoUnusedFragments if you're using fragments with variable definitions. I think this is OK, as fragments with variable definitions are their own independent "unit": they have semantic meaning that is very different from normal fragments without variable definitions. An unused fragment that has variable definitions I see as being "stand-alone" in the same way a query is "stand-alone": you could reasonably figure out a way to persist these to the server and execute them, provided you started on the correct type.

fragment A($id: ID!) on Query {
  node(id: $id) {
    name
  }
}

is, and in my mind should be, identical to

query A($id: ID!) {
  node(id: $id) {
    name
  }
}

I'm very OK with breaking people who are actually using fragments with variable definitions.

I can see the appeal to extracting it out, but I'd much prefer taking an opinionated stance about what a fragment with variable definitions is. It will also make it easier to make an RFC in the spec for them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, in that case, it's outside of scope for graphql-js and would make sense to discuss it in PR/RFC.

Just a quick note:

and execute them, provided you started on the correct type

There is no one to one matching between root types and operation types so you can write:

schema {
  query: Type
  mutation: Type
  subscription: Type
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah after thinking this through I think it does make more sense to add a second function specifically for excluding fragments with variable definitions. I'll make that change.

@IvanGoncharov
Copy link
Member

I suspect fragments with variable definitions are a different definition type, and should always be treated as their own self-contained definition-trees (so we should be doing the same thing as below inside NoUnusedFragments). But I'm not positive about that behavior, whereas I am positive about the behavior of u

@mjmahone As I understand from #1141 experimental fragments are not executed unless they are referenced inside at least one query so I don't see any difference with normal fragments in that sense.

I also concern with lack of PR/RFC in specification repo because maintainer of 3rd-party libraries use this repo as refence and they also implemented this feature:
webonyx/graphql-php@949b853
sangria-graphql/sangria@8992744
and probably others.

So I'm worry that we are repeating IDL/SDL mistake then solution that everyone adopted significantly differ from what was added into the spec and entire comunity now goes through painful migration process. This feature could have even bigger effect if people would start sending queries with such fragments to the server. I think it's very important to comminicate the purpose of this feature and it's current status to community members outside of Facebook by openning minimal PR on a spec repo.

@mjmahone
Copy link
Contributor Author

Fragment variables, and to some extent the SDL/IDL, fall into an interesting problem where we can't know what the right solution is, or even what everything means, until we use it. GraphQL itself was developed before it was formalized. The issue here is that at Facebook we have internal dependencies that would make it impossible for us to experiment with fragment variables (so that we can then drive the future spec RFC explaining what they mean) unless we have support for them within graphql-js. Things like Relay have a lot of adoption within Facebook, and we're not going to fork the open source Relay internally just so we can have one version that supports the spec and one that has experimental fragment variable support.

Fragment variables do not belong in the spec. If there was a clean way to depend on a forked-but-constantly-moving-with-open-source version of graphql-js in all of our GraphQL repos (including things like Relay), we would have done it.

In terms of fragments with variables being executable: I only think that because I'm seeing how they're actually used, and I think we need a way to describe a collection of executable definitions that is stand-alone, but not a "root" definition. This PR is not for that prupose, it is not changing the nature of fragments with variables, but I think it allows us to move in that direction by making sure people don't implement them without enforcing they define exactly those variables the fragments require. Which I'm hoping to put up as a separate GraphQL-spec RFC, but I don't want to get that wrong. We'll probably have to iterate on the various validation rules internally and through graphql-js before opening that up.

usages = this.getVariableUsages(operation);
const fragments = this.getRecursivelyReferencedFragments(operation);
usages = this.getVariableUsages(definition);
const fragments = this.getRecursivelyReferencedFragments(definition);
for (let i = 0; i < fragments.length; i++) {
Array.prototype.push.apply(
usages,
this.getVariableUsages(fragments[i]),
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah after thinking this through I think it does make more sense to add a second function specifically for excluding fragments with variable definitions. I'll make that change.

@mjmahone Idea mode 💡: I think we can have one function to do both

visitReferencedFragmentsRecursively(definition, fragment => {
  if (isExperimentalFragmentWithVariableDefinitions(fragment)) {
    return false;
  }
  Array.prototype.push.apply(
    usages,
    this.getVariableUsages(fragments[i]),
  );
});

And it will also simplify this code:

for (const fragment of context.getRecursivelyReferencedFragments(
operation,
)) {
fragmentNameUsed[fragment.name.value] = true;
}

Plus you don't need to create intermidiate array in a process.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mjmahone Stupid idea 🤦‍♂️ because it will break caching.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm would it make more sense to create the ValidationContext with a flag indicating we should use fragment variables as a "validation cut" point? Then you're either opting in to it or not, and we could for instance validate "human-written" graphql with fragment variables as well as "transformed" (whatever that means) graphql in two different validation steps, just by creating a new validation context without the flag.

Basically this means people using fragment variables might consume more memory/make two passes, but I'd rather have people on the experimental version have some pain than make things confusing for those on mainline GraphQL.

@mjmahone
Copy link
Contributor Author

I'm going to put this PR on ice for a bit: fragment variables need to go over at least 1-2 more iterations of "what they mean" design: I'm no longer confident this is how fragment variable validation should work, so there's no use thrashing more here.

@mjmahone mjmahone closed this Jul 18, 2018
@mjmahone mjmahone deleted the fragment-vars-validate branch July 18, 2018 15:15
@IvanGoncharov
Copy link
Member

IvanGoncharov commented Jul 18, 2018

@mjmahone Thanks for the detailed explanation about the intention of this feature, now I better understand what's happing here and how Facebook depends both on Relay and graphql-js.

Sorry for being too meticulous about this change, I just worried that query syntax is the most critical part of GraphQL since we can never do any breaking change to the existing clients.

Ping me if you need any help with this feature or a devil's advocate assistance 👿

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants