This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
defmodule Exqlite do
@moduledoc """
SQLite3 driver for Elixir.
"""
alias Exqlite.Connection
alias Exqlite.Error
alias Exqlite.Query
alias Exqlite.Result
@doc "See `Exqlite.Connection.connect/1`"
@spec start_link([Connection.connection_opt()]) :: {:ok, pid()} | {:error, Error.t()}
def start_link(opts) do
DBConnection.start_link(Connection, opts)
end
@spec query(DBConnection.conn(), iodata(), list(), list()) ::
{:ok, Result.t()} | {:error, Exception.t()}
def query(conn, statement, params \\ [], opts \\ []) do
query = %Query{name: "", statement: IO.iodata_to_binary(statement)}
case DBConnection.prepare_execute(conn, query, params, opts) do
{:ok, _query, result} ->
{:ok, result}
otherwise ->
otherwise
end
end
@spec query!(DBConnection.conn(), iodata(), list(), list()) :: Result.t()
def query!(conn, statement, params \\ [], opts \\ []) do
case query(conn, statement, params, opts) do
{:ok, result} -> result
{:error, err} -> raise err
end
end
@spec prepare(DBConnection.conn(), iodata(), list()) ::
{:ok, Query.t()} | {:error, Exception.t()}
def prepare(conn, name, statement, opts \\ []) do
query = %Query{name: name, statement: statement}
DBConnection.prepare(conn, query, opts)
end
@spec prepare!(DBConnection.conn(), iodata(), iodata(), list()) :: Query.t()
def prepare!(conn, name, statement, opts \\ []) do
query = %Query{name: name, statement: statement}
DBConnection.prepare!(conn, query, opts)
end
@spec prepare_execute(DBConnection.conn(), iodata(), iodata(), list(), list()) ::
{:ok, Query.t(), Result.t()} | {:error, Error.t()}
def prepare_execute(conn, name, statement, params, opts \\ []) do
query = %Query{name: name, statement: statement}
DBConnection.prepare_execute(conn, query, params, opts)
end
@spec prepare_execute!(DBConnection.conn(), iodata(), iodata(), list(), list()) ::
{Query.t(), Result.t()}
def prepare_execute!(conn, name, statement, params, opts \\ []) do
query = %Query{name: name, statement: statement}
DBConnection.prepare_execute!(conn, query, params, opts)
end
@spec execute(DBConnection.conn(), Query.t(), list(), list()) ::
{:ok, Result.t()} | {:error, Error.t()}
def execute(conn, query, params, opts \\ []) do
DBConnection.execute(conn, query, params, opts)
end
@spec execute!(DBConnection.conn(), Query.t(), list(), list()) :: Result.t()
def execute!(conn, query, params, opts \\ []) do
DBConnection.execute!(conn, query, params, opts)
end
@spec close(DBConnection.conn(), Query.t(), list()) :: :ok | {:error, Exception.t()}
def close(conn, query, opts \\ []) do
with {:ok, _} <- DBConnection.close(conn, query, opts) do
:ok
end
end
@spec close!(DBConnection.conn(), Query.t(), list()) :: :ok
def close!(conn, query, opts \\ []) do
DBConnection.close!(conn, query, opts)
:ok
end
@spec transaction(DBConnection.conn(), (DBConnection.t() -> result), list()) ::
{:ok, result} | {:error, any}
when result: var
def transaction(conn, fun, opts \\ []) do
DBConnection.transaction(conn, fun, opts)
end
@spec rollback(DBConnection.t(), term()) :: no_return()
def rollback(conn, reason), do: DBConnection.rollback(conn, reason)
@spec child_spec([Connection.connection_opt()]) :: :supervisor.child_spec()
def child_spec(opts) do
DBConnection.child_spec(Connection, opts)
end
end

View File

@@ -0,0 +1,48 @@
defmodule Exqlite.Basic do
@moduledoc """
A very basic API for simple use cases.
"""
alias Exqlite.Connection
alias Exqlite.Query
alias Exqlite.Sqlite3
alias Exqlite.Error
alias Exqlite.Result
def open(path) do
Connection.connect(database: path)
end
def close(%Connection{} = conn) do
case Sqlite3.close(conn.db) do
:ok -> :ok
{:error, reason} -> {:error, %Error{message: to_string(reason)}}
end
end
def exec(%Connection{} = conn, stmt, args \\ []) do
%Query{statement: stmt} |> Connection.handle_execute(args, [], conn)
end
def rows(exec_result) do
case exec_result do
{:ok, %Query{}, %Result{rows: rows, columns: columns}, %Connection{}} ->
{:ok, rows, columns}
{:error, %Error{message: message}, %Connection{}} ->
{:error, to_string(message)}
end
end
def load_extension(conn, path) do
exec(conn, "select load_extension(?)", [path])
end
def enable_load_extension(conn) do
Sqlite3.enable_load_extension(conn.db, true)
end
def disable_load_extension(conn) do
Sqlite3.enable_load_extension(conn.db, false)
end
end

View File

