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

Add experimental support for parsing variable definitions in fragments #1141

Merged
merged 1 commit into from
Dec 14, 2017
Merged

Add experimental support for parsing variable definitions in fragments #1141

merged 1 commit into from
Dec 14, 2017

Conversation

sam-swarr
Copy link
Contributor

This PR adds experimental support for parsing variable definitions within a fragment declaration. If the fragmentVariablesExperimental flag is passed into the options for parse then the parser will accept VariableDefinitions placed after the TypeCondition and before any Directives. The syntax is identical to the usual query-defined variable definitions, and the parser will parse these into the optional variableDefinitions field on FragmentDefinitionNode.

Context:
There's been ongoing discussion on exploring this concept: graphql/graphql-spec#204. Note that this PR only supports parsing fragment variable definitions into the AST. It does not allow for passing variable values into a fragment spread. It at least gets a foot in the door for starting to explore further down this path.

An emergent pain point with the current system (where a query must define all variables used in its dependent fragments) is that adding a new variable reference to a commonly used fragment could involve updating the declarations of the dozens of queries that depend on that fragment. With this PR, it would become easy to implement a client side transform that would allow for variable defaults defined at the fragment level to be automatically hoisted to its dependent queries. For example, with

query q () {
  ...a
}

fragment a on t ($size: Int = 0) {
  f(size: $size)
}

the transform would automatically hoist the $size variable declaration into query q without the need to explicitly define it. This can be useful if fragment a is used by many queries.

/**
* If enabled, the parser will understand and parse variable definitions
* contained in a fragment definition. They'll be represented in the
* `variableDefinitions` field of the FragmentDefinitionNode.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we make a point that this is not guaranteed to be supported in the future, and anyone that depends on this may have to migrate away from it at a later date?

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed - highlight the fact this is experimental and may change or be removed in the future

Copy link
Contributor

@leebyron leebyron left a comment

Choose a reason for hiding this comment

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

The implementation looks solid - I think making sure any reader cannot mistake the experiment is important so some suggestions inline

`fragment ${name} on ${typeCondition} ` +
wrap('(', join(variableDefinitions, ', '), ') ') +
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a comment here about this being experimental for those who stumble upon it

FragmentDefinition: [
'name',
'typeCondition',
'variableDefinitions',
Copy link
Contributor

Choose a reason for hiding this comment

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

same

@@ -488,6 +501,17 @@ function parseFragment(
function parseFragmentDefinition(lexer: Lexer<*>): FragmentDefinitionNode {
const start = lexer.token;
expectKeyword(lexer, 'fragment');
if (lexer.options.fragmentVariablesExperimental) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a comment above this that includes the altered grammar with a bit of explanation

// Experimental support for defining variables within fragments changes 
// the grammar of FragmentDefinition:
//   - fragment FragmentName on TypeCondition VariableDefinitions? Directives? SelectionSet 

/**
* If enabled, the parser will understand and parse variable definitions
* contained in a fragment definition. They'll be represented in the
* `variableDefinitions` field of the FragmentDefinitionNode.
Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed - highlight the fact this is experimental and may change or be removed in the future

* ...
* }
*/
fragmentVariablesExperimental?: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

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

This option reads a bit strangely, what about experimentalFragmentVariables?

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, I had fragmentVariables_experimental before (not sure if that was any better), but the camelcase linter didn't like that. I'll take your suggestion.

@@ -253,6 +253,7 @@ export type FragmentDefinitionNode = {
+loc?: Location,
+name: NameNode,
+typeCondition: NamedTypeNode,
+variableDefinitions?: $ReadOnlyArray<VariableDefinitionNode>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a short comment above this explaining its an experimental addition

@@ -290,6 +290,53 @@ describe('Visitor', () => {
]);
});

