From 2f36df2865f769d0bd349097ce5c24cb21b7145e Mon Sep 17 00:00:00 2001 From: Maarten van Vliet Date: Fri, 22 Oct 2021 19:25:48 +0200 Subject: [PATCH 1/5] Store if document uses shorthand notation It's not necessary for the execution of a document to preserve this. For converting Absinthe.Language AST to a string it is however. Note that it's not a goal that the document -> AST -> document transformation is perfect in all cases. E.g. commas or lack thereof is not preserved. The shorthand notation however seemed easy enough to implement. See https://spec.graphql.org/draft/#sec-Language.Operations.Query-shorthand --- lib/absinthe/language/operation_definition.ex | 2 ++ src/absinthe_parser.yrl | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/absinthe/language/operation_definition.ex b/lib/absinthe/language/operation_definition.ex index 09b27c6e46..b47b782621 100644 --- a/lib/absinthe/language/operation_definition.ex +++ b/lib/absinthe/language/operation_definition.ex @@ -8,6 +8,7 @@ defmodule Absinthe.Language.OperationDefinition do variable_definitions: [], directives: [], selection_set: nil, + shorthand: false, loc: %{line: nil} @type t :: %__MODULE__{ @@ -16,6 +17,7 @@ defmodule Absinthe.Language.OperationDefinition do variable_definitions: [Language.VariableDefinition.t()], directives: [Language.Directive.t()], selection_set: Language.SelectionSet.t(), + shorthand: boolean(), loc: Language.loc_t() } diff --git a/src/absinthe_parser.yrl b/src/absinthe_parser.yrl index 5ca6eb28a1..d77a3115f8 100644 --- a/src/absinthe_parser.yrl +++ b/src/absinthe_parser.yrl @@ -36,7 +36,7 @@ OperationType -> 'query' : '$1'. OperationType -> 'mutation' : '$1'. OperationType -> 'subscription' : '$1'. -OperationDefinition -> SelectionSet : build_ast_node('OperationDefinition', #{'operation' => 'query', 'selection_set' => '$1'}, extract_child_location('$1')). +OperationDefinition -> SelectionSet : build_ast_node('OperationDefinition', #{'operation' => 'query', 'selection_set' => '$1', 'shorthand' => true}, extract_child_location('$1')). OperationDefinition -> OperationType SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'selection_set' => '$2'}, extract_location('$1')). OperationDefinition -> OperationType VariableDefinitions SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'variable_definitions' => '$2', 'selection_set' => '$3'}, extract_child_location('$1')). OperationDefinition -> OperationType VariableDefinitions Directives SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'variable_definitions' => '$2', 'directives' => '$3', 'selection_set' => '$4'}, extract_child_location('$1')). From 85a1279eefe08717abcc449410253c2bec597d1d Mon Sep 17 00:00:00 2001 From: Maarten van Vliet Date: Fri, 22 Oct 2021 19:54:24 +0200 Subject: [PATCH 2/5] Render Absinthe.Language AST to graphql string --- lib/absinthe/language/document.ex | 5 + lib/absinthe/language/render.ex | 279 +++++++++++++++++++++++++ lib/absinthe/utils/render.ex | 72 +++++++ test/absinthe/language/render_test.exs | 124 +++++++++++ 4 files changed, 480 insertions(+) create mode 100644 lib/absinthe/language/render.ex create mode 100644 lib/absinthe/utils/render.ex create mode 100644 test/absinthe/language/render_test.exs diff --git a/lib/absinthe/language/document.ex b/lib/absinthe/language/document.ex index aea0309cb6..4dd409f17b 100644 --- a/lib/absinthe/language/document.ex +++ b/lib/absinthe/language/document.ex @@ -83,4 +83,9 @@ defmodule Absinthe.Language.Document do update_in(blueprint.fragments, &[Blueprint.Draft.convert(node, doc) | &1]) end end + + defimpl Inspect do + defdelegate inspect(term, options), + to: Absinthe.Language.Render + end end diff --git a/lib/absinthe/language/render.ex b/lib/absinthe/language/render.ex new file mode 100644 index 0000000000..6c6f83a2fe --- /dev/null +++ b/lib/absinthe/language/render.ex @@ -0,0 +1,279 @@ +defmodule Absinthe.Language.Render do + @moduledoc false + import Inspect.Algebra + import Absinthe.Utils.Render + + @line_width 120 + + def inspect(term, %{pretty: true}) do + term + |> render() + |> concat(line()) + |> format(@line_width) + |> to_string + end + + def inspect(term, options) do + Inspect.Any.inspect(term, options) + end + + defp render(bp) + + defp render(%Absinthe.Language.Document{} = doc) do + doc.definitions |> Enum.map(&render/1) |> join("\n") + end + + defp render(%Absinthe.Language.OperationDefinition{} = op) do + if op.shorthand do + concat(operation_definition(op), block(render_list(op.selection_set.selections))) + else + glue( + concat([to_string(op.operation), operation_definition(op)]), + block(render_list(op.selection_set.selections)) + ) + end + end + + defp render(%Absinthe.Language.Field{} = field) do + case field.selection_set do + nil -> + field_definition(field) + + selection_set -> + concat([ + field_definition(field), + " ", + block(render_list(selection_set.selections)) + ]) + end + end + + defp render(%Absinthe.Language.VariableDefinition{} = variable_definition) do + concat([ + "$", + variable_definition.variable.name, + ": ", + render(variable_definition.type), + default_value(variable_definition) + ]) + end + + defp render(%Absinthe.Language.NamedType{} = named_type) do + named_type.name + end + + defp render(%Absinthe.Language.NonNullType{} = non_null) do + concat(render(non_null.type), "!") + end + + defp render(%Absinthe.Language.Argument{} = argument) do + concat([argument.name, ": ", render(argument.value)]) + end + + defp render(%Absinthe.Language.Directive{} = directive) do + concat([" @", directive.name, arguments(directive.arguments)]) + end + + defp render(%Absinthe.Language.FragmentSpread{} = spread) do + concat(["...", spread.name, directives(spread.directives)]) + end + + defp render(%Absinthe.Language.InlineFragment{} = fragment) do + concat([ + "...", + inline_fragment_name(fragment), + directives(fragment.directives), + " ", + block(render_list(fragment.selection_set.selections)) + ]) + end + + defp render(%Absinthe.Language.Variable{} = variable) do + concat("$", variable.name) + end + + defp render(%Absinthe.Language.StringValue{value: value}) do + render_string_value(value) + end + + defp render(%Absinthe.Language.FloatValue{value: value}) do + "#{value}" + end + + defp render(%Absinthe.Language.ObjectField{} = object_field) do + concat([object_field.name, ": ", render(object_field.value)]) + end + + defp render(%Absinthe.Language.ObjectValue{fields: fields}) do + fields = fields |> Enum.map(&render(&1)) |> join(", ") + + concat(["{ ", fields, " }"]) + end + + defp render(%Absinthe.Language.NullValue{}) do + "null" + end + + defp render(%Absinthe.Language.ListType{type: type}) do + concat(["[", render(type), "]"]) + end + + defp render(%Absinthe.Language.ListValue{values: values}) do + values = values |> Enum.map(&render(&1)) |> join(", ") + + concat(["[", values, "]"]) + end + + defp render(%Absinthe.Language.Fragment{} = fragment) do + concat([ + "fragment ", + fragment.name, + " on ", + fragment.type_condition.name, + directives(fragment.directives) + ]) + |> block(render_list(fragment.selection_set.selections)) + end + + defp render(%{value: value}) do + to_string(value) + end + + defp operation_definition(%{name: nil} = op) do + case op.variable_definitions do + [] -> + concat( + variable_definitions(op.variable_definitions), + directives(op.directives) + ) + + _ -> + operation_definition(%{op | name: ""}) + end + end + + defp operation_definition(%{name: name} = op) do + concat([" ", name, variable_definitions(op.variable_definitions), directives(op.directives)]) + end + + defp variable_definitions([]) do + empty() + end + + defp variable_definitions(definitions) do + definitions = Enum.map(definitions, &render(&1)) + + concat([ + "(", + join(definitions, ", "), + ")" + ]) + end + + defp field_definition(field) do + concat([ + field_alias(field), + field.name, + arguments(field.arguments), + directives(field.directives) + ]) + end + + defp default_value(%{default_value: nil}) do + empty() + end + + defp default_value(%{default_value: value}) do + concat(" = ", render(value)) + end + + defp directives([]) do + empty() + end + + defp directives(directives) do + directives |> Enum.map(&render(&1)) |> join(" ") + end + + defp inline_fragment_name(%{type_condition: nil}) do + empty() + end + + defp inline_fragment_name(%{type_condition: %{name: name}}) do + " on #{name}" + end + + defp field_alias(%{alias: nil}) do + empty() + end + + defp field_alias(%{alias: alias}) do + concat(alias, ": ") + end + + defp arguments([]) do + empty() + end + + defp arguments(args) do + group( + glue( + nest( + glue( + "(", + "", + render_list(args, ", ") + ), + 2, + :break + ), + "", + ")" + ) + ) + end + + # Helpers + + defp block(docs) do + do_block(docs) + end + + defp block(:doc_nil, docs) do + do_block(docs) + end + + defp block(name, docs) do + glue( + name, + do_block(docs) + ) + end + + defp do_block(docs) do + group( + glue( + nest( + force_unfit( + glue( + "{", + "", + docs + ) + ), + 2, + :always + ), + "", + "}" + ) + ) + end + + defp render_list(items, separator \\ line()) do + List.foldr(items, :doc_nil, fn + item, :doc_nil -> render(item) + item, acc -> concat([render(item)] ++ [separator] ++ [acc]) + end) + end +end diff --git a/lib/absinthe/utils/render.ex b/lib/absinthe/utils/render.ex new file mode 100644 index 0000000000..4f1476023c --- /dev/null +++ b/lib/absinthe/utils/render.ex @@ -0,0 +1,72 @@ +defmodule Absinthe.Utils.Render do + @moduledoc false + + import Inspect.Algebra + + def join(docs, joiner) do + fold_doc(docs, fn doc, acc -> + concat([doc, concat(List.wrap(joiner)), acc]) + end) + end + + def render_string_value(string, indent \\ 2) do + string + |> String.trim() + |> String.split("\n") + |> case do + [string_line] -> + concat([~s("), escape_string(string_line), ~s(")]) + + string_lines -> + concat( + nest( + block_string([~s(""")] ++ string_lines), + indent, + :always + ), + concat(line(), ~s(""")) + ) + end + end + + @escaped_chars [?", ?\\, ?/, ?\b, ?\f, ?\n, ?\r, ?\t] + + defp escape_string(string) do + escape_string(string, []) + end + + defp escape_string(<>, acc) when char in @escaped_chars do + escape_string(rest, [acc | escape_char(char)]) + end + + defp escape_string(<>, acc) do + escape_string(rest, [acc | <>]) + end + + defp escape_string(<<>>, acc) do + to_string(acc) + end + + defp escape_char(?"), do: [?\\, ?"] + defp escape_char(?\\), do: [?\\, ?\\] + defp escape_char(?/), do: [?\\, ?/] + defp escape_char(?\b), do: [?\\, ?b] + defp escape_char(?\f), do: [?\\, ?f] + defp escape_char(?\n), do: [?\\, ?n] + defp escape_char(?\r), do: [?\\, ?r] + defp escape_char(?\t), do: [?\\, ?t] + + defp block_string([string]) do + string(string) + end + + defp block_string([string | rest]) do + string + |> string() + |> concat(block_string_line(rest)) + |> concat(block_string(rest)) + end + + defp block_string_line(["", _ | _]), do: nest(line(), :reset) + defp block_string_line(_), do: line() +end diff --git a/test/absinthe/language/render_test.exs b/test/absinthe/language/render_test.exs new file mode 100644 index 0000000000..5f688a7873 --- /dev/null +++ b/test/absinthe/language/render_test.exs @@ -0,0 +1,124 @@ +defmodule Absinthe.Language.RenderTest do + use ExUnit.Case + + describe "renders graphql" do + test "for unnamed query" do + assert_rendered(""" + { + version + } + """) + end + + test "for fragment typing" do + assert_rendered(""" + query FragmentTyping { + profiles(handles: ["zuck", "cocacola"]) { + handle + ...userFragment + ...pageFragment + } + } + fragment userFragment on User @defer { + friends { + count + } + } + fragment pageFragment on Page { + likers { + count + } + } + """) + end + + test "for inline fragment with type query" do + assert_rendered(""" + query inlineFragmentTyping { + profiles(handles: ["zuck", "cocacola"]) { + handle + ... on User @onInlineFragment { + friends { + count + } + } + ... on Page { + likers { + count + } + } + } + } + """) + end + + test "for inline fragments without type query" do + assert_rendered(""" + query inlineFragmentNoType($expandedInfo: Boolean) { + user(handle: "zuck") { + id + name + ... @include(if: $expandedInfo) { + firstName + lastName + birthday + } + } + } + """) + end + + test "for block strings" do + assert_rendered(""" + mutation { + sendEmail(message: \"\"\" + Hello, + World! + + Yours, + GraphQL. + \"\"\") + } + """) + end + + test "for null values" do + assert_rendered(""" + query { + field(arg: null) + field + } + """) + end + + test "for input objects" do + assert_rendered(""" + query { + nearestThing(location: { lon: 12.43, lat: -53.211 }) + } + """) + end + + test "for variables" do + assert_rendered(""" + query ($id: ID, $mult: Int = 6, $list: [Int!]! = [1, 2], $customScalar: CustomScalar!) { + times(base: 4, multiplier: $mult) + } + """) + end + + test "for introspection query" do + assert_rendered( + Path.join(__DIR__, "../../../priv/graphql/introspection.graphql") + |> File.read!() + ) + end + end + + defp assert_rendered(graphql) do + {:ok, blueprint} = Absinthe.Phase.Parse.run(graphql, []) + rendered_graphql = inspect(blueprint.input, pretty: true) + + assert graphql == rendered_graphql + end +end From 9673a1c4f6088d8f74acbb1a0af9b2844e89bdd9 Mon Sep 17 00:00:00 2001 From: Maarten van Vliet Date: Fri, 22 Oct 2021 20:05:39 +0200 Subject: [PATCH 3/5] Move common utils to separate file --- lib/absinthe/schema/notation/sdl_render.ex | 68 +--------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/lib/absinthe/schema/notation/sdl_render.ex b/lib/absinthe/schema/notation/sdl_render.ex index 83496b2d5b..abc85a2629 100644 --- a/lib/absinthe/schema/notation/sdl_render.ex +++ b/lib/absinthe/schema/notation/sdl_render.ex @@ -1,6 +1,7 @@ defmodule Absinthe.Schema.Notation.SDL.Render do @moduledoc false import Inspect.Algebra + import Absinthe.Utils.Render alias Absinthe.Blueprint @@ -450,53 +451,6 @@ defmodule Absinthe.Schema.Notation.SDL.Render do defp render_value(%{value: value}), do: to_string(value) - defp render_string_value(string, indent \\ 2) do - string - |> String.trim() - |> String.split("\n") - |> case do - [string_line] -> - concat([~s("), escape_string(string_line), ~s(")]) - - string_lines -> - concat( - nest( - block_string([~s(""")] ++ string_lines), - indent, - :always - ), - concat(line(), ~s(""")) - ) - end - end - - @escaped_chars [?", ?\\, ?/, ?\b, ?\f, ?\n, ?\r, ?\t] - - defp escape_string(string) do - escape_string(string, []) - end - - defp escape_string(<>, acc) when char in @escaped_chars do - escape_string(rest, [acc | escape_char(char)]) - end - - defp escape_string(<>, acc) do - escape_string(rest, [acc | <>]) - end - - defp escape_string(<<>>, acc) do - to_string(acc) - end - - defp escape_char(?"), do: [?\\, ?"] - defp escape_char(?\\), do: [?\\, ?\\] - defp escape_char(?/), do: [?\\, ?/] - defp escape_char(?\b), do: [?\\, ?b] - defp escape_char(?\f), do: [?\\, ?f] - defp escape_char(?\n), do: [?\\, ?n] - defp escape_char(?\r), do: [?\\, ?r] - defp escape_char(?\t), do: [?\\, ?t] - # Algebra Helpers defp multiline(docs, true) do @@ -536,24 +490,4 @@ defmodule Absinthe.Schema.Notation.SDL.Render do ) ) end - - defp block_string([string]) do - string(string) - end - - defp block_string([string | rest]) do - string - |> string() - |> concat(block_string_line(rest)) - |> concat(block_string(rest)) - end - - defp block_string_line(["", _ | _]), do: nest(line(), :reset) - defp block_string_line(_), do: line() - - def join(docs, joiner) do - fold_doc(docs, fn doc, acc -> - concat([doc, concat(List.wrap(joiner)), acc]) - end) - end end From f1a14995f222c9bf3561eb7e8f222c43b86905e8 Mon Sep 17 00:00:00 2001 From: Maarten van Vliet Date: Fri, 22 Oct 2021 20:25:29 +0200 Subject: [PATCH 4/5] Add Absinthe graphql formatter plugin Note it doesn't implement the Mix.Tasks.Format behaviour since this would give a warning pre Elixir 1.13 --- lib/absinthe/formatter.ex | 34 ++++++++++++++++++++++++++ test/absinthe/formatter_test.exs | 12 +++++++++ test/absinthe/language/render_test.exs | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 lib/absinthe/formatter.ex create mode 100644 test/absinthe/formatter_test.exs diff --git a/lib/absinthe/formatter.ex b/lib/absinthe/formatter.ex new file mode 100644 index 0000000000..cbc662d40d --- /dev/null +++ b/lib/absinthe/formatter.ex @@ -0,0 +1,34 @@ +defmodule Absinthe.Formatter do + @moduledoc """ + Formatter task for graphql + + Will format files with the extensions .graphql or .gql + + ## Example + + Absinthe.Formatter.format("{ version }") + "{\n version\n}\n" + + + From Elixir 1.13 onwards the Absinthe.Formatter can be added to + the formatter as a plugin: + + # .formatter.exs + [ + # Define the desired plugins + plugins: [Absinthe.Formatter], + # Remember to update the inputs list to include the new extensions + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "{lib, priv}/**/*.{gql,graphql}"] + ] + + """ + + def features(_opts) do + [sigils: [], extensions: [".graphql", ".gql"]] + end + + def format(contents, _opts \\ []) do + {:ok, blueprint} = Absinthe.Phase.Parse.run(contents, []) + inspect(blueprint.input, pretty: true) + end +end diff --git a/test/absinthe/formatter_test.exs b/test/absinthe/formatter_test.exs new file mode 100644 index 0000000000..309dbc6870 --- /dev/null +++ b/test/absinthe/formatter_test.exs @@ -0,0 +1,12 @@ +defmodule Absinthe.FormatterTest do + use Absinthe.Case, async: true + + @query """ + { + version + } + """ + test "formats a document" do + assert Absinthe.Formatter.format(@query) == "{\n version\n}\n" + end +end diff --git a/test/absinthe/language/render_test.exs b/test/absinthe/language/render_test.exs index 5f688a7873..93dd71a958 100644 --- a/test/absinthe/language/render_test.exs +++ b/test/absinthe/language/render_test.exs @@ -1,5 +1,5 @@ defmodule Absinthe.Language.RenderTest do - use ExUnit.Case + use ExUnit.Case, async: true describe "renders graphql" do test "for unnamed query" do From ced91e4c729a10351a2c16e1ff755764c583a64e Mon Sep 17 00:00:00 2001 From: Maarten van Vliet Date: Thu, 4 Nov 2021 08:54:44 +0100 Subject: [PATCH 5/5] Separate docs with two newlines --- lib/absinthe/language/render.ex | 2 +- priv/graphql/introspection.graphql | 3 +++ test/absinthe/language/render_test.exs | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/absinthe/language/render.ex b/lib/absinthe/language/render.ex index 6c6f83a2fe..7abe22260e 100644 --- a/lib/absinthe/language/render.ex +++ b/lib/absinthe/language/render.ex @@ -20,7 +20,7 @@ defmodule Absinthe.Language.Render do defp render(bp) defp render(%Absinthe.Language.Document{} = doc) do - doc.definitions |> Enum.map(&render/1) |> join("\n") + doc.definitions |> Enum.map(&render/1) |> join([line(), line()]) end defp render(%Absinthe.Language.OperationDefinition{} = op) do diff --git a/priv/graphql/introspection.graphql b/priv/graphql/introspection.graphql index 0d4b8680d7..3f8b0fd5e9 100644 --- a/priv/graphql/introspection.graphql +++ b/priv/graphql/introspection.graphql @@ -24,6 +24,7 @@ query IntrospectionQuery { } } } + fragment FullType on __Type { kind name @@ -56,6 +57,7 @@ fragment FullType on __Type { ...TypeRef } } + fragment InputValue on __InputValue { name description @@ -64,6 +66,7 @@ fragment InputValue on __InputValue { } defaultValue } + fragment TypeRef on __Type { kind name diff --git a/test/absinthe/language/render_test.exs b/test/absinthe/language/render_test.exs index 93dd71a958..add322c007 100644 --- a/test/absinthe/language/render_test.exs +++ b/test/absinthe/language/render_test.exs @@ -19,11 +19,13 @@ defmodule Absinthe.Language.RenderTest do ...pageFragment } } + fragment userFragment on User @defer { friends { count } } + fragment pageFragment on Page { likers { count