@@ -0,0 +1,741 @@
defmodule Exqlite.Connection do
@moduledoc """
This module implements connection details as defined in DBProtocol.
## Attributes
- `db` - The sqlite3 database reference.
- `path` - The path that was used to open.
- `transaction_status` - The status of the connection. Can be `:idle` or `:transaction`.
## Unknowns
- How are pooled connections going to work? Since sqlite3 doesn't allow for
simultaneous access. We would need to check if the write ahead log is
enabled on the database. We can't assume and set the WAL pragma because the
database may be stored on a network volume which would cause potential
issues.
Notes:
- we try to closely follow structure and naming convention of myxql.
- sqlite thrives when there are many small conventions, so we may not implement
some strategies employed by other adapters. See https://sqlite.org/np1queryprob.html
"""
use DBConnection
alias Exqlite.Error
alias Exqlite.Pragma
alias Exqlite.Query
alias Exqlite.Result
alias Exqlite.Sqlite3
require Logger
defstruct [
:db,
:default_transaction_mode,
:directory,
:path,
:transaction_status,
:status,
:chunk_size,
:before_disconnect
]
@type t() :: %__MODULE__{
db: Sqlite3.db(),
directory: String.t() | nil,
path: String.t(),
transaction_status: :idle | :transaction,
status: :idle | :busy,
chunk_size: integer(),
before_disconnect: (t -> any) | {module, atom, [any]} | nil
}
@type journal_mode() :: :delete | :truncate | :persist | :memory | :wal | :off
@type temp_store() :: :default | :file | :memory
@type synchronous() :: :extra | :full | :normal | :off
@type auto_vacuum() :: :none | :full | :incremental
@type locking_mode() :: :normal | :exclusive
@type transaction_mode() :: :deferred | :immediate | :exclusive
@type connection_opt() ::
{:database, String.t()}
| {:default_transaction_mode, transaction_mode()}
| {:mode, Sqlite3.open_opt()}
| {:journal_mode, journal_mode()}
| {:temp_store, temp_store()}
| {:synchronous, synchronous()}
| {:foreign_keys, :on | :off}
| {:cache_size, integer()}
| {:cache_spill, :on | :off}
| {:case_sensitive_like, boolean()}
| {:auto_vacuum, auto_vacuum()}
| {:locking_mode, locking_mode()}
| {:secure_delete, :on | :off}
| {:wal_auto_check_point, integer()}
| {:busy_timeout, integer()}
| {:chunk_size, integer()}
| {:journal_size_limit, integer()}
| {:soft_heap_limit, integer()}
| {:hard_heap_limit, integer()}
| {:key, String.t()}
| {:custom_pragmas, [{keyword(), integer() | boolean() | String.t()}]}
| {:before_disconnect, (t -> any) | {module, atom, [any]} | nil}
@impl true
@doc """
Initializes the Ecto Exqlite adapter.
For connection configurations we use the defaults that come with SQLite3, but
we recommend which options to choose. We do not default to the recommended
because we don't know what your environment is like.
Allowed options:
* `:database` - The path to the database. In memory is allowed. You can use
`:memory` or `":memory:"` to designate that.
* `:default_transaction_mode` - one of `deferred` (default), `immediate`,
or `exclusive`. If a mode is not specified in a call to `Repo.transaction/2`,
this will be the default transaction mode.
* `:mode` - use `:readwrite` to open the database for reading and writing
, `:readonly` to open it in read-only mode or `[:readonly | :readwrite, :nomutex]`
to open it with no mutex mode. `:readwrite` will also create
the database if it doesn't already exist. Defaults to `:readwrite`.
Note: [:readwrite, :nomutex] is not recommended.
* `:journal_mode` - Sets the journal mode for the sqlite connection. Can be
one of the following `:delete`, `:truncate`, `:persist`, `:memory`,
`:wal`, or `:off`. Defaults to `:delete`. It is recommended that you use
`:wal` due to support for concurrent reads. Note: `:wal` does not mean
concurrent writes.
* `:temp_store` - Sets the storage used for temporary tables. Default is
`:default`. Allowed values are `:default`, `:file`, `:memory`. It is
recommended that you use `:memory` for storage.
* `:synchronous` - Can be `:extra`, `:full`, `:normal`, or `:off`. Defaults
to `:normal`.
* `:foreign_keys` - Sets if foreign key checks should be enforced or not.
Can be `:on` or `:off`. Default is `:on`.
* `:cache_size` - Sets the cache size to be used for the connection. This is
an odd setting as a positive value is the number of pages in memory to use
and a negative value is the size in kilobytes to use. Default is `-2000`.
It is recommended that you use `-64000`.
* `:cache_spill` - The cache_spill pragma enables or disables the ability of
the pager to spill dirty cache pages to the database file in the middle of
a transaction. By default it is `:on`, and for most applications, it
should remain so.
* `:case_sensitive_like`
* `:auto_vacuum` - Defaults to `:none`. Can be `:none`, `:full` or
`:incremental`. Depending on the database size, `:incremental` may be
beneficial.
* `:locking_mode` - Defaults to `:normal`. Allowed values are `:normal` or
`:exclusive`. See [sqlite documentation][1] for more information.
* `:secure_delete` - Defaults to `:off`. If enabled, it will cause SQLite3
to overwrite records that were deleted with zeros.
* `:wal_auto_check_point` - Sets the write-ahead log auto-checkpoint
interval. Default is `1000`. Setting the auto-checkpoint size to zero or a
negative value turns auto-checkpointing off.
* `:busy_timeout` - Sets the busy timeout in milliseconds for a connection.
Default is `2000`.
* `:chunk_size` - The chunk size for bulk fetching. Defaults to `50`.
* `:key` - Optional key to set during database initialization. This PRAGMA
is often used to set up database level encryption.
* `:journal_size_limit` - The size limit in bytes of the journal.
* `:soft_heap_limit` - The size limit in bytes for the heap limit.
* `:hard_heap_limit` - The size limit in bytes for the heap.
* `:custom_pragmas` - A list of custom pragmas to set on the connection, for example to configure extensions.
* `:serialized` - A SQLite database which was previously serialized, to load into the database after connection.
* `:load_extensions` - A list of paths identifying extensions to load. Defaults to `[]`.
The provided list will be merged with the global extensions list, set on `:exqlite, :load_extensions`.
Be aware that the path should handle pointing to a library compiled for the current architecture.
Example configuration:
```
arch_dir =
System.cmd("uname", ["-sm"])
|> elem(0)
|> String.trim()
|> String.replace(" ", "-")
|> String.downcase() # => "darwin-arm64"
config :myapp, arch_dir: arch_dir
# global
config :exqlite, load_extensions: [ "./priv/sqlite/\#{arch_dir}/rotate" ]
# per connection in a Phoenix app
config :myapp, Myapp.Repo,
database: "path/to/db",
load_extensions: [
"./priv/sqlite/\#{arch_dir}/vector0",
"./priv/sqlite/\#{arch_dir}/vss0"
]
```
* `:before_disconnect` - A function to run before disconnect, either a
2-arity fun or `{module, function, args}` with the close reason and
`t:Exqlite.Connection.t/0` prepended to `args` or `nil` (default: `nil`)
For more information about the options above, see [sqlite documentation][1]
[1]: https://www.sqlite.org/pragma.html
"""
@spec connect([connection_opt()]) :: {:ok, t()} | {:error, Exception.t()}
def connect(options) do
database = Keyword.get(options, :database)
options =
Keyword.put_new(
options,
:chunk_size,
Application.get_env(:exqlite, :default_chunk_size, 50)
)
case database do
nil ->
{:error,
%Error{
message: """
You must provide a :database to the database. \
Example: connect(database: "./") or connect(database: :memory)\
"""
}}
:memory ->
do_connect(":memory:", options)
_ ->
do_connect(database, options)
end
end
@impl true
def disconnect(err, %__MODULE__{db: db} = state) do
if state.before_disconnect != nil do
apply(state.before_disconnect, [err, state])
end
case Sqlite3.close(db) do
:ok -> :ok
{:error, reason} -> {:error, %Error{message: to_string(reason)}}
end
end
@impl true
def checkout(%__MODULE__{status: :idle} = state) do
{:ok, %{state | status: :busy}}
end
def checkout(%__MODULE__{status: :busy} = state) do
{:disconnect, %Error{message: "Database is busy"}, state}
end
@impl true
def ping(state), do: {:ok, state}
##
## Handlers
##
@impl true
def handle_prepare(%Query{} = query, options, state) do
with {:ok, query} <- prepare(query, options, state) do
{:ok, query, state}
end
end
@impl true
def handle_execute(%Query{} = query, params, options, state) do
with {:ok, query} <- prepare(query, options, state) do
execute(:execute, query, params, state)
end
end
@doc """
Begin a transaction.
For full info refer to sqlite docs: https://sqlite.org/lang_transaction.html
Note: default transaction mode is DEFERRED.
"""
@impl true
def handle_begin(options, %{transaction_status: transaction_status} = state) do
# This doesn't handle more than 2 levels of transactions.
#
# One possible solution would be to just track the number of open
# transactions and use that for driving the transaction status being idle or
# in a transaction.
#
# I do not know why the other official adapters do not track this and just
# append level on the savepoint. Instead the rollbacks would just completely
# revert the issues when it may be desirable to fix something while in the
# transaction and then commit.
mode = Keyword.get(options, :mode, state.default_transaction_mode)
case mode do
:deferred when transaction_status == :idle ->
handle_transaction(:begin, "BEGIN TRANSACTION", state)
:transaction when transaction_status == :idle ->
handle_transaction(:begin, "BEGIN TRANSACTION", state)
:immediate when transaction_status == :idle ->
handle_transaction(:begin, "BEGIN IMMEDIATE TRANSACTION", state)
:exclusive when transaction_status == :idle ->
handle_transaction(:begin, "BEGIN EXCLUSIVE TRANSACTION", state)
mode
when mode in [:deferred, :immediate, :exclusive, :savepoint] and
transaction_status == :transaction ->
handle_transaction(:begin, "SAVEPOINT exqlite_savepoint", state)
end
end
@impl true
def handle_commit(options, %{transaction_status: transaction_status} = state) do
case Keyword.get(options, :mode, :deferred) do
:savepoint when transaction_status == :transaction ->
handle_transaction(
:commit_savepoint,
"RELEASE SAVEPOINT exqlite_savepoint",
state
)
mode
when mode in [:deferred, :immediate, :exclusive, :transaction] and
transaction_status == :transaction ->
handle_transaction(:commit, "COMMIT", state)
end
end
@impl true
def handle_rollback(options, %{transaction_status: transaction_status} = state) do
case Keyword.get(options, :mode, :deferred) do
:savepoint when transaction_status == :transaction ->
with {:ok, _result, state} <-
handle_transaction(
:rollback_savepoint,
"ROLLBACK TO SAVEPOINT exqlite_savepoint",
state
) do
handle_transaction(
:rollback_savepoint,
"RELEASE SAVEPOINT exqlite_savepoint",
state
)
end
mode
when mode in [:deferred, :immediate, :exclusive, :transaction] ->
handle_transaction(:rollback, "ROLLBACK TRANSACTION", state)
end
end
@doc """
Close a query prepared by `handle_prepare/3` with the database. Return
`{:ok, result, state}` on success and to continue,
`{:error, exception, state}` to return an error and continue, or
`{:disconnect, exception, state}` to return an error and disconnect.
This callback is called in the client process.
"""
@impl true
def handle_close(query, _opts, state) do
Sqlite3.release(state.db, query.ref)
{:ok, nil, state}
end
@impl true
def handle_declare(%Query{} = query, params, opts, state) do
# We emulate cursor functionality by just using a prepared statement and
# step through it. Thus we just return the query ref as the cursor.
with {:ok, query} <- prepare_no_cache(query, opts, state),
{:ok, query} <- bind_params(query, params, state) do
{:ok, query, query.ref, state}
end
end
@impl true
def handle_deallocate(%Query{} = query, _cursor, _opts, state) do
Sqlite3.release(state.db, query.ref)
{:ok, nil, state}
end
@impl true
def handle_fetch(%Query{statement: statement}, cursor, opts, state) do
chunk_size = opts[:chunk_size] || opts[:max_rows] || state.chunk_size
case Sqlite3.multi_step(state.db, cursor, chunk_size) do
{:done, rows} ->
{:halt, %Result{rows: rows, command: :fetch, num_rows: length(rows)}, state}
{:rows, rows} ->
{:cont, %Result{rows: rows, command: :fetch, num_rows: chunk_size}, state}
{:error, reason} ->
{:error, %Error{message: to_string(reason), statement: statement}, state}
:busy ->
{:error, %Error{message: "Database is busy", statement: statement}, state}
end
end
@impl true
def handle_status(_opts, state) do
{state.transaction_status, state}
end
### ----------------------------------
# Internal functions and helpers
### ----------------------------------
defp set_pragma(db, pragma_name, value) do
Sqlite3.execute(db, "PRAGMA #{pragma_name} = #{value}")
end
defp get_pragma(db, pragma_name) do
{:ok, statement} = Sqlite3.prepare(db, "PRAGMA #{pragma_name}")
case Sqlite3.fetch_all(db, statement) do
{:ok, [[value]]} -> {:ok, value}
_ -> :error
end
end
defp maybe_set_pragma(db, pragma_name, value) do
case get_pragma(db, pragma_name) do
{:ok, current} ->
if current == value do
:ok
else
set_pragma(db, pragma_name, value)
end
_ ->
set_pragma(db, pragma_name, value)
end
end
defp set_key(db, options) do
# we can't use maybe_set_pragma here since
# the only thing that will work on an encrypted
# database without error is setting the key.
case Keyword.fetch(options, :key) do
{:ok, key} -> set_pragma(db, "key", key)
_ -> :ok
end
end
defp set_custom_pragmas(db, options) do
# we can't use maybe_set_pragma because some pragmas
# are required to be set before the database is e.g. decrypted.
case Keyword.fetch(options, :custom_pragmas) do
{:ok, list} -> do_set_custom_pragmas(db, list)
_ -> :ok
end
end
defp do_set_custom_pragmas(db, list) do
list
|> Enum.reduce_while(:ok, fn {key, value}, :ok ->
case set_pragma(db, key, value) do
:ok -> {:cont, :ok}
{:error, _reason} -> {:halt, :error}
end
end)
end
defp set_pragma_if_present(_db, _pragma, nil), do: :ok
defp set_pragma_if_present(db, pragma, value), do: set_pragma(db, pragma, value)
defp set_journal_size_limit(db, options) do
set_pragma_if_present(
db,
"journal_size_limit",
Keyword.get(options, :journal_size_limit)
)
end
defp set_soft_heap_limit(db, options) do
set_pragma_if_present(db, "soft_heap_limit", Keyword.get(options, :soft_heap_limit))
end
defp set_hard_heap_limit(db, options) do
set_pragma_if_present(db, "hard_heap_limit", Keyword.get(options, :hard_heap_limit))
end
defp set_journal_mode(db, options) do
set_pragma_if_present(db, "journal_mode", Keyword.get(options, :journal_mode))
end
defp set_temp_store(db, options) do
set_pragma(db, "temp_store", Pragma.temp_store(options))
end
defp set_synchronous(db, options) do
set_pragma(db, "synchronous", Pragma.synchronous(options))
end
defp set_foreign_keys(db, options) do
set_pragma(db, "foreign_keys", Pragma.foreign_keys(options))
end
defp set_cache_size(db, options) do
maybe_set_pragma(db, "cache_size", Pragma.cache_size(options))
end
defp set_cache_spill(db, options) do
set_pragma(db, "cache_spill", Pragma.cache_spill(options))
end
defp set_case_sensitive_like(db, options) do
set_pragma(db, "case_sensitive_like", Pragma.case_sensitive_like(options))
end
defp set_auto_vacuum(db, options) do
set_pragma(db, "auto_vacuum", Pragma.auto_vacuum(options))
end
defp set_locking_mode(db, options) do
set_pragma(db, "locking_mode", Pragma.locking_mode(options))
end
defp set_secure_delete(db, options) do
set_pragma(db, "secure_delete", Pragma.secure_delete(options))
end
defp set_wal_auto_check_point(db, options) do
set_pragma(db, "wal_autocheckpoint", Pragma.wal_auto_check_point(options))
end
defp set_busy_timeout(db, options) do
set_pragma(db, "busy_timeout", Pragma.busy_timeout(options))
end
defp deserialize(db, options) do
case Keyword.get(options, :serialized, nil) do
nil -> :ok
serialized -> Sqlite3.deserialize(db, serialized)
end
end
defp load_extensions(db, options) do
global_extensions = Application.get_env(:exqlite, :load_extensions, [])
extensions =
Keyword.get(options, :load_extensions, [])
|> Enum.concat(global_extensions)
|> Enum.uniq()
do_load_extensions(db, extensions)
end
defp do_load_extensions(_db, []), do: :ok
defp do_load_extensions(db, extensions) do
Sqlite3.enable_load_extension(db, true)
Enum.each(extensions, fn extension ->
Logger.debug(fn -> "Exqlite: loading extension `#{extension}`" end)
Sqlite3.execute(db, "SELECT load_extension('#{extension}')")
end)
Sqlite3.enable_load_extension(db, false)
end
defp do_connect(database, options) do
with {:ok, directory} <- resolve_directory(database),
:ok <- mkdir_p(directory),
{:ok, db} <- Sqlite3.open(database, options),
:ok <- set_key(db, options),
:ok <- set_custom_pragmas(db, options),
:ok <- set_journal_mode(db, options),
:ok <- set_temp_store(db, options),
:ok <- set_synchronous(db, options),
:ok <- set_foreign_keys(db, options),
:ok <- set_cache_size(db, options),
:ok <- set_cache_spill(db, options),
:ok <- set_auto_vacuum(db, options),
:ok <- set_locking_mode(db, options),
:ok <- set_secure_delete(db, options),
:ok <- set_wal_auto_check_point(db, options),
:ok <- set_case_sensitive_like(db, options),
:ok <- set_busy_timeout(db, options),
:ok <- set_journal_size_limit(db, options),
:ok <- set_soft_heap_limit(db, options),
:ok <- set_hard_heap_limit(db, options),
:ok <- load_extensions(db, options),
:ok <- deserialize(db, options) do
state = %__MODULE__{
db: db,
default_transaction_mode:
Keyword.get(options, :default_transaction_mode, :deferred),
directory: directory,
path: database,
transaction_status: :idle,
status: :idle,
chunk_size: Keyword.get(options, :chunk_size),
before_disconnect: Keyword.get(options, :before_disconnect, nil)
}
{:ok, state}
else
{:error, reason} ->
{:error, %Exqlite.Error{message: to_string(reason)}}
end
end
def maybe_put_command(query, options) do
case Keyword.get(options, :command) do
nil -> query
command -> %{query | command: command}
end
end
# Attempt to retrieve the cached query, if it doesn't exist, we'll prepare one
# and cache it for later.
defp prepare(%Query{statement: statement} = query, options, state) do
query = maybe_put_command(query, options)
with {:ok, ref} <- Sqlite3.prepare(state.db, IO.iodata_to_binary(statement)),
query <- %{query | ref: ref} do
{:ok, query}
else
{:error, reason} ->
{:error, %Error{message: to_string(reason), statement: statement}, state}
end
end
# Prepare a query and do not cache it.
defp prepare_no_cache(%Query{statement: statement} = query, options, state) do
query = maybe_put_command(query, options)
case Sqlite3.prepare(state.db, statement) do
{:ok, ref} ->
{:ok, %{query | ref: ref}}
{:error, reason} ->
{:error, %Error{message: to_string(reason), statement: statement}, state}
end
end
@spec maybe_changes(Sqlite3.db(), Query.t()) :: integer() | nil
defp maybe_changes(db, %Query{command: command})
when command in [:update, :insert, :delete] do
case Sqlite3.changes(db) do
{:ok, total} -> total
_ -> nil
end
end
defp maybe_changes(_, _), do: nil
# when we have an empty list of columns, that signifies that
# there was no possible return tuple (e.g., update statement without RETURNING)
# and in that case, we return nil to signify no possible result.
defp maybe_rows([], []), do: nil
defp maybe_rows(rows, _cols), do: rows
defp execute(call, %Query{} = query, params, state) do
with {:ok, query} <- bind_params(query, params, state),
{:ok, columns} <- get_columns(query, state),
{:ok, rows} <- get_rows(query, state),
{:ok, transaction_status} <- Sqlite3.transaction_status(state.db),
changes <- maybe_changes(state.db, query) do
case query.command do
command when command in [:delete, :insert, :update] ->
{
:ok,
query,
Result.new(
command: call,
num_rows: changes,
rows: maybe_rows(rows, columns)
),
%{state | transaction_status: transaction_status}
}
_ ->
{
:ok,
query,
Result.new(
command: call,
columns: columns,
rows: rows,
num_rows: Enum.count(rows)
),
%{state | transaction_status: transaction_status}
}
end
end
end
defp bind_params(%Query{ref: ref, statement: statement} = query, params, state)
when ref != nil do
try do
Sqlite3.bind(ref, params)
rescue
e -> {:error, %Error{message: Exception.message(e), statement: statement}, state}
else
:ok -> {:ok, query}
end
end
defp get_columns(%Query{ref: ref, statement: statement}, state) do
case Sqlite3.columns(state.db, ref) do
{:ok, columns} ->
{:ok, columns}
{:error, reason} ->
{:error, %Error{message: to_string(reason), statement: statement}, state}
end
end
defp get_rows(%Query{ref: ref, statement: statement}, state) do
case Sqlite3.fetch_all(state.db, ref, state.chunk_size) do
{:ok, rows} ->
{:ok, rows}
{:error, reason} ->
{:error, %Error{message: to_string(reason), statement: statement}, state}
end
end
defp handle_transaction(call, statement, state) do
with :ok <- Sqlite3.execute(state.db, statement),
{:ok, transaction_status} <- Sqlite3.transaction_status(state.db) do
result = %Result{
command: call,
rows: [],
columns: [],
num_rows: 0
}
{:ok, result, %{state | transaction_status: transaction_status}}
else
{:error, reason} ->
{:disconnect, %Error{message: to_string(reason), statement: statement}, state}
end
end
defp resolve_directory(":memory:"), do: {:ok, nil}
defp resolve_directory("file:" <> _ = uri) do
case URI.parse(uri) do
%{path: path} when is_binary(path) ->
{:ok, Path.dirname(path)}
_ ->
{:error, "No path in #{inspect(uri)}"}
end
end
defp resolve_directory(path), do: {:ok, Path.dirname(path)}
# SQLITE_OPEN_CREATE will create the DB file if not existing, but
# will not create intermediary directories if they are missing.
# So let's preemptively create the intermediate directories here
# before trying to open the DB file.
defp mkdir_p(nil), do: :ok
defp mkdir_p(directory), do: File.mkdir_p(directory)
end