it('visits variables defined in fragments', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

it('Experimental: visits variables...

@sam-swarr
Copy link
Contributor Author

Added comments emphasizing the experimental nature of this.

Copy link
Contributor

@mjmahone mjmahone left a comment

Choose a reason for hiding this comment

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

two more experimental-notices and it looks good to me

@@ -391,6 +391,16 @@ describe('Parser', () => {
expect(result.loc).to.equal(undefined);
});

it('allows parsing fragment defined variables if enabled', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Experimental:

@@ -103,6 +103,22 @@ describe('Printer', () => {
`);
});

it('correctly prints fragment definition with variables', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Experimental:

@sam-swarr
Copy link
Contributor Author

Added "Experimental" to the other test cases.

@leebyron
Copy link
Contributor

Also - just to discuss I think we have two potential grammars for this experiment that I've seen and we should settle on one with some forethought.

  • Condition first:
    fragment FragmentName on TypeCondition VariableDefinitions? Directives? SelectionSet

  • Variables first
    fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet

I noticed in the linked issue in the graphql spec repo the examples use a variables-first form where this PR is condition first.

Thoughts on one vs the other?

@leebyron
Copy link
Contributor

Some examples to look at variables first

fragment ThisIsMyExampleFragment($singleVar: String) on ExampleType {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

fragment ThisIsMyExampleFragment($singleVar: String) on ExampleType @directive {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

fragment ThisIsMyExampleFragment(
  $firstVar: String
  $anotherVar: Int
  $evenOneMoreVar: Float
) on ExampleType {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

Some examples to look at type condition first

fragment ThisIsMyExampleFragment on ExampleType ($singleVar: String) {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

fragment ThisIsMyExampleFragment on ExampleType ($singleVar: String) @directive {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

fragment ThisIsMyExampleFragment on ExampleType (
  $firstVar: String
  $anotherVar: Int
  $evenOneMoreVar: Float
) {
  withSome
  fields {
    sometimes {
      deep
    }
  }
}

@sam-swarr
Copy link
Contributor Author

No strong opinions, but I leaned towards the latter (with TypeCondition first) since I like the look of the VariableDefinition's closing parenthesis immediately before the open curly brace of the SelectionSet. It more closely matches how JS functions and if statements look rather than having the variables embedded between two strings. I realize this doesn't hold true if there's a directive present on the FragmentDefinition, but in the common case where there's no directive, I think it looks cleaner.

@leebyron
Copy link
Contributor

leebyron commented Dec 13, 2017

Some argument for the other form is that the variable definitions sort of "attach" to the preceding fragment name - because of how we're used to reading myFunction(arg) instead of myFunction (arg) - perhaps it's best for these to look like they parameterize the fragment name instead of the fragment type. Operations like query Name($var: Type) { field } and SDL field definitions like field(arg: Type): Type are other examples of this.

Though I agree with you that when there are multiple variable definitions such that the cleanest rendering is multi-line, then having the type condition no longer on that first line is a real drawback

@sam-swarr
Copy link
Contributor Author

Yeah I can see fair arguments for either. I'm not attached to the current approach, so happy to switch it if you'd like.

@dschafer
Copy link
Contributor

I had the same initial reaction as @sam-swarr where "variables first" looked wrong to me because it didn't have the closing parenthesis immediately before the open curly brace. I think my reaction stemmed from my language background: I mostly used languages where return types precede the function (Java or C style) or there is no return type (JS or PHP style), and hence I expect the curly brace right after the close parenthesis.

Looking at languages that declare the return type after the function (like Flow/Hack/Swift/Rust), though:

// Flow
function concat(a: string, b: string): string {
  return a + b;
}
// Hack
public function alpha(): int {
  return 1;
}
// Swift
func greet(person: String, day: String) -> String {
    return "Hello \(person), today is \(day)."
}

It seems like having something between the arguments and the brace is becoming more common... and I do like the fact that in "variables first" the arguments attach to the fragment name, instead of attaching to the type name.

I don't feel strongly either way, though if it were up to me and I had to make the call, I'd lean towards "variables first".

My 2¢.

@mjmahone
Copy link
Contributor

Hmmm. Personally I think I've become convinced that, of the two to begin experimenting with, I'd try "variables first" for now. This is explicitly an experimental feature, so if we need to change it because over and over again it "keeps looking wrong" we can with a pretty trivial codemod. So I'm happiest if we pick something, but I suspect @leebyron's instincts are "more right" in that the latter, "variables last" proposal will end up feeling off in more cases compared to "variables first".

@sam-swarr
Copy link
Contributor Author

Having slept on it and after hearing everyone's input, I'm also in the variables first camp now. Updated the commit to reflect that.

@@ -391,6 +391,16 @@ describe('Parser', () => {
expect(result.loc).to.equal(undefined);
});

it('Experimental: allows parsing fragment defined variables', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it worth adding a test that verifies that without the experimentalFragmentVariables: true flag, this does not parse? Makes sure that we've gated the experimental parsing correctly. (applies to each of the tests, I suspect)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test case already has that (see my comment below).

@dschafer
Copy link
Contributor

Looks great to me!

expect(() =>
parse(source, { experimentalFragmentVariables: true }),
).to.not.throw();
expect(() => parse(source)).to.throw('Syntax Error');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dschafer, already got it covered here!

expect(() =>
parse(source, { experimentalFragmentVariables: true }),
).to.not.throw();
expect(() => parse(source)).to.throw('Syntax Error');
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, didn't notice this test did both, you're way ahead of me!

@IvanGoncharov
Copy link
Member

IvanGoncharov commented Dec 14, 2017

I also have some ideas for a few experiments with GraphQL syntax both on the client-side for query transformations and on the server-side for schema stitching.

Can we wrap all methods from parser.js into class so they call each other only through this? This will allow me to make a derived class that overrides only methods that I need without a need for maintaining my custom fork of graphql-js. It's probably too much implementation details to be exported as part of public API but it can be exported only in parser.js with a warning comment that it's not a part of stable API.

@leebyron Would you be interested in such PR?

@leebyron
Copy link
Contributor

@IvanGoncharov we discussed taking that exact approach before sending this PR and while there are merits to building an extendable parser, decided not to pursue it this time. I still like the idea though and would be in favor of trying it out in a PR to see what the complexity cost looks like.

@leebyron
Copy link
Contributor

Thanks for your feedback, @dschafer - that was a really valuable additional opinion. I'm happy to see some consensus on the grammar.

This PR looks great, @sam-swarr - thanks for your hard work

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.

6 participants