From 291dcb17c383202231e86bcaef4912802206cb72 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Sun, 18 Mar 2018 22:35:20 -0700 Subject: [PATCH] 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="" src/backend/fetchGitHubRepoTest.sh ``` and verify that it exits successfully. 4. Repeat for `require("./githubQueryInline.js")`. wchargin-branch: graphql-structured-queries --- .../__snapshots__/queries.test.js.snap | 961 ++++++++++++++++++ src/backend/graphql/queries.js | 398 ++++++++ src/backend/graphql/queries.test.js | 200 ++++ 3 files changed, 1559 insertions(+) create mode 100644 src/backend/graphql/__snapshots__/queries.test.js.snap create mode 100644 src/backend/graphql/queries.js create mode 100644 src/backend/graphql/queries.test.js diff --git a/src/backend/graphql/__snapshots__/queries.test.js.snap b/src/backend/graphql/__snapshots__/queries.test.js.snap new file mode 100644 index 0000000..bed8d50 --- /dev/null +++ b/src/backend/graphql/__snapshots__/queries.test.js.snap @@ -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 + } +}" +`; diff --git a/src/backend/graphql/queries.js b/src/backend/graphql/queries.js new file mode 100644 index 0000000..f6be94c --- /dev/null +++ b/src/backend/graphql/queries.js @@ -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( + 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( + 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("}"), + ]); + }, +}; diff --git a/src/backend/graphql/queries.test.js b/src/backend/graphql/queries.test.js new file mode 100644 index 0000000..52bdec7 --- /dev/null +++ b/src/backend/graphql/queries.test.js @@ -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; +}