View File

@@ -0,0 +1,18 @@
defmodule Exqlite.Error do
@moduledoc """
The error emitted from SQLite or a general error with the library.
"""
defexception [:message, :statement]
@type t :: %__MODULE__{
message: String.t(),
statement: String.t()
}
@impl true
def message(%__MODULE__{message: message, statement: nil}), do: message
def message(%__MODULE__{message: message, statement: statement}),
do: "#{message}\n#{statement}"
end

View File

@@ -0,0 +1,35 @@
defmodule Exqlite.Flags do
@moduledoc false
import Bitwise
# https://www.sqlite.org/c3ref/c_open_autoproxy.html
@file_open_flags [
sqlite_open_readonly: 0x00000001,
sqlite_open_readwrite: 0x00000002,
sqlite_open_create: 0x00000004,
sqlite_open_deleteonclos: 0x00000008,
sqlite_open_exclusive: 0x00000010,
sqlite_open_autoproxy: 0x00000020,
sqlite_open_uri: 0x00000040,
sqlite_open_memory: 0x00000080,
sqlite_open_main_db: 0x00000100,
sqlite_open_temp_db: 0x00000200,
sqlite_open_transient_db: 0x00000400,
sqlite_open_main_journal: 0x00000800,
sqlite_open_temp_journal: 0x00001000,
sqlite_open_subjournal: 0x00002000,
sqlite_open_super_journal: 0x00004000,
sqlite_open_nomutex: 0x00008000,
sqlite_open_fullmutex: 0x00010000,
sqlite_open_sharedcache: 0x00020000,
sqlite_open_privatecache: 0x00040000,
sqlite_open_wal: 0x00080000,
sqlite_open_nofollow: 0x01000000,
sqlite_open_exrescode: 0x02000000
]
def put_file_open_flags(current_flags \\ 0, flags) do
Enum.reduce(flags, current_flags, &(&2 ||| Keyword.fetch!(@file_open_flags, &1)))
end
end

