lib/edgedb/state.ex

defmodule EdgeDB.State do
  @moduledoc """
  State is an execution context that affects the execution of EdgeQL commands in different ways:

    1. default module.
    2. module aliases.
    3. session config.
    4. global values.

  The most convenient way to work with the state is to use the `EdgeDB` API to change a required part of
    the state.

  See `EdgeDB.with_state/2`, `EdgeDB.with_default_module/2`,
    `EdgeDB.with_module_aliases/2`/`EdgeDB.without_module_aliases/2`,
    `EdgeDB.with_config/2`/`EdgeDB.without_config/2` and
    `EdgeDB.with_globals/2`/`EdgeDB.without_globals/2` for more information.
  """

  @default_module "default"

  defstruct module: @default_module,
            aliases: %{},
            config: %{},
            globals: %{}

  @typedoc """
  State is an execution context that affects the execution of EdgeQL commands.
  """
  @opaque t() :: %__MODULE__{
            module: String.t(),
            aliases: %{String.t() => String.t()},
            config: %{String.t() => term()},
            globals: %{String.t() => term()}
          }

  @doc """
  Returns an `EdgeDB.State` with adjusted default module.

  This is equivalent to using the `set module` command,
    or using the `reset module` command when giving `nil`.
  """
  @spec with_default_module(t(), String.t() | nil) :: t()
  def with_default_module(%__MODULE__{} = state, module \\ nil) do
    %__MODULE__{state | module: module}
  end

  @doc """
  Returns an `EdgeDB.State` with adjusted module aliases.

  This is equivalent to using the `set alias` command.
  """
  @spec with_module_aliases(t(), %{String.t() => String.t()}) :: t()
  def with_module_aliases(%__MODULE__{} = state, aliases \\ %{}) do
    %__MODULE__{state | aliases: Map.merge(state.aliases, aliases)}
  end

  @doc """
  Returns an `EdgeDB.State` without specified module aliases.

  This is equivalent to using the `reset alias` command.
  """
  @spec without_module_aliases(t(), list(String.t())) :: t()
  def without_module_aliases(%__MODULE__{} = state, aliases \\ []) do
    new_aliases =
      case aliases do
        [] ->
          %{}

        aliases ->
          Enum.reduce(aliases, state.aliases, &Map.delete(&2, &1))
      end

    %__MODULE__{state | aliases: new_aliases}
  end

  @doc """
  Returns an `EdgeDB.State` with adjusted session config.

  This is equivalent to using the `configure session set` command.
  """
  @spec with_config(t(), %{atom() => term()}) :: t()
  def with_config(%__MODULE__{} = state, config \\ %{}) do
    %__MODULE__{state | config: Map.merge(state.config, config)}
  end

  @doc """
  Returns an `EdgeDB.State` without specified session config.

  This is equivalent to using the `configure session reset` command.
  """
  @spec without_config(t(), list(atom())) :: t()
  def without_config(%__MODULE__{} = state, config_keys \\ []) do
    new_config =
      case config_keys do
        [] ->
          %{}

        config_keys ->
          Enum.reduce(config_keys, state.config, &Map.delete(&2, &1))
      end

    %__MODULE__{state | config: new_config}
  end

  @doc """
  Returns an `EdgeDB.State` with adjusted global values.

  This is equivalent to using the `set global` command.
  """
  @spec with_globals(t(), %{String.t() => String.t()}) :: t()
  def with_globals(%__MODULE__{} = state, globals \\ %{}) do
    module = state.module || @default_module

    globals =
      state.globals
      |> Map.merge(globals)
      |> Enum.into(%{}, fn {global, value} ->
        {resolve_name(state.aliases, module, global), value}
      end)

    %__MODULE__{state | globals: globals}
  end

  @doc """
  Returns an `EdgeDB.State` without specified globals.

  This is equivalent to using the `reset global` command.
  """
  @spec without_globals(t(), list(String.t())) :: t()
  def without_globals(%__MODULE__{} = state, global_names \\ []) do
    module = state.module || @default_module

    new_globals =
      case global_names do
        [] ->
          %{}

        global_names ->
          Enum.reduce(
            global_names,
            state.config,
            &Map.delete(&2, resolve_name(state.aliases, module, &1))
          )
      end

    %__MODULE__{state | globals: new_globals}
  end

  @doc false
  @spec to_encodable(t()) :: map()
  def to_encodable(%__MODULE__{} = state) do
    state
    |> Map.from_struct()
    |> stringify_map_keys()
    |> Enum.reduce(%{}, fn
      {"module", nil}, acc ->
        acc

      {"aliases", aliases}, acc when map_size(aliases) == 0 ->
        acc

      {"aliases", aliases}, acc ->
        Map.put(acc, "aliases", Map.to_list(aliases))

      {"config", config}, acc when map_size(config) == 0 ->
        acc

      {"globals", config}, acc when map_size(config) == 0 ->
        acc

      {key, value}, acc ->
        Map.put(acc, key, value)
    end)
  end

  defp resolve_name(aliases, module_name, global_name) do
    case String.split(global_name, "::") do
      [global_name] ->
        "#{module_name}::#{global_name}"

      [module_name, global_name] ->
        module_name = aliases[module_name] || module_name
        "#{module_name}::#{global_name}"

      _other ->
        raise EdgeDB.InvalidArgumentError.new("invalid global name: #{inspect(global_name)}")
    end
  end

  defp stringify_map_keys(%{} = map) when not is_struct(map) do
    Enum.into(map, %{}, fn
      {key, value} when is_binary(key) ->
        {key, stringify_map_keys(value)}

      {key, value} when is_atom(key) ->
        {to_string(key), stringify_map_keys(value)}
    end)
  end

  defp stringify_map_keys(list) when is_list(list) do
    Enum.map(list, &stringify_map_keys/1)
  end

  defp stringify_map_keys(term) do
    term
  end
end