Add a GraphQL structured query format (#77)

Summary:
See motivation in #76. Feel free to look at the new snapshot file to
inspect the structured representation and also the stringified output.

This implementation is sufficient to encode our query against the
GitHub v4 API; see the test plan below.

Test Plan:
Unit tests added; run `yarn flow && yarn test`.

This code has full coverage except for lines 260, 315, and 380 of
`queries.js`; these lines check invariants that should never be
violated.

You can also use the following steps to verify that the sample query is
valid GraphQL that produces the same results as our hand-written query:

 1. Apply the following hacky patch:

    ```diff
    diff --git a/src/backend/graphql/queries.test.js b/src/backend/graphql/queries.test.js
    index 52bdec7..c04a636 100644
    --- a/src/backend/graphql/queries.test.js
    +++ b/src/backend/graphql/queries.test.js
    @@ -3,6 +3,18 @@
     import type {Body} from "./queries";
     import {build, stringify, multilineLayout, inlineLayout} from "./queries";

    +function emitGitHubQuery(layout, filename) {
    +  const fs = require("fs");
    +  const path = require("path");
    +  const result = stringify.body(usefulQuery(), layout);
    +  const outputFilepath = path.join(__dirname, "..", filename);
    +  const outputText = `module.exports = ${JSON.stringify(result)};\n`;
    +  fs.writeFileSync(outputFilepath, outputText);
    +  console.log(`Wrote output to ${outputFilepath}.`);
    +}
    +emitGitHubQuery(multilineLayout("  "), "githubQueryMultiline.js");
    +emitGitHubQuery(inlineLayout(), "githubQueryInline.js");
    +
     describe("queries", () => {
       describe("end-to-end-test cases", () => {
         const testCases = {
    ```

 2. Run `CI=true yarn test`, and verify that the following two files
    written to `src/backend/` contain appropriate contents. You can just
    eyeball them, or check that they match my results:
    https://gist.github.com/wchargin/f37b99fd4ec345c9d2541c2dc53ceda9

 3. In `fetchGitHubRepo.js`, change the definition of `const query` to

    ```js
    const query = require("./githubQueryMultiline.js");
    ```

    Run

    ```shell
    GITHUB_TOKEN="<your_token_here>" src/backend/fetchGitHubRepoTest.sh
    ```

    and verify that it exits successfully.

 4. Repeat for `require("./githubQueryInline.js")`.

wchargin-branch: graphql-structured-queries
This commit is contained in:
William Chargin 2018-03-18 22:35:20 -07:00 committed by GitHub
parent 1083540d21
commit 291dcb17c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 1559 additions and 0 deletions

View File

@ -0,0 +1,961 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`queries complex object stringification should build the object 1`] = `
Object {
"data": Object {
"exampleBoolean": Object {
"data": true,
"type": "LITERAL",
},
"exampleEnum": Object {
"data": "WORLD",
"type": "ENUM",
},
"exampleList": Object {
"data": Array [
Object {
"data": 12,
"type": "LITERAL",
},
Object {
"data": 14,
"type": "LITERAL",
},
Object {
"data": 16,
"type": "LITERAL",
},
],
"type": "LIST",
},
"exampleNull": Object {
"data": null,
"type": "LITERAL",
},
"exampleNumber": Object {
"data": 123,
"type": "LITERAL",
},
"exampleObject": Object {
"data": Object {
"roses": Object {
"data": "red",
"type": "LITERAL",
},
"violets": Object {
"data": "blue",
"type": "LITERAL",
},
},
"type": "OBJECT",
},
"exampleString": Object {
"data": "hello",
"type": "LITERAL",
},
"exampleVariable": Object {
"data": "widget",
"type": "VARIABLE",
},
"nestedList": Object {
"data": Array [
Object {
"data": Array [
Object {
"data": "w",
"type": "LITERAL",
},
Object {
"data": "x",
"type": "LITERAL",
},
],
"type": "LIST",
},
Object {
"data": Array [
Object {
"data": "y",
"type": "LITERAL",
},
Object {
"data": "z",
"type": "LITERAL",
},
],
"type": "LIST",
},
],
"type": "LIST",
},
"nestedObject": Object {
"data": Object {
"english": Object {
"data": Object {
"n1": Object {
"data": "one",
"type": "LITERAL",
},
"n2": Object {
"data": "two",
"type": "LITERAL",
},
},
"type": "OBJECT",
},
"greek": Object {
"data": Object {
"n1": Object {
"data": "ένα",
"type": "LITERAL",
},
"n2": Object {
"data": "δύο",
"type": "LITERAL",
},
},
"type": "OBJECT",
},
},
"type": "OBJECT",
},
},
"type": "OBJECT",
}
`;
exports[`queries complex object stringification should work inline 1`] = `"{ exampleNumber: 123 exampleString: \\"hello\\" exampleBoolean: true exampleNull: null exampleEnum: WORLD exampleVariable: $widget exampleList: [ 12 14 16 ] exampleObject: { roses: \\"red\\" violets: \\"blue\\" } nestedList: [ [ \\"w\\" \\"x\\" ] [ \\"y\\" \\"z\\" ] ] nestedObject: { english: { n1: \\"one\\" n2: \\"two\\" } greek: { n1: \\"ένα\\" n2: \\"δύο\\" } } }"`;
exports[`queries complex object stringification should work multiline 1`] = `
"{
exampleNumber:
123
exampleString:
\\"hello\\"
exampleBoolean:
true
exampleNull:
null
exampleEnum:
WORLD
exampleVariable:
$widget
exampleList:
[
12
14
16
]
exampleObject:
{
roses:
\\"red\\"
violets:
\\"blue\\"
}
nestedList:
[
[
\\"w\\"
\\"x\\"
]
[
\\"y\\"
\\"z\\"
]
]
nestedObject:
{
english:
{
n1:
\\"one\\"
n2:
\\"two\\"
}
greek:
{
n1:
\\"ένα\\"
n2:
\\"δύο\\"
}
}
}"
`;
exports[`queries end-to-end-test cases for a query using lots of features should build the query 1`] = `
Array [
Object {
"name": "QueryWithParameters",
"params": Array [
Object {
"name": "qp1",
"type": "String!",
},
Object {
"name": "qp2",
"type": "String!",
},
],
"selections": Array [
Object {
"args": Object {
"id": Object {
"data": 12345,
"type": "LITERAL",
},
"name": Object {
"data": "qp1",
"type": "VARIABLE",
},
},
"name": "thing",
"selections": Array [
Object {
"args": Object {
"tasty": Object {
"data": true,
"type": "LITERAL",
},
"type": Object {
"data": "APPLE",
"type": "ENUM",
},
},
"name": "fruit",
"selections": Array [],
"type": "FIELD",
},
Object {
"fragmentName": "otherThings",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "more",
"selections": Array [
Object {
"selections": Array [
Object {
"args": Object {},
"name": "mcguffins",
"selections": Array [
Object {
"args": Object {},
"name": "quantity",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {
"state": Object {
"data": "SLIMY",
"type": "ENUM",
},
},
"name": "slime",
"selections": Array [
Object {
"args": Object {},
"name": "availability",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "INLINE_FRAGMENT",
"typeCondition": "Widget",
},
Object {
"selections": Array [
Object {
"args": Object {
"attributes": Object {
"data": Object {
"shaft": Object {
"data": null,
"type": "LITERAL",
},
"teeth": Object {
"data": Array [
Object {
"data": 12,
"type": "LITERAL",
},
Object {
"data": 14,
"type": "LITERAL",
},
Object {
"data": 16,
"type": "LITERAL",
},
],
"type": "LIST",
},
},
"type": "OBJECT",
},
},
"name": "cogs",
"selections": Array [],
"type": "FIELD",
},
],
"type": "INLINE_FRAGMENT",
"typeCondition": "Gizmo",
},
],
"type": "FIELD",
},
],
"type": "QUERY",
},
Object {
"name": "QueryWithoutParameters",
"params": Array [],
"selections": Array [
Object {
"args": Object {},
"name": "rateLimit",
"selections": Array [
Object {
"args": Object {},
"name": "remaining",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "QUERY",
},
Object {
"name": "otherThings",
"selections": Array [
Object {
"args": Object {},
"name": "__typename",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "quality",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FRAGMENT",
"typeCondition": "Thing",
},
]
`;
exports[`queries end-to-end-test cases for a query using lots of features should stringify as inline 1`] = `"query QueryWithParameters($qp1: String! $qp2: String!) { thing(id: 12345 name: $qp1) { fruit(type: APPLE tasty: true) ...otherThings } more { ... on Widget { mcguffins { quantity } slime(state: SLIMY) { availability } } ... on Gizmo { cogs(attributes: { teeth: [ 12 14 16 ] shaft: null }) } } } query QueryWithoutParameters { rateLimit { remaining } } fragment otherThings on Thing { __typename quality }"`;
exports[`queries end-to-end-test cases for a query using lots of features should stringify as multiline 1`] = `
"query QueryWithParameters($qp1: String! $qp2: String!) {
thing(id: 12345 name: $qp1) {
fruit(type: APPLE tasty: true)
...otherThings
}
more {
... on Widget {
mcguffins {
quantity
}
slime(state: SLIMY) {
availability
}
}
... on Gizmo {
cogs(attributes: { teeth: [ 12 14 16 ] shaft: null })
}
}
}
query QueryWithoutParameters {
rateLimit {
remaining
}
}
fragment otherThings on Thing {
__typename
quality
}"
`;
exports[`queries end-to-end-test cases for a useful query should build the query 1`] = `
Array [
Object {
"name": "FetchData",
"params": Array [
Object {
"name": "repoOwner",
"type": "String!",
},
Object {
"name": "repoName",
"type": "String!",
},
],
"selections": Array [
Object {
"args": Object {
"name": Object {
"data": "repoName",
"type": "VARIABLE",
},
"owner": Object {
"data": "repoOwner",
"type": "VARIABLE",
},
},
"name": "repository",
"selections": Array [
Object {
"args": Object {
"first": Object {
"data": 100,
"type": "LITERAL",
},
},
"name": "issues",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "title",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "number",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {
"first": Object {
"data": 20,
"type": "LITERAL",
},
},
"name": "comments",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "url",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {
"first": Object {
"data": 100,
"type": "LITERAL",
},
},
"name": "pullRequests",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "title",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "number",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {
"first": Object {
"data": 20,
"type": "LITERAL",
},
},
"name": "comments",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "url",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {
"first": Object {
"data": 10,
"type": "LITERAL",
},
},
"name": "reviews",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "state",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {
"first": Object {
"data": 10,
"type": "LITERAL",
},
},
"name": "comments",
"selections": Array [
Object {
"args": Object {},
"name": "pageInfo",
"selections": Array [
Object {
"args": Object {},
"name": "hasNextPage",
"selections": Array [],
"type": "FIELD",
},
],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "nodes",
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "body",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "author",
"selections": Array [
Object {
"fragmentName": "whoami",
"type": "FRAGMENT_SPREAD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "FIELD",
},
],
"type": "QUERY",
},
Object {
"name": "whoami",
"selections": Array [
Object {
"args": Object {},
"name": "__typename",
"selections": Array [],
"type": "FIELD",
},
Object {
"args": Object {},
"name": "login",
"selections": Array [],
"type": "FIELD",
},
Object {
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
],
"type": "INLINE_FRAGMENT",
"typeCondition": "User",
},
Object {
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
],
"type": "INLINE_FRAGMENT",
"typeCondition": "Organization",
},
Object {
"selections": Array [
Object {
"args": Object {},
"name": "id",
"selections": Array [],
"type": "FIELD",
},
],
"type": "INLINE_FRAGMENT",
"typeCondition": "Bot",
},
],
"type": "FRAGMENT",
"typeCondition": "Actor",
},
]
`;
exports[`queries end-to-end-test cases for a useful query should stringify as inline 1`] = `"query FetchData($repoOwner: String! $repoName: String!) { repository(owner: $repoOwner name: $repoName) { issues(first: 100) { pageInfo { hasNextPage } nodes { id title body number author { ...whoami } comments(first: 20) { pageInfo { hasNextPage } nodes { id author { ...whoami } body url } } } } pullRequests(first: 100) { pageInfo { hasNextPage } nodes { id title body number author { ...whoami } comments(first: 20) { pageInfo { hasNextPage } nodes { id author { ...whoami } body url } } reviews(first: 10) { pageInfo { hasNextPage } nodes { id body author { ...whoami } state comments(first: 10) { pageInfo { hasNextPage } nodes { id body author { ...whoami } } } } } } } } } fragment whoami on Actor { __typename login ... on User { id } ... on Organization { id } ... on Bot { id } }"`;
exports[`queries end-to-end-test cases for a useful query should stringify as multiline 1`] = `
"query FetchData($repoOwner: String! $repoName: String!) {
repository(owner: $repoOwner name: $repoName) {
issues(first: 100) {
pageInfo {
hasNextPage
}
nodes {
id
title
body
number
author {
...whoami
}
comments(first: 20) {
pageInfo {
hasNextPage
}
nodes {
id
author {
...whoami
}
body
url
}
}
}
}
pullRequests(first: 100) {
pageInfo {
hasNextPage
}
nodes {
id
title
body
number
author {
...whoami
}
comments(first: 20) {
pageInfo {
hasNextPage
}
nodes {
id
author {
...whoami
}
body
url
}
}
reviews(first: 10) {
pageInfo {
hasNextPage
}
nodes {
id
body
author {
...whoami
}
state
comments(first: 10) {
pageInfo {
hasNextPage
}
nodes {
id
body
author {
...whoami
}
}
}
}
}
}
}
}
}
fragment whoami on Actor {
__typename
login
... on User {
id
}
... on Organization {
id
}
... on Bot {
id
}
}"
`;

