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; +}