View File

@@ -0,0 +1,121 @@
defmodule Exqlite.Pragma do
@moduledoc """
Handles parsing extra options for the SQLite connection
"""
def busy_timeout(nil), do: busy_timeout([])
def busy_timeout(options) do
Keyword.get(options, :busy_timeout, 2000)
end
def journal_mode(nil), do: journal_mode([])
def journal_mode(options) do
case Keyword.get(options, :journal_mode, :delete) do
:delete -> "delete"
:memory -> "memory"
:off -> "off"
:persist -> "persist"
:truncate -> "truncate"
:wal -> "wal"
_ -> raise ArgumentError, "invalid :journal_mode"
end
end
def temp_store(nil), do: temp_store([])
def temp_store(options) do
case Keyword.get(options, :temp_store, :default) do
:file -> 1
:memory -> 2
:default -> 0
_ -> raise ArgumentError, "invalid :temp_store"
end
end
def synchronous(nil), do: synchronous([])
def synchronous(options) do
case Keyword.get(options, :synchronous, :normal) do
:extra -> 3
:full -> 2
:normal -> 1
:off -> 0
_ -> raise ArgumentError, "invalid :synchronous"
end
end
def foreign_keys(nil), do: foreign_keys([])
def foreign_keys(options) do
case Keyword.get(options, :foreign_keys, :on) do
:off -> 0
:on -> 1
_ -> raise ArgumentError, "invalid :foreign_keys"
end
end
def cache_size(nil), do: cache_size([])
def cache_size(options) do
Keyword.get(options, :cache_size, -2000)
end
def cache_spill(nil), do: cache_spill([])
def cache_spill(options) do
case Keyword.get(options, :cache_spill, :on) do
:off -> 0
:on -> 1
_ -> raise ArgumentError, "invalid :cache_spill"
end
end
def case_sensitive_like(nil), do: case_sensitive_like([])
def case_sensitive_like(options) do
case Keyword.get(options, :case_sensitive_like, :off) do
:off -> 0
:on -> 1
_ -> raise ArgumentError, "invalid :case_sensitive_like"
end
end
def auto_vacuum(nil), do: auto_vacuum([])
def auto_vacuum(options) do
case Keyword.get(options, :auto_vacuum, :none) do
:none -> 0
:full -> 1
:incremental -> 2
_ -> raise ArgumentError, "invalid :auto_vacuum"
end
end
def locking_mode(nil), do: locking_mode([])
def locking_mode(options) do
case Keyword.get(options, :locking_mode, :normal) do
:normal -> "NORMAL"
:exclusive -> "EXCLUSIVE"
_ -> raise ArgumentError, "invalid :locking_mode"
end
end
def secure_delete(nil), do: secure_delete([])
def secure_delete(options) do
case Keyword.get(options, :secure_delete, :off) do
:off -> 0
:on -> 1
_ -> raise ArgumentError, "invalid :secure_delete"
end
end
def wal_auto_check_point(nil), do: wal_auto_check_point([])
def wal_auto_check_point(options) do
Keyword.get(options, :wal_auto_check_point, 1000)
end
end

