lib/snowpack.ex

defmodule Snowpack do
  @external_resource "README.md"

  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  # The amount of time in milliseconds between heartbeats
  # that will be sent to the server (default: 5 min)
  @default_session_keepalive :timer.minutes(5)

  @type conn() :: DBConnection.conn()

  @type snowflake_conn_option() ::
          {:dsn, String.t()}
          | {:driver, String.t()}
          | {:server, String.t()}
          | {:role, String.t()}
          | {:warehouse, String.t()}
          | {:database, String.t()}
          | {:schema, String.t()}
          | {:uid, String.t()}
          | {:pwd, String.t()}
          | {:authenticator, String.t()}
          | {:token, String.t()}
          | {:priv_key_file, String.t()}
          | {:priv_key_file_pwd, String.t()}

  @type start_option() ::
          {:connection, snowflake_conn_option()}
          | DBConnection.start_option()

  @type option() :: DBConnection.option()

  @type query_option() ::
          {:parse_results, boolean()}
          | DBConnection.option()

  defmacrop is_iodata(data) do
    quote do
      is_list(unquote(data)) or is_binary(unquote(data))
    end
  end

  @doc """
  Returns a supervisor child specification for a DBConnection pool.
  """
  @spec child_spec([start_option()]) :: :supervisor.child_spec()
  def child_spec(opts) do
    DBConnection.child_spec(Snowpack.Protocol, opts)
  end

  @doc """
  Starts the connection process and connects to Snowflake.

  ## Options

  ### Snowflake Connection Options

  See: https://docs.snowflake.com/en/user-guide/odbc-parameters.html#required-connection-parameters

  * `:connection`

    * `:dsn` - Specifies the name of your DSN

    * `:driver` - Snowflake ODBC driver path

    * `:server` - Specifies the hostname for your account

    * `:role` - Specifies the default role to use for sessions initiated by the driver

    * `:warehouse` - Specifies the default warehouse to use for sessions initiated by the driver

    * `:database` - Specifies the default database to use for sessions initiated by the driver

    * `:schema` - Specifies the default schema to use for sessions initiated by the driver (default: `public`)

    * `:uid` - Specifies the login name of the Snowflake user to authenticate

    * `:pwd` - A password is required to connect to Snowflake

    * `:authenticator` - Specifies the authenticator to use for verifying user login credentials

    * `:token` - Specifies the token to use for token based authentication

    * `:priv_key_file` - Specifies the local path to the private key file

  ### DBConnection Options

  The given options are passed down to DBConnection, some of the most commonly used ones are
  documented below:

    * `:after_connect` - A function to run after the connection has been established, either a
      1-arity fun, a `{module, function, args}` tuple, or `nil` (default: `nil`)

    * `:pool` - The pool module to use, defaults to built-in pool provided by DBconnection

    * `:pool_size` - The size of the pool

  See `DBConnection.start_link/2` for more information and a full list of available options.

  ## Examples

  Start connection using basic User / Pass configuration:

      iex> {:ok, pid} = Snowpack.start_link(connection: [server: "account-id.snowflakecomputing.com", uid: "USER", pwd: "PASS"])
      {:ok, #PID<0.69.0>}

  Start connection using DNS configuration:

      iex> {:ok, pid} = Snowpack.start_link(connection: [dsn: "snowflake"])
      {:ok, #PID<0.69.0>}

  Run a query after connection has been established:

      iex> {:ok, pid} = Snowpack.start_link(connection: [dsn: "snowflake"], after_connect: &Snowpack.query!(&1, "SET time_zone = '+00:00'"))
      {:ok, #PID<0.69.0>}

  """
  @spec start_link([start_option()]) :: {:ok, pid()} | {:error, Snowpack.Error.t()}
  def start_link(opts) do
    opts = Keyword.put_new(opts, :idle_interval, @default_session_keepalive)

    DBConnection.start_link(Snowpack.Protocol, opts)
  end

  @doc """
  Runs a query.

  ## Options

  Options are passed to `DBConnection.prepare/3`, see it's documentation for
  all available options.

  ## Additional Options

    * `:parse_results` - Wether or not to do type parsing on the results. Requires
    execution to be performed inside a transaction and an extra `DESCRIBE RESULT` to
    get the types of the columns in the result. Only important for `SELECT` queries. Default true.

  ## Examples

      iex> Snowpack.query(conn, "SELECT * FROM RECORDS")
      {:ok, %Snowpack.Result{}}

      iex> Snowpack.query(conn, "INSERT INTO RECORDS (ROW1, ROW2) VALUES(?, ?)", [1, 2], parse_results: false)

  """
  @spec query(conn, iodata, list, [query_option()]) ::
          {:ok, Snowpack.Result.t()} | {:error, Exception.t()}
  def query(conn, statement, params \\ [], options \\ []) when is_iodata(statement) do
    case prepare_execute(conn, "", statement, params, options) do
      {:ok, _query, result} -> {:ok, result}
      {:error, error} -> {:error, error}
    end
  end

  @doc """
  Runs a query.

  Returns `%Snowpack.Result{}` on success, or raises an exception if there was an error.

  See `query/4`.
  """
  @spec query!(conn, iodata, list, [query_option()]) :: Snowpack.Result.t()
  def query!(conn, statement, params \\ [], opts \\ []) do
    case query(conn, statement, params, opts) do
      {:ok, result} -> result
      {:error, exception} -> raise exception
    end
  end

  @doc """
  Prepares a query to be executed later.

  To execute the query, call `execute/4`. To close the query, call `close/3`.
  If a name is given, the name must be unique per query, as the name is cached
  but the statement isn't. If a new statement is given to an old name, the old
  statement will be the one effectively used.

  ## Options

  Options are passed to `DBConnection.prepare/3`, see it's documentation for
  all available options.

  ## Additional Options

    * `:parse_results` - Wether or not to do type parsing on the results. Requires
    execution to be performed inside a transaction and an extra `DESCRIBE RESULT` to
    get the types of the columns in the result. Only important for `SELECT` queries. Default true.

  ## Examples

      iex> {:ok, query} = Snowpack.prepare(conn, "", "SELECT ? * ?")
      iex> {:ok, %Snowpack.Result{rows: [row]}} = Snowpack.execute(conn, query, [2, 3])
      iex> row
      [6]

  """
  @spec prepare(conn(), iodata(), iodata(), [query_option()]) ::
          {:ok, Snowpack.Query.t()} | {:error, Exception.t()}
  def prepare(conn, name, statement, opts \\ []) when is_iodata(name) and is_iodata(statement) do
    query = %Snowpack.Query{name: name, statement: statement}
    DBConnection.prepare(conn, query, opts)
  end

  @doc """
  Prepares a query.

  Returns `%Snowpack.Query{}` on success, or raises an exception if there was an error.

  See `prepare/4`.
  """
  @spec prepare!(conn(), iodata(), iodata(), [query_option()]) :: Snowpack.Query.t()
  def prepare!(conn, name, statement, opts \\ []) when is_iodata(name) and is_iodata(statement) do
    query = %Snowpack.Query{name: name, statement: statement}
    DBConnection.prepare!(conn, query, opts)
  end

  @doc """
  Prepares and executes a query in a single step.

  ## Multiple results

  If a query returns multiple results (e.g. it's calling a procedure that returns multiple results)
  an error is raised. If a query may return multiple results it's recommended to use `stream/4` instead.

  ## Options

  Options are passed to `DBConnection.prepare_execute/4`, see it's documentation for
  all available options.

  ## Additional Options

    * `:parse_results` - Wether or not to do type parsing on the results. Requires
    execution to be performed inside a transaction and an extra `DESCRIBE RESULT` to
    get the types of the columns in the result. Only important for `SELECT` queries. Default true.

  ## Examples

      iex> {:ok, _query, %Snowpack.Result{rows: [row]}} = Snowpack.prepare_execute(conn, "", "SELECT ? * ?", [2, 3])
      iex> row
      [6]

  """
  @spec prepare_execute(conn, iodata, iodata, list, keyword()) ::
          {:ok, Snowpack.Query.t(), Snowpack.Result.t()} | {:error, Exception.t()}
  def prepare_execute(conn, name, statement, params \\ [], opts \\ [])
      when is_iodata(name) and is_iodata(statement) do
    query = %Snowpack.Query{name: name, statement: statement}
    DBConnection.prepare_execute(conn, query, params, opts)
  end

  @doc """
  Prepares and executes a query in a single step.

  Returns `{%Snowpack.Query{}, %Snowpack.Result{}}` on success, or raises an exception if there was
  an error.

  See: `prepare_execute/5`.
  """
  @spec prepare_execute!(conn, iodata, iodata, list, [query_option()]) ::
          {Snowpack.Query.t(), Snowpack.Result.t()}
  def prepare_execute!(conn, name, statement, params \\ [], opts \\ [])
      when is_iodata(name) and is_iodata(statement) do
    query = %Snowpack.Query{name: name, statement: statement}
    DBConnection.prepare_execute!(conn, query, params, opts)
  end

  @doc """
  Executes a prepared query.

  ## Options

  Options are passed to `DBConnection.execute/4`, see it's documentation for
  all available options.

  ## Examples

      iex> {:ok, query} = Snowpack.prepare(conn, "", "SELECT ? * ?")
      iex> {:ok, %Snowpack.Result{rows: [row]}} = Snowpack.execute(conn, query, [2, 3])
      iex> row
      [6]

  """
  @spec execute(conn(), Snowpack.Query.t(), list(), [query_option()]) ::
          {:ok, Snowpack.Query.t(), Snowpack.Result.t()} | {:error, Exception.t()}
  defdelegate execute(conn, query, params, opts \\ []), to: DBConnection

  @doc """
  Executes a prepared query.

  Returns `%Snowpack.Result{}` on success, or raises an exception if there was an error.

  See: `execute/4`.
  """
  @spec execute!(conn(), Snowpack.Query.t(), list(), keyword()) :: Snowpack.Result.t()
  defdelegate execute!(conn, query, params, opts \\ []), to: DBConnection

  @doc """
  Closes a prepared query.

  Returns `:ok` on success, or raises an exception if there was an error.

  ## Options

  Options are passed to `DBConnection.close/3`, see it's documentation for
  all available options.
  """
  @spec close(conn(), Snowpack.Query.t(), [option()]) :: :ok | {:error, Exception.t()}
  def close(conn, %Snowpack.Query{} = query, opts \\ []) do
    case DBConnection.close(conn, query, opts) do
      {:ok, _} ->
        :ok

      # coveralls-ignore-start
      # handle_close is a noop. no db resources to free.
      error ->
        error
        # coveralls-ignore-stop
    end
  end

  @doc """
  Return the transaction status of a connection.
  """
  @spec status(conn(), opts :: Keyword.t()) :: DBConnection.status()
  def status(conn, opts \\ []) do
    DBConnection.status(conn, opts)
  end
end