lib/adbc_database.ex

defmodule Adbc.Database do
  @moduledoc """
  Documentation for `Adbc.Database`.

  Databases are modelled as processes. They required
  a driver to be started.
  """

  use GenServer
  import Adbc.Helper, only: [error_to_exception: 1]

  @doc """
  Starts a database process.

  ## Options

    * `:driver` (required) - the driver to use for this database.
      It must be an atom (see `Adbc` module documentation for all
      built-in drivers) or a string representing the path to a driver

    * `:process_options` - the options to be given to the underlying
      process. See `GenServer.start_link/3` for all options

  All other options are given as database options to the underlying driver.

  ## Examples

      Adbc.Database.start_link(
        driver: :sqlite,
        process_options: [name: MyApp.DB]
      )

  In your supervision tree it would be started like this:

      children = [
        {Adbc.Database,
         driver: :sqlite,
         process_options: [name: MyApp.DB]},
      ]

  """
  def start_link(opts \\ []) do
    {driver, opts} = Keyword.pop(opts, :driver, nil)

    unless driver do
      raise ArgumentError, ":driver option must be specified"
    end

    {process_options, opts} = Keyword.pop(opts, :process_options, [])
    opts = Keyword.merge(driver_default_options(driver), opts)

    with {:ok, ref} <- Adbc.Nif.adbc_database_new(),
         :ok <- init_driver(ref, driver),
         :ok <- init_options(ref, opts),
         :ok <- Adbc.Nif.adbc_database_init(ref) do
      GenServer.start_link(__MODULE__, {driver, ref}, process_options)
    else
      {:error, reason} -> {:error, error_to_exception(reason)}
    end
  end

  @doc """
  Get a string type option of the database.
  """
  @spec get_string_option(pid(), atom() | String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def get_string_option(db, key) when is_pid(db) do
    Adbc.Helper.option(db, :adbc_database_get_option, [:string, to_string(key)])
  end

  @doc """
  Get a binary (bytes) type option of the database.
  """
  @spec get_binary_option(pid(), atom() | String.t()) :: {:ok, binary()} | {:error, String.t()}
  def get_binary_option(db, key) when is_pid(db) do
    Adbc.Helper.option(db, :adbc_database_get_option, [:binary, to_string(key)])
  end

  @doc """
  Get an integer type option of the database.
  """
  @spec get_integer_option(pid(), atom() | String.t()) :: {:ok, integer()} | {:error, String.t()}
  def get_integer_option(db, key) when is_pid(db) do
    Adbc.Helper.option(db, :adbc_database_get_option, [:integer, to_string(key)])
  end

  @doc """
  Get a float type option of the database.
  """
  @spec get_float_option(pid(), atom() | String.t()) :: {:ok, float()} | {:error, String.t()}
  def get_float_option(db, key) when is_pid(db) do
    Adbc.Helper.option(db, :adbc_database_get_option, [:float, to_string(key)])
  end

  @doc """
  Set option for the database.

  - If `value` is an atom or a string, then corresponding string option will be set.
  - If `value` is a `{:binary, iodata()}`-tuple, then corresponding binary option will be set.
  - If `value` is an integer, then corresponding integer option will be set.
  - If `value` is a float, then corresponding float option will be set.
  """
  @spec set_option(
          pid(),
          atom() | String.t(),
          atom() | {:binary, iodata()} | String.t() | number()
        ) ::
          :ok | {:error, String.t()}
  def set_option(db, key, value)

  def set_option(db, key, value) when is_pid(db) and (is_atom(value) or is_binary(value)) do
    Adbc.Helper.option(db, :adbc_database_set_option, [:string, key, value])
  end

  def set_option(db, key, {:binary, value}) when is_pid(db) do
    Adbc.Helper.option(db, :adbc_database_set_option, [:binary, key, value])
  end

  def set_option(db, key, value) when is_pid(db) and is_integer(value) do
    Adbc.Helper.option(db, :adbc_database_set_option, [:integer, key, value])
  end

  def set_option(db, key, value) when is_pid(db) and is_float(value) do
    Adbc.Helper.option(db, :adbc_database_set_option, [:float, key, value])
  end

  defp driver_default_options(:duckdb), do: [entrypoint: "duckdb_adbc_init"]
  defp driver_default_options(_), do: []

  ## Callbacks

  @impl true
  def init({driver, db}) do
    Process.flag(:trap_exit, true)
    {:ok, {driver, db}}
  end

  @impl true
  def handle_call({:initialize_connection, conn_ref}, {pid, _}, {driver, db}) do
    case Adbc.Nif.adbc_connection_init(conn_ref, db) do
      :ok ->
        Process.link(pid)
        {:reply, {:ok, driver}, {driver, db}}

      {:error, reason} ->
        {:reply, {:error, reason}, {driver, db}}
    end
  end

  def handle_call({:option, func, args}, _from, {driver, db}) do
    {:reply, Adbc.Helper.option(db, func, args), {driver, db}}
  end

  @impl true
  def handle_info(_msg, state), do: {:noreply, state}

  defp init_driver(ref, driver) do
    case Adbc.Driver.so_path(driver) do
      {:ok, path} -> Adbc.Helper.option(ref, :adbc_database_set_option, [:string, "driver", path])
      {:error, reason} -> {:error, reason}
    end
  end

  defp init_options(ref, opts) do
    Enum.reduce_while(opts, :ok, fn
      {key, value}, :ok when is_binary(value) or is_atom(value) ->
        Adbc.Helper.option_ok_or_halt(ref, :adbc_database_set_option, [:string, key, value])

      {key, {:binary, value}}, :ok ->
        Adbc.Helper.option_ok_or_halt(ref, :adbc_database_set_option, [:binary, key, value])

      {key, value}, :ok when is_integer(value) ->
        Adbc.Helper.option_ok_or_halt(ref, :adbc_database_set_option, [:integer, key, value])

      {key, value}, :ok when is_float(value) ->
        Adbc.Helper.option_ok_or_halt(ref, :adbc_database_set_option, [:float, key, value])
    end)
  end
end