lib/graphql_document/field.ex

defmodule GraphQLDocument.Field do
  @moduledoc """
  > A [Field](http://spec.graphql.org/October2021/#Field) describes one discrete
  piece of information available to request within a selection set.

  Scalar fields (e.g. Ints, Booleans, and Strings) are requested by their name.

      :name
      :email

  Whereas object fields must be given with child fields as well.

      [
        user: [:name, email]
      ]

  """

  defstruct [
    :name,
    as: nil,
    args: [],
    directives: [],
    select: []
  ]

  alias __MODULE__
  alias GraphQLDocument.{Argument, Directive, Name, Selection}

  @typedoc """
  Fields can be expressed a number of different ways.

  If the field is a scalar type, provide a `String` or `Atom`:

      [:id, :firstName, :lastName]

  If the field is an object type, express it as a keyword list with either
  `[fields]` or `{args, fields}` as the value of each key:

      [
        friends: {[first: 10], [:name]}, # with args
        birthday: [:month, :day], # with sub-fields, but no args
      ]

  See `render/2` for examples of how different Elixir expressions are rendered
  as GraphQL syntax.
  """
  @type t :: Name.t() | {Name.t(), [t]} | {Name.t(), {[Argument.t()], [t]}} | spec

  @typedoc """
  An expression of a field with an alias or directives.
  """
  @type spec ::
          {:field,
           [
             as: atom,
             args: [Argument.t()],
             directives: [Directive.t()],
             select: [Selection.t()]
           ]}

  @typedoc """
           A struct containing all of the aspects of a Field expression.

           Instead of creating `%Field{}` structs manually, use the
           `GraphQLDocument.field/1` function or provide a structure from `t:t/0`.
           """ && false
  @type field_struct :: %Field{
          as: atom,
          name: atom,
          args: [Argument.t()],
          directives: [Directive.t()],
          select: [Selection.t()]
        }

  @doc """
  Allows you to specify a complex Field expression with Arguments, Directives,
  Selections, and an Alias.
  """
  def new(name) when is_binary(name) or is_atom(name) do
    struct!(Field, %{
      name: Name.render!(name)
    })
  end

  def new({name, selections}) when is_map(selections) or is_list(selections) do
    struct!(Field, %{
      name: Name.render!(name),
      select: selections
    })
  end

  def new({name, {args, selections}})
      when (is_map(args) or is_list(args)) and (is_map(selections) or is_list(selections)) do
    struct!(Field, %{
      name: Name.render!(name),
      args: args,
      select: selections
    })
  end

  def new({name, {:field, config}}) do
    config =
      config
      |> Enum.into([])
      |> Keyword.merge(name: name)

    struct!(Field, config)
  end

  def new({as, {:field, name, config}}) do
    config =
      config
      |> Enum.into([])
      |> Keyword.merge(name: name, as: as)

    struct!(Field, config)
  end

  def new(field) do
    raise ArgumentError, message: "Expected a field; received #{inspect(field)}"
  end

  @doc ~S'''
  Returns a Field as iodata to be inserted into a Document.

  ### Examples

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

      iex> render({:self, [:name, :email]}, 1)
      ...> |> IO.iodata_to_binary()
      """
      self {
        name
        email
      }\
      """

      iex> {:user, {:field,
      ...>   args: [id: 100],
      ...>   select: [:name, :email]
      ...> }}
      ...> |> render(1)
      ...> |> IO.iodata_to_binary()
      """
      user(id: 100) {
        name
        email
      }\
      """

      iex> {:user, {:field,
      ...>   args: [id: 100],
      ...>   directives: [log: [level: "warn"]],
      ...>   select: [:name, :email]
      ...> }}
      ...> |> render(1)
      ...> |> IO.iodata_to_binary()
      """
      user(id: 100) @log(level: "warn") {
        name
        email
      }\
      """

      iex> {:me, {:field,
      ...>   :user,
      ...>   args: [id: 100],
      ...>   directives: [log: [level: "warn"]],
      ...>   select: [:name, :email]
      ...> }}
      ...> |> render(1)
      ...> |> IO.iodata_to_binary()
      """
      me: user(id: 100) @log(level: "warn") {
        name
        email
      }\
      """

  '''
  @spec render(t, pos_integer) :: iolist
  def render(field, indent_level) do
    # immediately coerce the expression into a `%Field{}` struct to standardize rendering below
    field = Field.new(field)

    [
      if field_alias = field.as do
        [
          Name.render!(field_alias),
          ?:,
          ?\s
        ]
      else
        []
      end,
      Name.render!(field.name),
      Argument.render(field.args),
      Directive.render(field.directives),
      Selection.render(field.select, indent_level)
    ]
  end
end