lib/graphql_document.ex

defmodule GraphQLDocument do
  @moduledoc """
  A utility for building GraphQL documents. (Strings that contain a GraphQL query/mutation.)

  ## Syntax

  `GraphQLDocument.to_string` converts nested lists/keyword lists into the analogous
  GraphQL syntax.

  Simply write lists and keyword lists "as they look in GraphQL".

  Let's take a look at some examples.

  ### Object Fields

  To request a list of fields in an object, include them in a list:

  ```elixir
  [query: [
    human: [:name, :height]
  ]]
  ```

  `GraphQLDocument.to_string/1` will take that Elixir structure and return

  ```elixir
  \"\"\"
  query {
    human {
      name
      height
    }
  }
  \"\"\"
  ```

  ### Arguments

  When a field includes arguments, wrap the arguments and child fields in a
  tuple, like this:

  ```elixir
  {args, fields}
  ```

  For example, `GraphQLDocument.to_string/1` will take this Elixir structure

  ```elixir
  [query: [
    human: {
      [id: "1000"],
      [:name, :height]
    }
  ]]
  ```

  and return this GraphQL document:

  ```elixir
  \"\"\"
  query {
    human(id: "1000") {
      name
      height
    }
  }
  \"\"\"
  ```

  #### Argument types and Enums

  `GraphQLDocument.to_string/1` will translate Elixir primitives into the
  analogous GraphQL primitive type for arguments.

  GraphQL enums can be expressed as an atom (e.g. `FOOT`) or in a tuple
  syntax, `{:enum, "FOOT"}`.

  For example:

  ```elixir
  [query: [
    human: {
      [id: "1000"],
      [:name, height: {[unit: FOOT], []}]
    }
  ]]
  ```

  becomes

  ```elixir
  query {
    human(id: "1000") {
      name
      height(unit: FOOT)
    }
  }
  ```

  We can specify `[unit: FOOT]` as `[unit: {:enum, "FOOT"}]`, which
  is useful for interpolating dynamic values into the query.

  > #### Expressing arguments without sub-fields {: .tip}
  >
  > Notice the slightly complicated syntax above: `height: {[unit: FOOT], []}`
  >
  > The way to include arguments is in an `{args, fields}` tuple. So if a
  > field has arguments but no sub-fields, put `[]` where the sub-fields go.

  ### Nesting Fields

  Since GraphQL supports a theoretically infinite amount of nesting, you can also
  nest as much as needed in the Elixir structure.

  Furthermore, we can take advantage of Elixir's syntax feature that allows a
  regular list to be "mixed" with a keyword list:

  ```elixir
  # Elixir allows lists with a Keyword List as the final members
  [:name, :height, friends: [:name, :age]]
  ```

  Using this syntax, we can build a nested structure like this:

  ```elixir
  [query: [
    human: {
      [id: "1000"],
      [
        :name,
        :height,
        friends: {
          [olderThan: 30],
          [:name, :height]
        }
      ]
    }
  ]]
  ```

  ```elixir
  query {
    human(id: "1000") {
      name
      height
      friends(olderThan: 30) {
        name
        height
      }
    }
  }
  ```

  ### Aliases

  In order to name a field with an alias, follow the syntax below, where `me`
  is the alias and `user` is the field:

  ```elixir
  [query: [
    me: {
      :user
      [id: 100],
      [:name, :email]
    }
  ]]
  ```

  Which will emit this GraphQL document:

  ```elixir
  query {
    me: user(id: 100) {
      name
      email
    }
  }
  ```
  """

  @doc """
  Wraps an enum string value (such as user input from a form) into a
  `GraphQLDocument`-friendly tuple.

  ### Example

      iex> GraphQLDocument.enum("soundex")
      {:enum, "soundex"}

  """
  def enum(str) when is_binary(str), do: {:enum, str}

  @doc """
  Generates GraphQL syntax from a nested Elixir keyword list.

  ### Example

      iex> GraphQLDocument.to_string(query: [user: {[id: 3], [:name, :age, :height, documents: [:filename, :url]]}])
      \"\"\"
      query {
        user(id: 3) {
          name
          age
          height
          documents {
            filename
            url
          }
        }
      }\\
      \"\"\"

  """
  def to_string(params, indent_level \\ 0)

  def to_string(params, indent_level) when is_list(params) or is_map(params) do
    indent = String.duplicate("  ", indent_level)

    params
    |> Enum.map_join("\n", fn
      field when is_binary(field) or is_atom(field) ->
        "#{indent}#{field}"

      {field, sub_fields} when is_list(sub_fields) ->
        "#{indent}#{field}#{sub_fields(sub_fields, indent, indent_level)}"

      {field, {args, sub_fields}} ->
        "#{indent}#{field}#{args(args)}#{sub_fields(sub_fields, indent, indent_level)}"

      {field_alias, {field, args, sub_fields}} when is_map(args) or is_list(args) ->
        "#{indent}#{field_alias}: #{field}#{args(args)}#{sub_fields(sub_fields, indent, indent_level)}"
    end)
  end

  def to_string(params, _indent_level) do
    raise ArgumentError,
      message: """
      [GraphQLDocument] Expected a list of fields.

      Received: `#{inspect(params)}`
      Did you forget to enclose it in a list?
      """
  end

  defp args(args) do
    unless is_map(args) or is_list(args) do
      raise "Expected a keyword list or map for args, received: #{inspect(args)}"
    end

    if Enum.any?(args) do
      args_string =
        Enum.map_join(args, ", ", fn {key, value} ->
          "#{key}: #{argument(value)}"
        end)

      "(#{args_string})"
    else
      ""
    end
  end

  defp sub_fields(sub_fields, indent, indent_level) do
    if Enum.any?(sub_fields) do
      " {\n#{to_string(sub_fields, indent_level + 1)}\n#{indent}}"
    else
      ""
    end
  end

  defp argument(%Date{} = date), do: inspect(Date.to_iso8601(date))
  defp argument(%DateTime{} = date_time), do: inspect(DateTime.to_iso8601(date_time))
  defp argument(%Time{} = time), do: inspect(Time.to_iso8601(time))

  defp argument(%NaiveDateTime{} = naive_date_time),
    do: inspect(NaiveDateTime.to_iso8601(naive_date_time))

  if Code.ensure_loaded?(Decimal) do
    defp argument(%Decimal{} = decimal), do: Decimal.to_string(decimal)
  end

  defp argument({:enum, enum}) when is_binary(enum) do
    if valid_name?(enum) do
      enum
    else
      raise ArgumentError,
        message:
          "[GraphQLDocument] Enums must be a valid GraphQL name, matching this regex: /[_A-Za-z][_0-9A-Za-z]*/"
    end
  end

  defp argument([]), do: "[]"

  defp argument(enum) when is_list(enum) or is_map(enum) do
    if is_map(enum) || Keyword.keyword?(enum) do
      nested_arguments =
        enum
        |> Enum.map_join(", ", fn {key, value} -> "#{key}: #{argument(value)}" end)

      "{#{nested_arguments}}"
    else
      nested_arguments =
        enum
        |> Enum.map_join(", ", &"#{argument(&1)}")

      "[#{nested_arguments}]"
    end
  end

  defp argument(value) when is_binary(value) do
    inspect(value, printable_limit: :infinity)
  end

  defp argument(value) when is_number(value) do
    inspect(value, printable_limit: :infinity)
  end

  defp argument(value) when is_boolean(value) do
    inspect(value)
  end

  defp argument(value) when is_atom(value) do
    raise ArgumentError,
      message: "[GraphQLDocument] Cannot pass an atom as an argument; received `#{value}`"
  end

  # A GraphQL "Name" matches the following regex.
  # See: http://spec.graphql.org/June2018/#sec-Names
  defp valid_name?(name) when is_binary(name) do
    String.match?(name, ~r/^[_A-Za-z][_0-9A-Za-z]*$/)
  end
end