View File

@ -0,0 +1,398 @@
// @flow
/**
* GraphQL structured query data format.
*
* Main module exports:
* - lots of types for various GraphQL language constructs
* - the `build` object, providing a fluent builder API
* - the `stringify` object, and particularly `stringify.body`
* - the two layout strategies `multilineLayout` and `inlineLayout`
*/
export type Body = Definition[];
export type Definition = QueryDefinition | FragmentDefinition;
// We only need opaque type handles; no need to embed the GraphQL type
// system into Flow.
export type GraphQLType = string;
export type QueryDefinition = {|
+type: "QUERY",
+name: string,
+params: Parameter[],
+selections: Selection[],
|};
export type Parameter = {|+name: string, +type: GraphQLType|};
export type FragmentDefinition = {|
+type: "FRAGMENT",
+name: string,
+typeCondition: GraphQLType,
+selections: Selection[],
|};
export type Selection = Field | FragmentSpread | InlineFragment;
export type Field = {|
+type: "FIELD",
+name: string,
+args: Arguments,
+selections: Selection[],
|};
export type FragmentSpread = {|
+type: "FRAGMENT_SPREAD",
+fragmentName: string,
|};
export type InlineFragment = {|
+type: "INLINE_FRAGMENT",
+typeCondition: ?GraphQLType,
+selections: Selection[],
|};
export type Arguments = {[string]: Value};
export type Value =
| VariableValue
| LiteralValue
| EnumValue
| ListValue
| ObjectValue;
export type VariableValue = {|+type: "VARIABLE", +data: string|};
export type LiteralValue = {|
+type: "LITERAL",
+data: number | string | boolean | null,
|};
export type EnumValue = {|+type: "ENUM", +data: string|};
export type ListValue = {|+type: "LIST", +data: Value[]|};
export type ObjectValue = {|+type: "OBJECT", +data: {[string]: Value}|};
export const build = {
query(
name: string,
params: Parameter[],
selections: Selection[]
): QueryDefinition {
return {
type: "QUERY",
name,
params,
selections,
};
},
param(name: string, type: GraphQLType): Parameter {
return {name, type};
},
fragment(
name: string,
typeCondition: GraphQLType,
selections: Selection[]
): FragmentDefinition {
return {
type: "FRAGMENT",
name,
typeCondition,
selections,
};
},
field(name: string, args: ?Arguments, selections: ?(Selection[])): Field {
return {
type: "FIELD",
name,
args: args || {},
selections: selections || [],
};
},
fragmentSpread(fragmentName: string): FragmentSpread {
return {
type: "FRAGMENT_SPREAD",
fragmentName,
};
},
inlineFragment(
typeCondition: ?GraphQLType,
selections: Selection[]
): InlineFragment {
return {
type: "INLINE_FRAGMENT",
typeCondition,
selections,
};
},
variable(name: string): VariableValue {
return {
type: "VARIABLE",
data: name,
};
},
literal(value: number | string | boolean | null): LiteralValue {
return {
type: "LITERAL",
data: value,
};
},
enumLiteral(value: string): EnumValue {
return {
type: "ENUM",
data: value,
};
},
list(data: Value[]): ListValue {
return {
type: "LIST",
data: data,
};
},
object(data: {[string]: Value}): ObjectValue {
return {
type: "OBJECT",
data: data,
};
},
};
/**
* A strategy for stringifying a sequence of GraphQL language tokens.
*/
export interface LayoutStrategy {
// Lay out a group of tokens that should be treated atomically.
atom(line: string): string;
// Join groups of tokens. The elements should be the results of calls
// to `atom` (or other `join`s).
join(lines: string[]): string;
// Get a strategy for the next level of nesting. For instance, if this
// object lays out its result over multiple lines, then this method
// might produce a strategy with one extra level of indentation.
next(): LayoutStrategy;
}
/**
* Create a layout strategy that lays out text over multiple lines,
* indenting with the given tab string (such as "\t" or " ").
*/
export function multilineLayout(tab: string): LayoutStrategy {
function strategy(indentLevel: number) {
return {
atom: (line: string) => {
const indentation = Array(indentLevel)
.fill(tab)
.join("");
return indentation + line;
},
join: (xs: string[]) => xs.join("\n"),
next: () => strategy(indentLevel + 1),
};
}
return strategy(0);
}
/**
* Create a layout strategy that lays out all text on one line.
*/
export function inlineLayout(): LayoutStrategy {
const result = {
atom: (line) => line,
join: (xs) => xs.join(" "),
next: () => result,
};
return result;
}
/*
* Map a stringification function across a list, and join the results
* with a formatter.
*/
function formatList<T>(
values: T[],
subformatter: (value: T, ls: LayoutStrategy) => string,
ls: LayoutStrategy
): string {
return ls.join(values.map((x) => subformatter(x, ls)));
}
/*
* Map a stringification function across the values of an object, and
* join the keys and their corresponding results with a formatter.
*/
function formatObject<T>(
object: {[string]: T},
subformatter: (value: T, ls: LayoutStrategy) => string,
ls: LayoutStrategy
): string {
function formatKey(k: string, ls: LayoutStrategy): string {
return ls.join([ls.atom(`${k}:`), subformatter(object[k], ls.next())]);
}
return formatList(Object.keys(object), formatKey, ls);
}
export const stringify = {
body(body: Body, ls: LayoutStrategy): string {
return formatList(body, stringify.definition, ls);
},
definition(definition: Definition, ls: LayoutStrategy): string {
switch (definition.type) {
case "QUERY":
return stringify.queryDefinition(definition, ls);
case "FRAGMENT":
return stringify.fragmentDefinition(definition, ls);
default:
throw new Error(`Unknown definition type: ${definition.type}`);
}
},
queryDefinition(query: QueryDefinition, ls: LayoutStrategy): string {
const paramsPart = (() => {
if (query.params.length === 0) {
return "";
} else {
const items = formatList(
query.params,
stringify.parameter,
inlineLayout()
);
return `(${items})`;
}
})();
const selectionsPart = formatList(
query.selections,
stringify.selection,
ls.next()
);
return ls.join([
ls.atom(`query ${query.name}${paramsPart} {`),
selectionsPart,
ls.atom("}"),
]);
},
parameter(parameter: Parameter, ls: LayoutStrategy): string {
return ls.atom(`\$${parameter.name}: ${parameter.type}`);
},
fragmentDefinition(fragment: FragmentDefinition, ls: LayoutStrategy): string {
const selectionsPart = formatList(
fragment.selections,
stringify.selection,
ls.next()
);
return ls.join([
ls.atom(`fragment ${fragment.name} on ${fragment.typeCondition} {`),
selectionsPart,
ls.atom("}"),
]);
},
selection(selection: Selection, ls: LayoutStrategy): string {
switch (selection.type) {
case "FIELD":
return stringify.field(selection, ls);
case "FRAGMENT_SPREAD":
return stringify.fragmentSpread(selection, ls);
case "INLINE_FRAGMENT":
return stringify.inlineFragment(selection, ls);
default:
throw new Error(`Unknown selection type: ${selection.type}`);
}
},
field(field: Field, ls: LayoutStrategy): string {
const argsPart = (() => {
if (Object.keys(field.args).length === 0) {
return "";
} else {
const args = formatObject(field.args, stringify.value, inlineLayout());
return `(${args})`;
}
})();
if (field.selections.length === 0) {
return ls.atom(`${field.name}${argsPart}`);
} else {
const selectionsPart = formatList(
field.selections,
stringify.selection,
ls.next()
);
return ls.join([
ls.atom(`${field.name}${argsPart} {`),
selectionsPart,
ls.atom("}"),
]);
}
},
fragmentSpread(fs: FragmentSpread, ls: LayoutStrategy): string {
return ls.atom(`...${fs.fragmentName}`);
},
inlineFragment(fragment: InlineFragment, ls: LayoutStrategy): string {
const typeConditionPart =
fragment.typeCondition == null ? "" : ` on ${fragment.typeCondition}`;
const selectionsPart = formatList(
fragment.selections,
stringify.selection,
ls.next()
);
return ls.join([
ls.atom(`...${typeConditionPart} {`),
selectionsPart,
ls.atom("}"),
]);
},
value(value: Value, ls: LayoutStrategy): string {
switch (value.type) {
case "VARIABLE":
return stringify.variableValue(value, ls);
case "LITERAL":
return stringify.literalValue(value, ls);
case "ENUM":
return stringify.enumValue(value, ls);
case "LIST":
return stringify.listValue(value, ls);
case "OBJECT":
return stringify.objectValue(value, ls);
default:
throw new Error(`Unknown value type: ${value.type}`);
}
},
variableValue(value: VariableValue, ls: LayoutStrategy): string {
return ls.atom(`\$${value.data}`);
},
literalValue(value: LiteralValue, ls: LayoutStrategy): string {
return ls.atom(JSON.stringify(value.data));
},
enumValue(value: EnumValue, ls: LayoutStrategy): string {
return ls.atom(value.data);
},
listValue(value: ListValue, ls: LayoutStrategy): string {
return ls.join([
ls.atom("["),
formatList(value.data, stringify.value, ls.next()),
ls.atom("]"),
]);
},
objectValue(value: ObjectValue, ls: LayoutStrategy): string {
return ls.join([
ls.atom("{"),
formatObject(value.data, stringify.value, ls.next()),
ls.atom("}"),
]);
},
};