View File

@@ -0,0 +1,70 @@
defmodule Exqlite.Query do
@moduledoc """
Query struct returned from a successfully prepared query.
"""
@type t :: %__MODULE__{
statement: iodata(),
name: atom() | String.t(),
ref: reference() | nil,
command: :insert | :delete | :update | nil
}
defstruct statement: nil,
name: nil,
ref: nil,
command: nil
def build(options) do
statement = Keyword.get(options, :statement)
name = Keyword.get(options, :name)
ref = Keyword.get(options, :ref)
command =
case Keyword.get(options, :command) do
nil -> extract_command(statement)
value -> value
end
%__MODULE__{
statement: statement,
name: name,
ref: ref,
command: command
}
end
defp extract_command(nil), do: nil
defp extract_command(statement) do
cond do
String.contains?(statement, "INSERT") -> :insert
String.contains?(statement, "DELETE") -> :delete
String.contains?(statement, "UPDATE") -> :update
true -> nil
end
end
defimpl DBConnection.Query do
def parse(query, _opts) do
query
end
def describe(query, _opts) do
query
end
def encode(_query, params, _opts) do
params
end
def decode(_query, result, _opts) do
result
end
end
defimpl String.Chars do
def to_string(%{statement: statement}) do
IO.iodata_to_binary(statement)
end
end
end

View File

@@ -0,0 +1,35 @@
defmodule Exqlite.Result do
@moduledoc """
The database results.
"""
@type t :: %__MODULE__{
command: atom,
columns: [String.t()] | nil,
rows: [[term] | term] | nil,
num_rows: integer()
}
defstruct command: nil, columns: [], rows: [], num_rows: 0
def new(options) do
%__MODULE__{
command: Keyword.get(options, :command),
columns: Keyword.get(options, :columns, []),
rows: Keyword.get(options, :rows, []),
num_rows: Keyword.get(options, :num_rows, 0)
}
end
end
if Code.ensure_loaded?(Table.Reader) do
defimpl Table.Reader, for: Exqlite.Result do
def init(%{columns: columns}) when columns in [nil, []] do
{:rows, %{columns: []}, []}
end
def init(result) do
{:rows, %{columns: result.columns}, result.rows}
end
end
end

View File

