lib/absinthe_streamdata.ex

defmodule Absinthe.StreamData do
  @moduledoc """
  Documentation for `Absinthe.StreamData`.
  """

  require Logger

  def run(schema, doc, opts) do
    pipeline =
      schema
      |> Absinthe.Pipeline.for_document(opts)
      |> Absinthe.Pipeline.without(Absinthe.Phase.Parse)

    {:ok, blueprint, _} = Absinthe.Pipeline.run(doc, pipeline)
    {:ok, blueprint}
  end

  def execution_of(schema, doc, opts) do
    StreamData.constant(run(schema, doc, opts))
  end

  def variables_of(schema, doc, type_mapper \\ {Absinthe.StreamData.DefaultTypeMapper, []}) do
    Absinthe.StreamData.Variables.variables_of(schema, doc, type_mapper)
  end

  def operation_of(schema) do
    Absinthe.Schema.types(schema)
    |> Enum.filter(&(&1.identifier in [:query, :mutation]))
    |> Enum.map(& &1.identifier)
    |> StreamData.member_of()
  end

  def field_of(schema, operation_type) do
    operation_fields = find_type(schema, operation_type)

    operation_fields.fields
    |> Map.keys()
    |> Enum.reject(&(&1 in [:__schema, :__type, :__typename]))
    |> StreamData.member_of()
  end

  def document_of(schema, operation_type, name, opts \\ [max_depth: 5]) do
    opts = Map.new(opts)
    operation_fields = find_type(schema, operation_type)

    {_name, field} =
      Enum.find(operation_fields.fields, fn {op_name, _value} ->
        op_name == name
      end)

    input =
      struct_of(Absinthe.Language.Document, %{
        definitions:
          StreamData.list_of(
            StreamData.bind(
              StreamData.fixed_map(%{
                loc: nil,
                selection_set:
                  struct_of(
                    Absinthe.Language.SelectionSet,
                    %{
                      selections: root_operation(field, schema, opts)
                    }
                  )
              }),
              fn operation_definition ->
                struct_of(
                  Absinthe.Language.OperationDefinition,
                  Map.merge(operation_definition, %{
                    name: StreamData.string(:printable),
                    operation: StreamData.constant(operation_type),
                    variable_definitions:
                      operation_definition.selection_set
                      |> extract_arguments()
                      |> build_variable_definitions(field.type, schema),
                    selection_set:
                      StreamData.constant(
                        operation_definition.selection_set
                        |> normalize_selection_set
                      )
                  })
                )
              end
            ),
            length: 1
          )
      })

    struct_of(Absinthe.Blueprint, %{input: input})
  end

  defp find_type(schema, type) when is_atom(schema) do
    Absinthe.Schema.types(schema) |> Enum.find(fn a -> a.identifier == type end)
  end

  defp root_operation(field, schema, opts) do
    struct_of(
      Absinthe.Language.Field,
      %{
        loc: nil,
        name: StreamData.constant(field.name),
        arguments: build_arguments(field.args, schema),
        selection_set:
          schema
          |> find_type(Absinthe.Type.unwrap(field.type))
          |> build_selection_set(schema, 0, opts)
      }
    )
    |> StreamData.list_of(length: 1)
  end

  defp build_arg_type(%Absinthe.Type.NonNull{of_type: type}, schema) do
    %Absinthe.Language.NonNullType{type: build_arg_type(type, schema)}
  end

  defp build_arg_type(%Absinthe.Type.List{of_type: type}, schema) do
    %Absinthe.Language.ListType{type: build_arg_type(type, schema)}
  end

  defp build_arg_type(%Absinthe.Language.NamedType{name: name}, schema) do
    build_arg_type(name, schema)
  end

  defp build_arg_type(name, schema) do
    %Absinthe.Language.NamedType{
      loc: nil,
      name: find_type(schema, name).name
    }
  end

  defp build_variable_definitions(arguments, _type, schema) do
    arguments
    |> Enum.map(fn {arg, type} ->
      %Absinthe.Language.VariableDefinition{
        loc: nil,
        type: build_arg_type(type, schema),
        variable: %Absinthe.Language.Variable{loc: nil, name: arg.value.name}
      }
    end)
    |> StreamData.constant()
  end

  # the AST is annotated with type information for arguments
  # this needs to be removed before it is a valid absinthe document again
  defp normalize_selection_set(selection_set) do
    {doc, _} =
      Absinthe.StreamData.LanguageTransform.walk(selection_set, nil, fn
        {%Absinthe.Language.Argument{} = node, _type}, _ ->
          {node, nil}

        node, _ ->
          {node, nil}
      end)

    doc
  end

  defp extract_arguments(selection_set) do
    {_, arguments} =
      Absinthe.StreamData.LanguageTransform.walk(selection_set, [], fn node, acc ->
        acc =
          case node do
            {%Absinthe.Language.Argument{} = node, type} ->
              [{node, type} | acc]

            _ ->
              acc
          end

        {node, acc}
      end)

    arguments
  end

  def struct_of(struct, data) do
    data |> StreamData.fixed_map() |> StreamData.map(&struct!(struct, &1))
  end

  defp build_selection_set(%{fields: _} = type, schema, depth, %{max_depth: max_depth})
       when depth >= max_depth do
    struct_of(Absinthe.Language.SelectionSet, %{
      loc: StreamData.constant(nil),
      selections: []
    })
  end

  defp build_selection_set(%{fields: _} = type, schema, depth, opts) do
    struct_of(Absinthe.Language.SelectionSet, %{
      loc: StreamData.constant(nil),
      selections: build_selections(type, schema, depth, opts)
    })
  end

  defp build_selection_set(_t, _schema, _, _) do
    StreamData.constant(nil)
  end

  # can do complexity analysis here to limit list length.
  # E.g. # field (recursive) * args * directives * # fields * constant
  defp complexity(%Absinthe.Type.Object{} = type, depth) do
    Enum.reduce(type.fields, 0, fn {_, field}, acc -> complexity(field, depth) + acc end)
  end

  defp complexity(%Absinthe.Type.Interface{} = type, depth) do
    1
  end

  defp complexity(%{args: args}, _depth) do
    max(Enum.count(args), 1)
  end

  defp complexity(_type, _depth) do
    1
  end

  defp build_selections(%Absinthe.Type.Union{} = type, schema, depth, opts) do
    type.types
    |> Enum.map(fn type ->
      type = find_type(schema, type)

      struct_of(Absinthe.Language.InlineFragment, %{
        type_condition:
          StreamData.constant(%Absinthe.Language.NamedType{loc: nil, name: type.name}),
        loc: StreamData.constant(nil),
        selection_set: build_selection_set(type, schema, depth + 1, opts)
      })
    end)
    |> StreamData.one_of()
    |> StreamData.resize(Enum.count(type.types))
    |> StreamData.list_of(min_length: 1)
  end

  defp build_selections(type, schema, depth, opts) do
    max_length = complexity(type, depth)

    type.fields
    |> Enum.map(fn {_n, field} ->
      struct_of(Absinthe.Language.Field, %{
        alias: StreamData.constant(nil),
        arguments: build_arguments(field.args, schema),
        directives: StreamData.constant([]),
        loc: StreamData.constant(nil),
        name: StreamData.constant(field.name),
        selection_set:
          find_type(schema, Absinthe.Type.unwrap(field.type))
          |> build_selection_set(schema, depth + 1, opts)
      })
    end)
    |> StreamData.one_of()
    |> StreamData.resize(Enum.count(type.fields))
    |> StreamData.uniq_list_of(min_length: 1, max_length: max_length, max_tries: max_length * 10)
  end

  defp build_arguments(args, _schema) when args == %{} do
    StreamData.constant([])
  end

  defp build_arguments(args, _schema) do
    {required, optional} =
      args
      |> Enum.split_with(&match?(%{type: %Absinthe.Type.NonNull{}}, elem(&1, 1)))

    required =
      required
      |> Enum.map(fn {_, arg} ->
        build_argument(arg)
      end)
      |> StreamData.fixed_list()

    num_optional_args = Enum.count(optional)

    optional =
      if num_optional_args == 0 do
        StreamData.constant([])
      else
        optional
        |> Enum.map(fn {_, arg} ->
          build_argument(arg)
        end)
        |> StreamData.one_of()
        |> StreamData.uniq_list_of(
          uniq_fun: fn {arg, _} -> arg.name end,
          min_length: 0,
          max_tries: num_optional_args * 10,
          max_length: num_optional_args
        )
      end

    StreamData.bind(required, fn required ->
      StreamData.bind(optional, fn optional ->
        StreamData.constant(required ++ optional)
      end)
    end)
  end

  # it returns a tuple, with the first element the argument
  # and the second its type since we need that at a later stage
  def build_argument(arg) do
    StreamData.tuple({
      struct_of(Absinthe.Language.Argument, %{
        loc: nil,
        name: StreamData.constant(arg.name),
        value: struct_of(Absinthe.Language.Variable, %{loc: nil, name: var_name(arg.name)})
      }),
      StreamData.constant(arg.type)
    })
  end

  def var_name(name) do
    StreamData.map(
      {StreamData.unshrinkable(StreamData.positive_integer()),
       StreamData.string(:alphanumeric, min_length: 5)},
      fn {i, r} ->
        "var_#{name}_#{i}_#{r}"
      end
    )
  end
end