Skip to main content

lib/ex_sql.ex

defmodule ExSQL do
  @moduledoc """
  A SQLite implementation in pure Elixir.

  ExSQL follows SQLite's architecture — tokenizer, parser, executor, storage —
  reshaped for the BEAM: the engine is a pure functional core over immutable
  data, with an optional GenServer connection for stateful use.

  ## Quick start

      {:ok, conn} = ExSQL.open()

      ExSQL.execute(conn, \"\"\"
      CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);
      INSERT INTO users (name, age) VALUES ('alice', 34), ('bob', 29);
      \"\"\")

      {:ok, result} = ExSQL.query(conn, "SELECT name FROM users WHERE age > 30")
      result.rows
      #=> [["alice"]]

  ## Purely functional use

  The engine itself never touches a process; you can thread the database
  value yourself:

      db = ExSQL.Database.new()
      {:ok, _, db} = ExSQL.Executor.run(db, "CREATE TABLE t (x)")
      {:ok, [result], _db} = ExSQL.Executor.run(db, "SELECT count(*) FROM t")

  ## Pipeline

  | stage | SQLite (C) | ExSQL |
  |-------|------------|-------|
  | lexing | `tokenize.c` | `ExSQL.Tokenizer` |
  | parsing | `parse.y` (Lemon) | `ExSQL.Parser` |
  | execution | codegen + VDBE (`vdbe.c`) | `ExSQL.Executor` (tree-walking) |
  | storage | `btree.c` + `pager.c` | `ExSQL.Table` / `ExSQL.Database` (in-memory) |
  """

  alias ExSQL.{Connection, Error, Result}

  @doc """
  Opens a new in-memory database connection (the equivalent of
  `sqlite3_open(":memory:")`).
  """
  @spec open(keyword()) :: GenServer.on_start()
  def open(opts \\ []), do: Connection.start_link(opts)

  @doc "Stops a connection."
  @spec close(GenServer.server()) :: :ok
  def close(conn), do: GenServer.stop(conn)

  @doc """
  Registers or replaces a connection-local scalar function.

  The callback arity must match the SQL arity exactly. It receives evaluated
  SQL values as positional arguments and may return a SQL value, `{:ok, value}`,
  or `{:error, message}`.

      :ok = ExSQL.create_function(conn, "double", 1, fn x -> x * 2 end)
      ExSQL.query!(conn, "SELECT double(21)").rows
      #=> [[42]]
  """
  @spec create_function(GenServer.server(), String.t(), non_neg_integer(), function()) ::
          :ok | {:error, Error.t()}
  def create_function(conn, name, arity, callback),
    do: Connection.create_function(conn, name, arity, callback)

  @doc """
  Registers or replaces a connection-local aggregate function.

  `arity` is the SQL arity. The callback itself receives one argument: a list
  of evaluated, non-NULL argument rows. For example, a one-argument aggregate
  receives `[[value], ...]`.

      :ok = ExSQL.create_aggregate(conn, "product", 1, fn rows ->
        rows |> Enum.map(&hd/1) |> Enum.product()
      end)
  """
  @spec create_aggregate(GenServer.server(), String.t(), non_neg_integer(), function()) ::
          :ok | {:error, Error.t()}
  def create_aggregate(conn, name, arity, callback),
    do: Connection.create_aggregate(conn, name, arity, callback)

  @doc """
  Registers or replaces a connection-local aggregate window function.

  The callback contract matches `create_aggregate/4`: it receives one list of
  evaluated, non-NULL argument rows. When used with `OVER (...)`, ExSQL calls
  it with the rows in the current window frame.

      :ok = ExSQL.create_window_function(conn, "frame_count", 1, fn rows ->
        length(rows)
      end)
  """
  @spec create_window_function(GenServer.server(), String.t(), non_neg_integer(), function()) ::
          :ok | {:error, Error.t()}
  def create_window_function(conn, name, arity, callback),
    do: Connection.create_window_function(conn, name, arity, callback)

  @doc """
  Registers or replaces a connection-local incremental aggregate window function.

  The callback map must contain `:init`, `:step`, `:inverse`, `:value`, and
  `:final` functions. `init.()` returns the initial state, `step.(state, args)`
  adds one frame row, `inverse.(state, args)` removes one frame row,
  `value.(state)` returns the current window value, and `final.(state)` returns
  the aggregate value when the function is used without `OVER`.

      :ok = ExSQL.create_incremental_window_function(conn, "running_sum", 1, %{
        init: fn -> 0 end,
        step: fn total, [x] -> total + (x || 0) end,
        inverse: fn total, [x] -> total - (x || 0) end,
        value: fn total -> total end,
        final: fn total -> total end
      })
  """
  @spec create_incremental_window_function(
          GenServer.server(),
          String.t(),
          non_neg_integer(),
          map()
        ) :: :ok | {:error, Error.t()}
  def create_incremental_window_function(conn, name, arity, callbacks),
    do: Connection.create_incremental_window_function(conn, name, arity, callbacks)

  @doc """
  Registers or replaces a connection-local collation.

  The callback receives two text values and may return `:lt`, `:eq`, `:gt`, a
  negative/zero/positive integer, `{:ok, result}`, or `{:error, message}`.

      :ok = ExSQL.create_collation(conn, "reverse", fn a, b ->
        cond do
          a < b -> :gt
          a > b -> :lt
          true -> :eq
        end
      end)
      ExSQL.query!(conn, "SELECT name FROM users ORDER BY name COLLATE reverse")
  """
  @spec create_collation(GenServer.server(), String.t(), function()) ::
          :ok | {:error, Error.t()}
  def create_collation(conn, name, callback),
    do: Connection.create_collation(conn, name, callback)

  @doc """
  Executes one or more `;`-separated statements, returning one
  `ExSQL.Result` per statement.

  Bind parameters are bound from `params`: a list for positional parameters
  (`?`, `?NNN`; 1-based) or a map for named ones (`:name`, `@name`, `$name`;
  keys may include or omit the sigil, and integer keys bind by index).

      ExSQL.query(conn, "SELECT * FROM users WHERE age > ?", [30])
      ExSQL.query(conn, "SELECT * FROM users WHERE name = :name", %{name: "alice"})
  """
  @spec execute(GenServer.server(), String.t(), [ExSQL.Value.t()] | map()) ::
          {:ok, [Result.t()]} | {:error, Error.t()}
  def execute(conn, sql, params \\ []), do: Connection.execute(conn, sql, params)

  @doc "Like `execute/3`, but raises `ExSQL.Error` on failure."
  @spec execute!(GenServer.server(), String.t(), [ExSQL.Value.t()] | map()) :: [Result.t()]
  def execute!(conn, sql, params \\ []) do
    case execute(conn, sql, params) do
      {:ok, results} -> results
      {:error, error} -> raise error
    end
  end

  @doc """
  Executes a single statement and returns its `ExSQL.Result`.

  Errors if `sql` contains more than one statement — use `execute/3` for
  scripts. Takes bind parameters like `execute/3`.
  """
  @spec query(GenServer.server(), String.t(), [ExSQL.Value.t()] | map()) ::
          {:ok, Result.t()} | {:error, Error.t()}
  def query(conn, sql, params \\ []) do
    case execute(conn, sql, params) do
      {:ok, [result]} ->
        {:ok, result}

      {:ok, results} ->
        {:error, %Error{message: "expected one statement, got #{length(results)}"}}

      {:error, _} = error ->
        error
    end
  end

  @doc "Like `query/3`, but raises `ExSQL.Error` on failure."
  @spec query!(GenServer.server(), String.t(), [ExSQL.Value.t()] | map()) :: Result.t()
  def query!(conn, sql, params \\ []) do
    case query(conn, sql, params) do
      {:ok, result} -> result
      {:error, error} -> raise error
    end
  end
end