@@ -0,0 +1,587 @@
defmodule Exqlite.Sqlite3 do
@moduledoc """
The interface to the NIF implementation.
"""
# If the database reference is closed, any prepared statements should be
# dereferenced as well. It is entirely possible that an application does
# not properly remove a stale reference.
#
# Will need to add a test for this and think of possible solution.
# Need to figure out if we can just stream results where we use this
# module as a sink.
alias Exqlite.Flags
alias Exqlite.Sqlite3NIF
@type db() :: reference()
@type statement() :: reference()
@type reason() :: atom() | String.t()
@type row() :: list()
@type open_mode :: :readwrite | :readonly | :nomutex
@type open_opt :: {:mode, :readwrite | :readonly | [open_mode()]}
@doc """
Opens a new sqlite database at the Path provided.
`path` can be `":memory"` to keep the sqlite database in memory.
## Options
* `:mode` - use `:readwrite` to open the database for reading and writing
, `:readonly` to open it in read-only mode or `[:readonly | :readwrite, :nomutex]`
to open it with no mutex mode. `:readwrite` will also create
the database if it doesn't already exist. Defaults to `:readwrite`.
Note: [:readwrite, :nomutex] is not recommended.
"""
@spec open(String.t(), [open_opt()]) :: {:ok, db()} | {:error, reason()}
def open(path, opts \\ []) do
mode = Keyword.get(opts, :mode, :readwrite)
Sqlite3NIF.open(path, flags_from_mode(mode))
end
defp flags_from_mode(:nomutex) do
raise ArgumentError,
"expected mode to be `:readwrite` or `:readonly`, can't use a single :nomutex mode"
end
defp flags_from_mode(:readwrite),
do: do_flags_from_mode([:readwrite], [])
defp flags_from_mode(:readonly),
do: do_flags_from_mode([:readonly], [])
defp flags_from_mode([_ | _] = modes),
do: do_flags_from_mode(modes, [])
defp flags_from_mode(mode) do
raise ArgumentError,
"expected mode to be `:readwrite`, `:readonly` or list of modes, but received #{inspect(mode)}"
end
defp do_flags_from_mode([:readwrite | tail], acc),
do: do_flags_from_mode(tail, [:sqlite_open_readwrite, :sqlite_open_create | acc])
defp do_flags_from_mode([:readonly | tail], acc),
do: do_flags_from_mode(tail, [:sqlite_open_readonly | acc])
defp do_flags_from_mode([:nomutex | tail], acc),
do: do_flags_from_mode(tail, [:sqlite_open_nomutex | acc])
defp do_flags_from_mode([mode | _tail], _acc) do
raise ArgumentError,
"expected mode to be `:readwrite`, `:readonly` or `:nomutex`, but received #{inspect(mode)}"
end
defp do_flags_from_mode([], acc),
do: Flags.put_file_open_flags(acc)
@doc """
Closes the database and releases any underlying resources.
"""
@spec close(db() | nil) :: :ok | {:error, reason()}
def close(nil), do: :ok
def close(conn), do: Sqlite3NIF.close(conn)
@doc """
Interrupt a long-running query.
> #### Warning {: .warning}
> If you are going to interrupt a long running process, it is unsafe to call
> `close/1` immediately after. You run the risk of undefined behavior. This
> is a limitation of the sqlite library itself. Please see the documentation
> https://www.sqlite.org/c3ref/interrupt.html for more information.
>
> If close must be called after, it is best to put a short sleep in order to
> let sqlite finish doing its book keeping.
"""
@spec interrupt(db() | nil) :: :ok | {:error, reason()}
def interrupt(nil), do: :ok
def interrupt(conn), do: Sqlite3NIF.interrupt(conn)
@doc """
Executes an sql script. Multiple stanzas can be passed at once.
"""
@spec execute(db(), String.t()) :: :ok | {:error, reason()}
def execute(conn, sql), do: Sqlite3NIF.execute(conn, sql)
@doc """
Get the number of changes recently.
**Note**: If triggers are used, the count may be larger than expected.
See: https://sqlite.org/c3ref/changes.html
"""
@spec changes(db()) :: {:ok, integer()} | {:error, reason()}
def changes(conn), do: Sqlite3NIF.changes(conn)
@spec prepare(db(), String.t()) :: {:ok, statement()} | {:error, reason()}
def prepare(conn, sql), do: Sqlite3NIF.prepare(conn, sql)
@doc """
Resets a prepared statement.
See: https://sqlite.org/c3ref/reset.html
"""
@spec reset(statement) :: :ok | {:error, atom()}
def reset(stmt), do: Sqlite3NIF.reset(stmt)
@doc """
Returns number of SQL parameters in a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?, ?")
iex> Sqlite3.bind_parameter_count(stmt)
2
"""
@spec bind_parameter_count(statement) :: non_neg_integer | {:error, atom()}
def bind_parameter_count(stmt), do: Sqlite3NIF.bind_parameter_count(stmt)
@type bind_value ::
NaiveDateTime.t()
| DateTime.t()
| Date.t()
| Time.t()
| number
| iodata
| {:blob, iodata}
| atom
@doc """
Resets a prepared statement and binds values to it.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?, ?, ?, ?, ?")
iex> Sqlite3.bind(stmt, [42, 3.14, "Alice", {:blob, <<0, 0, 0>>}, nil])
iex> Sqlite3.step(conn, stmt)
{:row, [42, 3.14, "Alice", <<0, 0, 0>>, nil]}
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT :42, @pi, $name, @blob, :null")
iex> Sqlite3.bind(stmt, %{":42" => 42, "@pi" => 3.14, "$name" => "Alice", :"@blob" => {:blob, <<0, 0, 0>>}, ~c":null" => nil})
iex> Sqlite3.step(conn, stmt)
{:row, [42, 3.14, "Alice", <<0, 0, 0>>, nil]}
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind(stmt, [42, 3.14, "Alice"])
** (ArgumentError) expected 1 arguments, got 3
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?, ?")
iex> Sqlite3.bind(stmt, [42])
** (ArgumentError) expected 2 arguments, got 1
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind(stmt, [:erlang.list_to_pid(~c"<0.0.0>")])
** (ArgumentError) unsupported type: #PID<0.0.0>
"""
@spec bind(
statement,
[bind_value] | %{optional(String.t()) => bind_value} | nil
) :: :ok
def bind(stmt, nil), do: bind(stmt, [])
def bind(stmt, args) when is_list(args) do
case bind_parameter_count(stmt) do
{:error, reason} ->
{:error, reason}
params_count ->
args_count = length(args)
if args_count == params_count do
bind_all(args, stmt, 1)
else
raise ArgumentError, "expected #{params_count} arguments, got #{args_count}"
end
end
end
def bind(stmt, args) when is_map(args) do
case bind_parameter_count(stmt) do
{:error, reason} ->
{:error, reason}
params_count ->
args_count = map_size(args)
if args_count == params_count do
bind_all_named(Map.to_list(args), stmt)
else
raise ArgumentError,
"expected #{params_count} named arguments, got #{args_count}: #{inspect(Map.keys(args))}"
end
end
end
defp bind_all([param | params], stmt, idx) do
do_bind(stmt, idx, param)
bind_all(params, stmt, idx + 1)
end
defp bind_all([], _stmt, _idx), do: :ok
defp bind_all_named([{name, param} | named_params], stmt) do
idx = Sqlite3NIF.bind_parameter_index(stmt, to_string(name))
if idx == 0 do
raise ArgumentError, "unknown named parameter: #{inspect(name)}"
end
do_bind(stmt, idx, param)
bind_all_named(named_params, stmt)
end
defp bind_all_named([], _stmt), do: :ok
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp do_bind(stmt, idx, param) do
case convert(param) do
i when is_integer(i) -> bind_integer(stmt, idx, i)
f when is_float(f) -> bind_float(stmt, idx, f)
b when is_binary(b) -> bind_text(stmt, idx, b)
b when is_list(b) -> bind_text(stmt, idx, IO.iodata_to_binary(b))
nil -> bind_null(stmt, idx)
:undefined -> bind_null(stmt, idx)
a when is_atom(a) -> bind_text(stmt, idx, Atom.to_string(a))
{:blob, b} when is_binary(b) -> bind_blob(stmt, idx, b)
{:blob, b} when is_list(b) -> bind_blob(stmt, idx, IO.iodata_to_binary(b))
_other -> raise ArgumentError, "unsupported type: #{inspect(param)}"
end
end
@spec columns(db(), statement()) :: {:ok, [binary()]} | {:error, reason()}
def columns(conn, statement), do: Sqlite3NIF.columns(conn, statement)
@spec step(db(), statement()) :: :done | :busy | {:row, row()} | {:error, reason()}
def step(conn, statement), do: Sqlite3NIF.step(conn, statement)
@spec multi_step(db(), statement()) ::
:busy | {:rows, [row()]} | {:done, [row()]} | {:error, reason()}
def multi_step(conn, statement) do
chunk_size = Application.get_env(:exqlite, :default_chunk_size, 50)
multi_step(conn, statement, chunk_size)
end
@spec multi_step(db(), statement(), integer()) ::
:busy | {:rows, [row()]} | {:done, [row()]} | {:error, reason()}
def multi_step(conn, statement, chunk_size) do
case Sqlite3NIF.multi_step(conn, statement, chunk_size) do
:busy ->
:busy
{:error, reason} ->
{:error, reason}
{:rows, rows} ->
{:rows, Enum.reverse(rows)}
{:done, rows} ->
{:done, Enum.reverse(rows)}
end
end
@spec last_insert_rowid(db()) :: {:ok, integer()}
def last_insert_rowid(conn), do: Sqlite3NIF.last_insert_rowid(conn)
@spec transaction_status(db()) :: {:ok, :idle | :transaction}
def transaction_status(conn), do: Sqlite3NIF.transaction_status(conn)
@doc """
Causes the database connection to free as much memory as it can. This is
useful if you are on a memory restricted system.
"""
@spec shrink_memory(db()) :: :ok | {:error, reason()}
def shrink_memory(conn) do
Sqlite3NIF.execute(conn, "PRAGMA shrink_memory")
end
@spec fetch_all(db(), statement(), integer()) :: {:ok, [row()]} | {:error, reason()}
def fetch_all(conn, statement, chunk_size) do
{:ok, try_fetch_all(conn, statement, chunk_size)}
catch
:throw, {:error, _reason} = error -> error
end
defp try_fetch_all(conn, statement, chunk_size) do
case multi_step(conn, statement, chunk_size) do
{:done, rows} -> rows
{:rows, rows} -> rows ++ try_fetch_all(conn, statement, chunk_size)
{:error, _reason} = error -> throw(error)
:busy -> throw({:error, "Database busy"})
end
end
@spec fetch_all(db(), statement()) :: {:ok, [row()]} | {:error, reason()}
def fetch_all(conn, statement) do
# Should this be done in the NIF? It can be _much_ faster to build a list
# there, but at the expense that it could block other dirty nifs from
# getting work done.
#
# For now this just works
chunk_size = Application.get_env(:exqlite, :default_chunk_size, 50)
fetch_all(conn, statement, chunk_size)
end
@doc """
Serialize the contents of the database to a binary.
"""
@spec serialize(db(), String.t()) :: {:ok, binary()} | {:error, reason()}
def serialize(conn, database \\ "main") do
Sqlite3NIF.serialize(conn, database)
end
@doc """
Disconnect from database and then reopen as an in-memory database based on
the serialized binary.
"""
@spec deserialize(db(), String.t(), binary()) :: :ok | {:error, reason()}
def deserialize(conn, database \\ "main", serialized) do
Sqlite3NIF.deserialize(conn, database, serialized)
end
def release(_conn, nil), do: :ok
@doc """
Once finished with the prepared statement, call this to release the underlying
resources.
This should be called whenever you are done operating with the prepared statement. If
the system has a high load the garbage collector may not clean up the prepared
statements in a timely manner and causing higher than normal levels of memory
pressure.
If you are operating on limited memory capacity systems, definitely call this.
"""
@spec release(db(), statement()) :: :ok | {:error, reason()}
def release(conn, statement) do
Sqlite3NIF.release(conn, statement)
end
@doc """
Allow loading native extensions.
"""
@spec enable_load_extension(db(), boolean()) :: :ok | {:error, reason()}
def enable_load_extension(conn, flag) do
if flag do
Sqlite3NIF.enable_load_extension(conn, 1)
else
Sqlite3NIF.enable_load_extension(conn, 0)
end
end
@doc """
Send data change notifications to a process.
Each time an insert, update, or delete is performed on the connection provided
as the first argument, a message will be sent to the pid provided as the second argument.
The message is of the form: `{action, db_name, table, row_id}`, where:
* `action` is one of `:insert`, `:update` or `:delete`
* `db_name` is a string representing the database name where the change took place
* `table` is a string representing the table name where the change took place
* `row_id` is an integer representing the unique row id assigned by SQLite
## Restrictions
* There are some conditions where the update hook will not be invoked by SQLite.
See the documentation for [more details](https://www.sqlite.org/c3ref/update_hook.html)
* Only one pid can listen to the changes on a given database connection at a time.
If this function is called multiple times for the same connection, only the last pid will
receive the notifications
* Updates only happen for the connection that is opened. For example, there
are two connections A and B. When an update happens on connection B, the
hook set for connection A will not receive the update, but the hook for
connection B will receive the update.
"""
@spec set_update_hook(db(), pid()) :: :ok | {:error, reason()}
def set_update_hook(conn, pid) do
Sqlite3NIF.set_update_hook(conn, pid)
end
@doc """
Set an authorizer that denies specific SQL operations.
Accepts a list of action atoms to deny. Any SQL statement that triggers a
denied action will fail with a "not authorized" error during preparation.
Pass an empty list to clear the authorizer.
## Action atoms
`:attach`, `:detach`, `:pragma`, `:insert`, `:update`, `:delete`,
`:create_table`, `:drop_table`, `:create_index`, `:drop_index`,
`:create_trigger`, `:drop_trigger`, `:create_view`, `:drop_view`,
`:alter_table`, `:reindex`, `:analyze`, `:function`, `:savepoint`,
`:transaction`, `:read`, `:select`, `:recursive`,
`:create_temp_table`, `:create_temp_index`, `:create_temp_trigger`,
`:create_temp_view`, `:drop_temp_table`, `:drop_temp_index`,
`:drop_temp_trigger`, `:drop_temp_view`, `:create_vtable`, `:drop_vtable`
## Examples
# Block ATTACH and DETACH (prevent cross-database reads)
:ok = Sqlite3.set_authorizer(conn, [:attach, :detach])
# Clear the authorizer
:ok = Sqlite3.set_authorizer(conn, [])
"""
@spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()}
def set_authorizer(conn, deny_list) when is_list(deny_list) do
Sqlite3NIF.set_authorizer(conn, deny_list)
end
@doc """
Send log messages to a process.
Each time a message is logged in SQLite a message will be sent to the pid provided as the argument.
The message is of the form: `{:log, rc, message}`, where:
* `rc` is an integer [result code](https://www.sqlite.org/rescode.html) or an [extended result code](https://www.sqlite.org/rescode.html#extrc)
* `message` is a string representing the log message
See [`SQLITE_CONFIG_LOG`](https://www.sqlite.org/c3ref/c_config_covering_index_scan.html) and
["The Error And Warning Log"](https://www.sqlite.org/errlog.html) for more details.
## Restrictions
* Only one pid can listen to the log messages at a time.
If this function is called multiple times, only the last pid will
receive the notifications
"""
@spec set_log_hook(pid()) :: :ok | {:error, reason()}
def set_log_hook(pid) do
Sqlite3NIF.set_log_hook(pid)
end
@sqlite_ok 0
@doc """
Binds a text value to a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind_text(stmt, 1, "Alice")
:ok
"""
@spec bind_text(statement, non_neg_integer, String.t()) :: :ok
def bind_text(stmt, index, text) do
case Sqlite3NIF.bind_text(stmt, index, text) do
@sqlite_ok -> :ok
rc -> raise Exqlite.Error, message: errmsg(stmt) || errstr(rc)
end
end
@doc """
Binds a blob value to a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind_blob(stmt, 1, <<0, 0, 0>>)
:ok
"""
@spec bind_blob(statement, non_neg_integer, binary) :: :ok
def bind_blob(stmt, index, blob) do
case Sqlite3NIF.bind_blob(stmt, index, blob) do
@sqlite_ok -> :ok
rc -> raise Exqlite.Error, message: errmsg(stmt) || errstr(rc)
end
end
@doc """
Binds an integer value to a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind_integer(stmt, 1, 42)
:ok
"""
@spec bind_integer(statement, non_neg_integer, integer) :: :ok
def bind_integer(stmt, index, integer) do
case Sqlite3NIF.bind_integer(stmt, index, integer) do
@sqlite_ok -> :ok
rc -> raise Exqlite.Error, message: errmsg(stmt) || errstr(rc)
end
end
@doc """
Binds a float value to a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind_float(stmt, 1, 3.14)
:ok
"""
@spec bind_float(statement, non_neg_integer, float) :: :ok
def bind_float(stmt, index, float) do
case Sqlite3NIF.bind_float(stmt, index, float) do
@sqlite_ok -> :ok
rc -> raise Exqlite.Error, message: errmsg(stmt) || errstr(rc)
end
end
@doc """
Binds a null value to a prepared statement.
iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly])
iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT ?")
iex> Sqlite3.bind_null(stmt, 1)
:ok
"""
@spec bind_null(statement, non_neg_integer) :: :ok
def bind_null(stmt, index) do
case Sqlite3NIF.bind_null(stmt, index) do
@sqlite_ok -> :ok
rc -> raise Exqlite.Error, message: errmsg(stmt) || errstr(rc)
end
end
defp errmsg(stmt), do: Sqlite3NIF.errmsg(stmt)
defp errstr(rc), do: Sqlite3NIF.errstr(rc)
defp convert(%Date{} = val), do: Date.to_iso8601(val)
defp convert(%Time{} = val), do: Time.to_iso8601(val)
defp convert(%NaiveDateTime{} = val), do: NaiveDateTime.to_iso8601(val)
defp convert(%DateTime{time_zone: "Etc/UTC"} = val), do: NaiveDateTime.to_iso8601(val)
defp convert(%DateTime{} = datetime) do
raise ArgumentError, "#{inspect(datetime)} is not in UTC"
end
defp convert(val) do
convert_with_type_extensions(type_extensions(), val)
end
defp convert_with_type_extensions(nil, val), do: val
defp convert_with_type_extensions([], val), do: val
defp convert_with_type_extensions([extension | other_extensions], val) do
case extension.convert(val) do
nil ->
convert_with_type_extensions(other_extensions, val)
{:ok, converted} ->
converted
{:error, reason} ->
raise ArgumentError,
"Failed conversion by TypeExtension #{extension}: #{inspect(val)}. Reason: #{inspect(reason)}."
end
end
defp type_extensions do
Application.get_env(:exqlite, :type_extensions)
end
end