View File

@ -0,0 +1,200 @@
// @flow
import type {Body} from "./queries";
import {build, stringify, multilineLayout, inlineLayout} from "./queries";
describe("queries", () => {
describe("end-to-end-test cases", () => {
const testCases = {
"a query using lots of features": featurefulQuery,
"a useful query": usefulQuery,
};
Object.keys(testCases).forEach((key) => {
describe(`for ${key}`, () => {
const testCase = testCases[key];
it("should build the query", () => {
expect(testCase()).toMatchSnapshot();
});
it("should stringify as multiline", () => {
const result = stringify.body(testCase(), multilineLayout(" "));
expect(result).toMatchSnapshot();
});
it("should stringify as inline", () => {
const result = stringify.body(testCase(), inlineLayout());
expect(result).toMatchSnapshot();
});
});
});
});
describe("complex object stringification", () => {
const object = () => {
const b = build;
return b.object({
exampleNumber: b.literal(123),
exampleString: b.literal("hello"),
exampleBoolean: b.literal(true),
exampleNull: b.literal(null),
exampleEnum: b.enumLiteral("WORLD"),
exampleVariable: b.variable("widget"),
exampleList: b.list([b.literal(12), b.literal(14), b.literal(16)]),
exampleObject: b.object({
roses: b.literal("red"),
violets: b.literal("blue"),
}),
nestedList: b.list([
b.list([b.literal("w"), b.literal("x")]),
b.list([b.literal("y"), b.literal("z")]),
]),
nestedObject: b.object({
english: b.object({
n1: b.literal("one"),
n2: b.literal("two"),
}),
greek: b.object({
n1: b.literal("ένα"),
n2: b.literal("δύο"),
}),
}),
});
};
it("should build the object", () => {
expect(object()).toMatchSnapshot();
});
it("should work multiline", () => {
const result = stringify.value(object(), multilineLayout(" "));
expect(result).toMatchSnapshot();
});
it("should work inline", () => {
const result = stringify.value(object(), inlineLayout());
expect(result).toMatchSnapshot();
});
});
});
function featurefulQuery(): Body {
const b = build;
const body: Body = [
b.query(
"QueryWithParameters",
[b.param("qp1", "String!"), b.param("qp2", "String!")],
[
b.field("thing", {id: b.literal(12345), name: b.variable("qp1")}, [
b.field("fruit", {
type: b.enumLiteral("APPLE"),
tasty: b.literal(true),
}),
b.fragmentSpread("otherThings"),
]),
b.field("more", {}, [
b.inlineFragment("Widget", [
b.field("mcguffins", {}, [b.field("quantity")]),
b.field("slime", {state: b.enumLiteral("SLIMY")}, [
b.field("availability"),
]),
]),
b.inlineFragment("Gizmo", [
b.field("cogs", {
attributes: b.object({
teeth: b.list([b.literal(12), b.literal(14), b.literal(16)]),
shaft: b.literal(null),
}),
}),
]),
]),
]
),
b.query(
"QueryWithoutParameters",
[],
[b.field("rateLimit", {}, [b.field("remaining")])]
),
b.fragment("otherThings", "Thing", [
b.field("__typename"),
b.field("quality"),
]),
];
return body;
}
function usefulQuery(): Body {
const b = build;
const makePageInfo = () => b.field("pageInfo", {}, [b.field("hasNextPage")]);
const makeAuthor = () => b.field("author", {}, [b.fragmentSpread("whoami")]);
const body: Body = [
b.query(
"FetchData",
[b.param("repoOwner", "String!"), b.param("repoName", "String!")],
[
b.field(
"repository",
{owner: b.variable("repoOwner"), name: b.variable("repoName")},
[
b.field("issues", {first: b.literal(100)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
b.field("title"),
b.field("body"),
b.field("number"),
makeAuthor(),
b.field("comments", {first: b.literal(20)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
makeAuthor(),
b.field("body"),
b.field("url"),
]),
]),
]),
]),
b.field("pullRequests", {first: b.literal(100)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
b.field("title"),
b.field("body"),
b.field("number"),
makeAuthor(),
b.field("comments", {first: b.literal(20)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
makeAuthor(),
b.field("body"),
b.field("url"),
]),
]),
b.field("reviews", {first: b.literal(10)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
b.field("body"),
makeAuthor(),
b.field("state"),
b.field("comments", {first: b.literal(10)}, [
makePageInfo(),
b.field("nodes", {}, [
b.field("id"),
b.field("body"),
makeAuthor(),
]),
]),
]),
]),
]),
]),
]
),
]
),
b.fragment("whoami", "Actor", [
b.field("__typename"),
b.field("login"),
b.inlineFragment("User", [b.field("id")]),
b.inlineFragment("Organization", [b.field("id")]),
b.inlineFragment("Bot", [b.field("id")]),
]),
];
return body;
}