lib/graphql_document/fragment.ex

defmodule GraphQLDocument.Fragment do
  @moduledoc """
  > [Fragments](http://spec.graphql.org/October2021/#sec-Language.Fragments)
  > are the primary unit of composition in GraphQL.
  >
  > Fragments allow for the reuse of common repeated selections of fields,
  > reducing duplicated text in the document. Inline Fragments can be used
  > directly within a selection to condition upon a type condition when querying
  > against an interface or union.

  See `render_definitions/1` for details about rendering
  [FragmentDefinitions](http://spec.graphql.org/October2021/#FragmentDefinition).

  See `render/2` for details about rendering a
  [FragmentSpread](http://spec.graphql.org/October2021/#FragmentSpread) or
  [InlineFragment](http://spec.graphql.org/October2021/#sec-Inline-Fragments).
  """
  alias GraphQLDocument.{Directive, Name, Selection}

  @type t :: {:..., spread | inline}

  @typedoc """
  These are given in the `fragments` key of the Operation options. (See
  `t:GraphQLDocument.Operation.option/0`.)

  This is not the _usage_ of the Fragment (in a Selection set) but
  rather defining to be used in the rest of the Document.

  ### Examples

      GraphQLDocument.query(
        [...],
        fragments: [
          friendFields: {
            on(User),
            [
              :id,
              :name,
              profilePic: field(args: [size: 50])
            ]
          }
        ]
      )

  """
  @type definition ::
          {name,
           {type_condition, [Selection.t()]}
           | {type_condition, [Directive.t()], [Selection.t()]}}

  @typedoc """
  The name of a fragment. An atom or string.
  """
  @type name :: Name.t()

  @typedoc """
  The type to which the fragment applies.

  `{:on, Person}` is rendered as `"on Person"`.

  Instead of using `{:on, type}` tuples directly, you can use `GraphQLDocument.on/1`:

      iex> import GraphQLDocument
      iex> on(Person)
      {:on, Person}

  """
  @type type_condition :: {:on, Name.t()}

  @typedoc """
  A fragment spread is injecting `...fragmentName` into a request to instruct
  the server to return the fields in the fragment.

  Fragment spreads are expressed with the `:...` atom to match GraphQL syntax.
  They are often inserted among other fields as in `...: :friendFields` below.

      GraphQLDocument.query(
        [
          self: [
            :name,
            :email,
            ...: :friendFields
          ]
        ],
        fragments: [
          friendFields: {...}
        ]
      )
  """
  @type spread :: {:..., name} | {:..., {name, [Directive.t()]}}

  @typedoc """
  An [inline fragment](http://spec.graphql.org/October2021/#sec-Inline-Fragments)
  is a fragment that doesn't have a definition; the definition appears right in
  the middle of a selection set in the query/mutation/subscription.

  It exists to support requesting certain fields only if the object is a
  certain type (for objects of a union type), or to apply directives to only a
  subset of fields.
  """
  @type inline ::
          [Selection.t()]
          | {[Directive.t()], [Selection.t()]}
          | {type_condition, [Selection.t()]}
          | {type_condition, [Directive.t()], [Selection.t()]}

  @doc ~S'''
  Returns a
  [FragmentSpread](http://spec.graphql.org/October2021/#FragmentSpread) or
  [InlineFragment](http://spec.graphql.org/October2021/#sec-Inline-Fragments)
  as an iolist to be inserted in a Document.

  ## Fragment Spreads

  To express a Fragment Spread, provide the name of the fragment as an atom or string.
  If there are directives, provide a `{name, directives}` tuple.

  ### Examples

      iex> render(:friendFields, 1)
      ...> |> IO.iodata_to_binary()
      "...friendFields"

      iex> render({:friendFields, [skip: [if: {:var, :antisocial}]]}, 1)
      ...> |> IO.iodata_to_binary()
      "...friendFields @skip(if: $antisocial)"

  ## Inline Fragments

  To express an Inline Fragment, provide an `{{:on, Type}, selections}` tuple.
  If there are directives, provide `{{:on, Type}, directives, selections}`.

  The `{:on, Type}` syntax can be substituted with `GraphQLDocument.on/1`:

      on(Type)

  ### Examples

      iex> render(
      ...>   {
      ...>     :friendFields,
      ...>     [include: [if: {:var, :expanded}]]
      ...>   },
      ...>   1
      ...> )
      ...> |> IO.iodata_to_binary()
      "...friendFields @include(if: $expanded)"

      iex> render([:foo, :bar], 1)
      ...> |> IO.iodata_to_binary()
      """
      ... {
        foo
        bar
      }\
      """

      iex> render({[log: [level: "warn"]], [:foo, :bar]}, 1)
      ...> |> IO.iodata_to_binary()
      """
      ... @log(level: "warn") {
        foo
        bar
      }\
      """

      iex> render(
      ...>   {
      ...>     {:on, Person},
      ...>     [:name, friends: [:name, :city]]
      ...>   },
      ...>   1
      ...> )
      ...> |> IO.iodata_to_binary()
      """
      ... on Person {
        name
        friends {
          name
          city
        }
      }\
      """

      iex> render(
      ...>   {
      ...>     {:on, Person},
      ...>     [:log],
      ...>     [:name, friends: [:name, :city]]
      ...>   },
      ...>   1
      ...> )
      ...> |> IO.iodata_to_binary()
      """
      ... on Person @log {
        name
        friends {
          name
          city
        }
      }\
      """

  '''
  @spec render(t, non_neg_integer) :: iolist
  def render(fragment, indent_level) do
    case fragment do
      name when is_binary(name) or is_atom(name) ->
        render_spread(name)

      {name, directives} when (is_binary(name) or is_atom(name)) and is_list(directives) ->
        render_spread(name, directives)

      selection when is_list(selection) ->
        render_inline(nil, [], selection, indent_level)

      {directives, selection} when is_list(directives) and is_list(selection) ->
        render_inline(nil, directives, selection, indent_level)

      {{:on, on}, selection} when (is_atom(on) or is_atom(on)) and is_list(selection) ->
        render_inline(on, [], selection, indent_level)

      {{:on, on}, directives, selection}
      when (is_atom(on) or is_atom(on)) and is_list(directives) and is_list(selection) ->
        render_inline(on, directives, selection, indent_level)
    end
  end

  @doc ~S'''
  Returns the given fragment definitions as iodata to be rendered in a GraphQL document.

  ### Examples

      iex> render_definitions(friendFields: {
      ...>   {:on, User},
      ...>   [:id, :name, profilePic: {[size: 50], []}]
      ...> })
      ...> |> IO.iodata_to_binary()
      """
      \n\nfragment friendFields on User {
        id
        name
        profilePic(size: 50)
      }\
      """

  '''
  @spec render_definitions([definition]) :: iolist
  def render_definitions(fragments) do
    unless is_map(fragments) or is_list(fragments) do
      raise "Expected a keyword list or map for fragments, received: #{inspect(fragments)}"
    end

    for {name, definition} <- fragments do
      {on, directives, selection} =
        case definition do
          {{:on, on}, selection} -> {on, [], selection}
          {{:on, on}, directives, selection} -> {on, directives, selection}
        end

      [
        "\n\nfragment ",
        Name.render!(name),
        " on ",
        Name.render!(on),
        Directive.render(directives),
        Selection.render(selection, 1)
      ]
    end
  end

  @spec render_spread(Name.t(), [Directive.t()]) :: iolist
  defp render_spread(name, directives \\ []) do
    [
      "...",
      Name.render!(name),
      Directive.render(directives)
    ]
  end

  @spec render_inline(Name.t() | nil, [Directive.t()], [Selection.t()], integer) :: iolist
  defp render_inline(on, directives, selection, indent_level) when indent_level > 0 do
    [
      "...",
      if on do
        [" on ", Name.render!(on)]
      else
        []
      end,
      Directive.render(directives),
      Selection.render(selection, indent_level)
    ]
  end
end