View File

@@ -0,0 +1,106 @@
defmodule Exqlite.Sqlite3NIF do
@moduledoc """
This is the module where all of the NIF entry points reside. Calling this directly
should be avoided unless you are aware of what you are doing.
"""
@compile {:autoload, false}
@on_load {:load_nif, 0}
@type db() :: reference()
@type statement() :: reference()
@type reason() :: :atom | String.Chars.t()
@type row() :: list()
def load_nif() do
path = :filename.join(:code.priv_dir(:exqlite), ~c"sqlite3_nif")
:erlang.load_nif(path, 0)
end
@spec open(String.t(), integer()) :: {:ok, db()} | {:error, reason()}
def open(_path, _flags), do: :erlang.nif_error(:not_loaded)
@spec close(db()) :: :ok | {:error, reason()}
def close(_conn), do: :erlang.nif_error(:not_loaded)
@spec interrupt(db()) :: :ok | {:error, reason()}
def interrupt(_conn), do: :erlang.nif_error(:not_loaded)
@spec execute(db(), String.t()) :: :ok | {:error, reason()}
def execute(_conn, _sql), do: :erlang.nif_error(:not_loaded)
@spec changes(db()) :: {:ok, integer()} | {:error, reason()}
def changes(_conn), do: :erlang.nif_error(:not_loaded)
@spec prepare(db(), String.t()) :: {:ok, statement()} | {:error, reason()}
def prepare(_conn, _sql), do: :erlang.nif_error(:not_loaded)
@spec step(db(), statement()) :: :done | :busy | {:row, row()} | {:error, reason()}
def step(_conn, _statement), do: :erlang.nif_error(:not_loaded)
@spec multi_step(db(), statement(), integer()) ::
:busy | {:rows, [row()]} | {:done, [row()]} | {:error, reason()}
def multi_step(_conn, _statement, _chunk_size), do: :erlang.nif_error(:not_loaded)
@spec columns(db(), statement()) :: {:ok, list(binary())} | {:error, reason()}
def columns(_conn, _statement), do: :erlang.nif_error(:not_loaded)
@spec last_insert_rowid(db()) :: {:ok, integer()}
def last_insert_rowid(_conn), do: :erlang.nif_error(:not_loaded)
@spec transaction_status(db()) :: {:ok, :idle | :transaction}
def transaction_status(_conn), do: :erlang.nif_error(:not_loaded)
@spec serialize(db(), String.t()) :: {:ok, binary()} | {:error, reason()}
def serialize(_conn, _database), do: :erlang.nif_error(:not_loaded)
@spec deserialize(db(), String.t(), binary()) :: :ok | {:error, reason()}
def deserialize(_conn, _database, _serialized), do: :erlang.nif_error(:not_loaded)
@spec release(db(), statement()) :: :ok | {:error, reason()}
def release(_conn, _statement), do: :erlang.nif_error(:not_loaded)
@spec enable_load_extension(db(), integer()) :: :ok | {:error, reason()}
def enable_load_extension(_conn, _flag), do: :erlang.nif_error(:not_loaded)
@spec set_update_hook(db(), pid()) :: :ok | {:error, reason()}
def set_update_hook(_conn, _pid), do: :erlang.nif_error(:not_loaded)
@spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()}
def set_authorizer(_conn, _deny_list), do: :erlang.nif_error(:not_loaded)
@spec set_log_hook(pid()) :: :ok | {:error, reason()}
def set_log_hook(_pid), do: :erlang.nif_error(:not_loaded)
@spec bind_parameter_count(statement) :: integer
def bind_parameter_count(_stmt), do: :erlang.nif_error(:not_loaded)
@spec bind_parameter_index(statement, String.t()) :: integer
def bind_parameter_index(_stmt, _name), do: :erlang.nif_error(:not_loaded)
@spec bind_text(statement, non_neg_integer, String.t()) :: integer()
def bind_text(_stmt, _index, _text), do: :erlang.nif_error(:not_loaded)
@spec bind_blob(statement, non_neg_integer, binary) :: integer()
def bind_blob(_stmt, _index, _blob), do: :erlang.nif_error(:not_loaded)
@spec bind_integer(statement, non_neg_integer, integer) :: integer()
def bind_integer(_stmt, _index, _integer), do: :erlang.nif_error(:not_loaded)
@spec bind_float(statement, non_neg_integer, float) :: integer()
def bind_float(_stmt, _index, _float), do: :erlang.nif_error(:not_loaded)
@spec bind_null(statement, non_neg_integer) :: integer()
def bind_null(_stmt, _index), do: :erlang.nif_error(:not_loaded)
@spec reset(statement) :: :ok
def reset(_stmt), do: :erlang.nif_error(:not_loaded)
@spec errmsg(db | statement) :: String.t() | nil
def errmsg(_db_or_stmt), do: :erlang.nif_error(:not_loaded)
@spec errstr(integer) :: String.t()
def errstr(_rc), do: :erlang.nif_error(:not_loaded)
# add statement inspection tooling https://sqlite.org/c3ref/expanded_sql.html
end

View File

@@ -0,0 +1,42 @@
defmodule Exqlite.Stream do
@moduledoc false
defstruct [:conn, :query, :params, :options]
@type t :: %Exqlite.Stream{}
defimpl Enumerable do
def reduce(%Exqlite.Stream{query: %Exqlite.Query{} = query} = stream, acc, fun) do
# Possibly need to pass a chunk size option along so that we can let
# the NIF chunk it.
%Exqlite.Stream{conn: conn, params: params, options: opts} = stream
stream = %DBConnection.Stream{
conn: conn,
query: query,
params: params,
opts: opts
}
DBConnection.reduce(stream, acc, fun)
end
def reduce(%Exqlite.Stream{query: statement} = stream, acc, fun) do
%Exqlite.Stream{conn: conn, params: params, options: opts} = stream
query = %Exqlite.Query{name: "", statement: statement}
stream = %DBConnection.PrepareStream{
conn: conn,
query: query,
params: params,
opts: opts
}
DBConnection.reduce(stream, acc, fun)
end
def member?(_, _), do: {:error, __MODULE__}
def count(_), do: {:error, __MODULE__}
def slice(_), do: {:error, __MODULE__}
end
end

View File

@@ -0,0 +1,14 @@
defmodule Exqlite.TypeExtension do
@moduledoc """
A behaviour that defines the API for extensions providing custom data loaders and dumpers
for Ecto schemas.
"""
@doc """
Takes a value and convers it to data suitable for storage in the database.
Returns a tagged :ok/:error tuple. If the value is not convertable by this
extension, returns nil.
"""
@callback convert(value :: term) :: {:ok, term} | {:error, reason :: term} | nil
end