defmodule Sqlcx do alias Sqlcx.Config alias Sqlcx.Statement @typedoc "Database connection to an encrypted or unencrypted database." @opaque connection() :: {:connection, reference(), :encrypted | :plaintext} @typedoc "Database connection to an unencrypted database, subtype of `connection()`" @opaque plaintext_connection() :: {:connection, reference(), :encrypted | :plaintext} @typedoc "Database connection to an encrypted database., subtype of `connection()`" @opaque encrypted_connection() :: {:connection, reference(), :encrypted} @typedoc "Database password" @type password() :: String.t() | {:raw, binary()} @typedoc "Options that can be passed to `open/2`, `open!/2`, or `with_db/3`." @type open_options() :: [db_password: password(), db_timeout: timeout()] @typedoc "Options that can be passed to `open/2` or `open!/2` for unencrypted databases." @type open_plaintext_options() :: [db_timeout: timeout()] @typedoc "Options that can be passed to `open/2` or `open!/2` for encrypted databases." @type open_encrypted_options() :: [db_password: password(), db_timeout: timeout()] | [db_timeout: timeout(), db_password: password()] | [db_password: password()] @typedoc "Options that can be passed to the `query` functions." @type query_options() :: [ bind: [Statement.bind_value()], into: Collectable.t(), db_timeout: timeout(), db_chunk_size: pos_integer() ] @typedoc "An error that can be returned by sqlcipher calls. Contains an error type atom and a reason string." @type error() :: {:error, {id :: atom(), reason :: charlist()}} @moduledoc """ Sqlcx gives you a way to create and query SQLCipher (encrypted SQLite) databases. ## Basic Example ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.exec(db, "CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)") :ok iex> Sqlcx.exec(db, "INSERT INTO t VALUES (1, 2, 3), (4, 5, 6)") :ok iex> Sqlcx.query(db, "SELECT * FROM t") {:ok, [[1, 2, 3], [4, 5, 6]]} iex> Sqlcx.query_rows(db, "SELECT * FROM t") {:ok, [%{a: 1, b: 2, c: 3}, %{a: 4, b: 5, c: 6}]} ``` ## Configuration Sqlcx uses the Erlang library [esqlcipher](https://github.com/FelixKiunke/esqlcipher) which accepts a timeout parameter for almost all interactions with the database. The default value for this timeout is 5000 ms. Many functions in Sqlcx accept a `:db_timeout` option that is passed on to the esqlcipher calls and also defaults to 5000 ms. If required, this default value can be overridden globally with the following in your `config.exs`: ``` config :sqlcx, db_timeout: 10_000 # or other positive integer number of ms ``` Another esqlcipher parameter is `:db_chunk_size`. This is the number of rows that are read from native sqlcipher and send to the Erlang process in one bulk. For example, consider a table `mytable` that has 1000 rows. We make the query to get all rows with `db_chunk_size: 500` parameter: ``` Sqlcx.query(db, "select * from mytable", db_chunk_size: 500) ``` In this case, all rows will be passed from native sqlcipher OS thread to the Erlang process in two passes with each pass containing 500 rows. This reduces the overhead of transmitting and copying rows from the native OS sqlcipher thread to the Erlang process by chunking the list of result rows. There will be no difference visible to the end user: The result of the `Sqlcx.query` call will be the same no matter the chunk size. Please decrease `:db_chunk_size` if rows are heavy. The default value is `5000`. If you’re in doubt what to do with this parameter, just leave it as is. The default value will be ok in almost all cases. You can also change the default value like this: ``` config :sqlcx, db_chunk_size: 500 # if most of the database rows are heavy ``` """ @doc "Whether `conn` is a database connection. Can be used in guards." defguard is_db(conn) when is_tuple(conn) and tuple_size(conn) == 3 and elem(conn, 0) == :connection and is_reference(elem(conn, 1)) and (elem(conn, 2) == :encrypted or elem(conn, 2) == :plaintext) @doc "Whether `conn` is a connection to an encrypted database. Can be used in guards." defguard is_encrypted(conn) when is_db(conn) and elem(conn, 2) == :encrypted @doc "Whether `conn` is a connection to an unencrypted/plaintext database. Can be used in guards." defguard is_plaintext(conn) when is_db(conn) and elem(conn, 2) == :plaintext @doc """ Open an sqlite3/sqlcipher database connection that can be queried. If the database file does not yet exist, it will be created. Pass `[db_password: "1234"]` as an options to open an sqlcipher encrypted database. If the database does not exist, an encrypted database will be created on first write. The actual encryption key will be derived from `db_password` using PBKDF2 key derivation by sqlcipher. However, it's possible to specify a raw byte sequence as a key using `[db_password: {:raw, <<...>>}]`. The raw key has to be either 32 or 48 bytes long where the key is 32 bytes and the optional salt is another 16 bytes long. If the salt is not explicitly provided, it will be generated randomly and stored in the first 16 bytes of the database. The raw binary key will be internally hex-encoded and passed to sqlcipher as `x'A0B1C2(...)D3E4F5'` with either 64 or 96 hex chaarcters. In order to avoid confusion or unsafe behavior, string passwords of that format will return an `{:error, {:badarg, _}}`. Please refer to the [sqlcipher documentation](https://www.zetetic.net/sqlcipher/sqlcipher-api/#key) for further information about the generation and usage of encryption keys. Note that opening encrypted databases can take quite some time. This is by design because key derivation uses the PBKDF2 algorithm. If on a slower machine, adjust the timeout accordingly. Returns `{:ok, db}` where `db` is a database connection if the file could be opened or created and could be read (i.e., the `db_password` was correct or the database is not encrypted). If the password is incorrect or a password was given on an unencrypted database (or vice versa), or if the file is corrupted or unreadable, will return an error of the form `{:error, {:baddb, _}}`. Remember to `close/2` the database connection when it is no longer needed (although it will automatically be closed when its process dies or it is garbage collected) """ @spec open(String.t()) :: {:ok, plaintext_connection()} | error() @spec open(String.t(), open_plaintext_options()) :: {:ok, plaintext_connection()} | error() @spec open(String.t(), open_encrypted_options()) :: {:ok, encrypted_connection()} | error() def open(path, opts \\ []) do case Config.db_password(opts) do nil -> :esqlcipher.open(path, Config.db_timeout(opts)) password -> with {:ok, key} <- db_key(password) do :esqlcipher.open_encrypted(path, key, Config.db_timeout(opts)) end end end @doc "Shortcut for `open/2`, raises errors instead of returning error tuples." @spec open!(String.t()) :: plaintext_connection() @spec open!(String.t(), open_plaintext_options()) :: plaintext_connection() @spec open!(String.t(), open_encrypted_options()) :: encrypted_connection() def open!(path, opts \\ []) do case open(path, opts) do {:error, {id, reason}} -> raise Sqlcx.OpenError, id: id, reason: reason, encrypted: Config.db_password(opts) != nil {:ok, db} -> db end end @doc "Close the database connection." @spec close(connection()) :: :ok | error() @spec close(connection(), [db_timeout: timeout()]) :: :ok | error() def close(db, opts \\ []) do :esqlcipher.close(db, Config.db_timeout(opts)) end @doc """ Opens a database connection, executes `fun` on it and closes it. Returns an `{:ok, result}` tuple containing the return value of `fun` or an `t:error/0` tuple if an error occurs while opening the database connection. Note that errors raised by `fun` will remain raised and will not be converted to an error tuple. """ @spec with_db(String.t(), (connection() -> t)) :: {:ok, t} | error() when t: var @spec with_db(String.t(), (connection() -> t), open_options()) :: {:ok, t} | error() when t: var def with_db(path, fun, opts \\ []) do case open(path, opts) do {:ok, db} -> try do res = fun.(db) {:ok, res} after close(db, opts) end error -> error end end @doc """ Opens a database connection, executes `fun` on it and closes it. Returns the result of `fun` or raises an error if the opening the database fails. """ @spec with_db!(String.t(), (connection() -> t)) :: t when t: var @spec with_db!(String.t(), (connection() -> t), open_options()) :: t when t: var def with_db!(path, fun, opts \\ []) do with db <- open!(path, opts) do try do fun.(db) after close(db, opts) end end end @doc """ Change the password of an encrypted database. `password` can either be a String or a `{:raw, <<...>>}` tuple containing a binary raw key. See `open/2`. Note that this function cannot be used to encrypt an unencrypted database. Returns `:ok` or an `t:error/0` if the new key is invalid or rekeying the database fails. """ @spec rekey(encrypted_connection(), String.t()) :: :ok | error() @spec rekey(encrypted_connection(), String.t(), [db_timeout: timeout()]) :: :ok | error() def rekey(db, password, opts \\ []) when is_encrypted(db) do with {:ok, key} <- db_key(password) do :esqlcipher.rekey(key, db, Config.db_timeout(opts)) end end @doc "Shortcut for `rekey/3`, raises errors instead of returning error tuples." @spec rekey!(encrypted_connection(), String.t()) :: :ok @spec rekey!(encrypted_connection(), String.t(), [db_timeout: timeout()]) :: :ok def rekey!(db, password, opts \\ []) when is_encrypted(db) do case rekey(db, password, opts) do {:error, {id, reason}} -> raise Sqlcx.RekeyError, id: id, reason: reason :ok -> :ok end end @doc """ Send a raw SQL statement to the database. This function is intended for running simple/one-off SQL statements. Parameters are not supported here, see `query/3` related functions for those! No results are returned; queries return either `:ok` or an error. This is generally useful for things like re-playing a SQL export back into the database or for simple commands like `BEGIN` or `COMMIT` (but see also `with_transaction/3` and `with_savepoint/4` for transactions). Do not manually insert values into SQL statements as that is prone to [SQL injection](https://en.wikipedia.org/wiki/SQL_injection). If you need the results returned by a query, use the `Sqlcx.Query` interface (see also the `query/3` and `query_rows/3` shortcuts) or work directly with prepared statements using `Sqlcx.Statement`. """ @spec exec(connection(), String.t()) :: :ok | error() @spec exec(connection(), String.t(), [db_timeout: timeout()]) :: :ok | error() def exec(db, sql, opts \\ []) do :esqlcipher.exec(sql, db, Config.db_timeout(opts)) end @doc """ Same as `exec/3` but raises a Sqlcx.QueryError on error. Returns `:ok` otherwise. """ @spec exec!(connection(), String.t()) :: :ok @spec exec!(connection(), String.t(), [db_timeout: timeout()]) :: :ok def exec!(db, sql, opts \\ []) do case :esqlcipher.exec(sql, db, Config.db_timeout(opts)) do :ok -> :ok {:error, {id, reason}} -> raise Sqlcx.QueryError, id: id, reason: reason end end @doc """ Runs a query and returns the results. ## Parameters * `db` - An SQLCipher database connection. * `sql` - The query to run as a string. * `opts` - Options to pass into the query. See below for details. ## Options * `bind` - List/keyword list of values to bind to the sqlite parameters. See `Statement.bind/3` for details. * `into` - The collection to put each row into. If provided, values are `zip`ed with the column names, so `into: %{}` produces a Map (note that column names are Strings, not atoms). Defaults to `nil` which simply outputs each row as a list of values without any column names. * `db_timeout` - The timeout (in ms) to apply to each of the underlying SQLCipher operations. Defaults to `Application.get_env(:sqlcx, :db_timeout)` or `5000` ms if not configured. * `db_chunk_size` - The number of rows to read from native sqlite and send to erlang process in one bulk. Defaults to `Application.get_env(:sqlcx, :db_chunk_size)` or `5000` if not configured. ## Returns * `{:ok, [[values...]...]}` on success * `{:error, _}` on failure. ## Example ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.exec(db, "CREATE TABLE data (cat, name)") :ok iex> Sqlcx.exec(db, "INSERT INTO data VALUES (1, 'a'), (2, 'b'), (2, 'c')") :ok iex> Sqlcx.query(db, "SELECT * FROM data WHERE cat = :cat", bind: [cat: 2]) {:ok, [[2, "b"], [2, "c"]]} iex> Sqlcx.query(db, "SELECT * FROM data WHERE cat = :cat", bind: [cat: 2], into: []) {:ok, [[{"cat", 2}, {"name", "b"}], [{"cat", 2}, {"name", "c"}]]} iex> Sqlcx.query(db, "SELECT * FROM data WHERE cat = :cat", bind: [cat: 2], into: %{}) {:ok, [%{"cat" => 2, "name" => "b"}, %{"cat" => 2, "name" => "c"}]} iex> Sqlcx.close(db) :ok ``` """ @spec query(connection(), String.t()) :: {:ok, [keyword]} | error() @spec query(connection(), String.t(), query_options()) :: {:ok, [keyword]} | error() def query(db, sql, opts \\ []) do with params <- Keyword.get(opts, :bind, []), {:ok, stmt} <- Statement.prepare_bind(db, sql, params, opts), {:ok, res} <- Statement.fetch_all(stmt, opts), do: {:ok, res} end @doc """ Same as `query/3` but raises a `Sqlcx.QueryError` on error. Returns the results otherwise. """ @spec query!(connection(), String.t()) :: [keyword] @spec query!(connection(), String.t(), query_options()) :: [Enum.t] def query!(db, sql, opts \\ []) do case query(db, sql, opts) do {:error, {id, reason}} -> raise Sqlcx.QueryError, id: id, reason: reason {:ok, results} -> results end end @doc """ Runs a query and returns the results as a list of rows each represented as a map. Unlike `query/3`, column names are translated into atoms and rows are `Map`s so values can be accessed idiomatically using a `row.value` syntax. **Questioning:** This function might be dropped in future versions. ## Parameters * `db` - An SQLCipher database. * `sql` - The query to run as a string. * `opts` - Options to pass into the query. See below for details. ## Options * `bind` - List/keyword list of values to bind to the sqlite parameters. See `Statement.bind/3` for details. * `into` - The collection to put each row into. Use `[]` to get keyword lists. Defaults to `%{}`. * `db_timeout` - The timeout (in ms) to apply to each of the underlying SQLCipher operations. Defaults to `Application.get_env(:sqlcx, :db_timeout)` or `5000` ms if not configured. * `db_chunk_size` - The number of rows to read from native sqlite and send to erlang process in one bulk. Defaults to `Application.get_env(:sqlcx, :db_chunk_size)` or `5000` if not configured. ## Returns * `{:ok, [%{name: value}]}` on success * `{:error, _}` on failure. ## Example ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.exec(db, "CREATE TABLE data (cat, name)") :ok iex> Sqlcx.exec(db, "INSERT INTO data VALUES (1, 'a'), (2, 'b'), (2, 'c')") :ok iex> Sqlcx.query_rows(db, "SELECT * FROM data WHERE cat = :cat", bind: [cat: 2]) {:ok, [%{cat: 2, name: "b"}, %{cat: 2, name: "c"}]} iex> Sqlcx.query_rows(db, "SELECT * FROM data WHERE cat = :cat", bind: [cat: 2], into: []) {:ok, [[cat: 2, name: "b"], [cat: 2, name: "c"]]} iex> Sqlcx.close(db) :ok ``` """ @spec query_rows(connection(), String.t()) :: {:ok, %{}} | error() @spec query_rows(connection(), String.t(), query_options()) :: {:ok, %{}} | error() def query_rows(db, sql, opts \\ []) do with fetch_opts <- Keyword.merge([into: %{}], opts) |> Keyword.put(:atomic_names, true), params <- Keyword.get(opts, :bind, []), {:ok, stmt} <- Statement.prepare_bind(db, sql, params, opts), {:ok, rows} <- Statement.fetch_all(stmt, fetch_opts), do: {:ok, rows} end @doc """ Same as `query_rows/3` but raises a Sqlcx.QueryError on error. Returns the results otherwise. """ @spec query_rows!(connection(), String.t()) :: %{} @spec query_rows!(connection(), String.t(), query_options()) :: %{} def query_rows!(db, sql, opts \\ []) do case query_rows(db, sql, opts) do {:error, {id, reason}} -> raise Sqlcx.QueryError, id: id, reason: reason {:ok, results} -> results end end @doc """ Return the number of the rows that have been modified, inserted, or deleted by the last statement. See https://www.sqlite.org/c3ref/changes.html for details information. ## Example iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.exec(db, "create table test (val integer)") :ok iex> Sqlcx.exec(db, "insert into test(val) values (1), (2)") :ok iex> Sqlcx.changes(db) 2 """ @spec changes(connection()) :: non_neg_integer() @spec changes(connection(), [db_timeout: timeout()]) :: non_neg_integer() def changes(db, opts \\ []) do :esqlcipher.changes(db, Config.db_timeout(opts)) end @doc """ Sets a PID to receive notifications about table updates. Messages will come in the shape of: `{action, table, rowid}` * `action`: either `:insert`, `:update`, or `:delete` * `table`: charlist of the table name. Example: `'posts'` * `rowid`: internal rowid index of the row. If the table contains an `INTEGER PRIMARY KEY` column, this column will be the row id. Do note that `INT PRIMARY KEY` and similar will *not* be an alias for the row id. Also note that update hooks do not work on [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. See the sqlite3 docs for [update_hook](https://www.sqlite.org/c3ref/update_hook.html) and for [row ids](https://www.sqlite.org/lang_createtable.html#rowid). """ @spec set_update_hook(connection(), pid()) :: :ok | error() @spec set_update_hook(connection(), pid(), [db_timeout: timeout()]) :: :ok | error() def set_update_hook(db, pid, opts \\ []) do :esqlcipher.set_update_hook(pid, db, Config.db_timeout(opts)) end @doc """ Runs `fun` inside a transaction. If `fun` returns without raising an exception, the transaction will be commited via `COMMIT`. Otherwise, `ROLLBACK` will be called. `fun` will be called with the database connection as its first argument. Use the `args` option to pass a list of further arguments if needed. Note that transactions cannot be nested. Prefer `with_savepoint/3` which also allows nested transactions. See also the `transaction/2` version which takes a code block. Returns the result of the function if it function succeeds. Any errors or exceptions are reraised, and errors in the transaction SQL commands raise an exception as well. ## Example ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.with_transaction(db, fn(db, val) -> ...> Sqlcx.exec(db, "create table foo(id integer)") ...> {:ok, _} = Sqlcx.query(db, "insert into foo (id) values(:value)", bind: [value: val]) ...> end, [args: [42]]) iex> Sqlcx.query_rows(db, "select * from foo") {:ok, [%{id: 42}]} ``` """ @spec with_transaction(connection(), (... -> t)) :: t when t: var @spec with_transaction(connection(), (... -> t), [db_timeout: timeout(), args: [any()]]) ::t when t: var def with_transaction(db, fun, opts \\ []) do args = opts[:args] || [] # If we cannot BEGIN a transaction (e.g., because one is already active), # ROLLBACK should not be executed, so we run this outside of the try block :ok = exec(db, "BEGIN TRANSACTION", opts) try do result = apply(fun, [db | args]) :ok = exec(db, "COMMIT TRANSACTION", opts) result catch type, value -> :ok = exec(db, "ROLLBACK TRANSACTION", opts) reraise_caught(type, value, __STACKTRACE__) end end @doc """ Run a code block inside a transaction. If the `do` block does not raise an exception, the transaction will be commited via `COMMIT`. Otherwise, `ROLLBACK` will be called. Note that transactions cannot be nested. If you need nested transactions, use `savepoint/2`. Since this is implemented as a macro, `Sqlcx` must be `require`d or `import`ed in order to use it. See also the `with_transaction/3` version which takes a function as an argument. Note that this version does not take any further arguments. The default timeout will be applied to the `BEGIN`, `COMMIT` and `ROLLBACK` commands. Returns the result of the function if it function succeeds. Any errors or exceptions are reraised, and errors in the transaction SQL commands raise an exception as well. ## Examples ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.transaction db do ...> Sqlcx.exec(db, "create table foo(id integer)") ...> Sqlcx.exec(db, "insert into foo (id) values(42)") ...> end iex> Sqlcx.query_rows(db, "select * from foo") {:ok, [%{id: 42}]} ``` """ defmacro transaction(db, do: f) do quote do Sqlcx.with_transaction(unquote(db), fn _ -> unquote(Macro.expand(f, __CALLER__)) end, []) end end @doc """ Runs `fun` inside a `SAVEPOINT` transaction. Unlike regular `BEGIN`/`COMMIT` transactions, savepoints can be nested. `fun` will be called with the database connection as its first argument. Use the `args` option to pass a list of further arguments if needed. If `fun` returns without raising an exception, the savepoint will be commited via `RELEASE SAVEPOINT`. Otherwise, `ROLLBACK TO SAVEPOINT` will be called. Even if an inner savepoint is committed, it may be rolled back if an outer transaction is rolled back. By default, a random savepoint name is automatically generated. Pass a `name` option to set your own savepoint name. Be careful with the naming of savepoints, unexpected things might happen if you nest savepoints of the same name. The `name` is also not escaped in the SQL commands, so it should only use alphanumeric characters. See also the sqlite3 docs about [savepoints](https://sqlite.org/lang_savepoint.html) Returns the result of `fun` if the function succeeds. If any exceptions are raised or values are thrown, the savepoint transaction will be rolled back and the exception will be reraised or the value rethrown. This implies that if you nest savepoint transactions, unless you rescue/catch the exception, the outer transaction will also be rolled back because an exception has been raised ## Examples ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.with_savepoint(db, fn(db, value) -> ...> Sqlcx.exec(db, "create table foo (id integer)") ...> {:ok, _} = Sqlcx.query(db, "insert into foo (id) values (?)", bind: [value]) ...> try do ...> Sqlcx.with_savepoint(db, fn(db) -> ...> Sqlcx.exec(db, "insert into foo (id) values (3)") ...> :ok = Sqlcx.exec(db, "not a valid sql query") # Raises an exception ...> end) # will be rolled back because the function fails ...> rescue ...> _ -> "we don't care about the exception" ...> end ...> end, [args: [42]]) iex> Sqlcx.query_rows(db, "select * from foo") {:ok, [%{id: 42}]} ``` """ @spec with_savepoint(connection(), (... -> t)) :: t when t: var @spec with_savepoint(connection(), (... -> t), [db_timeout: timeout(), name: String.t(), args: [any()]]) :: t when t: var def with_savepoint(db, fun, opts \\ []) do sp = opts[:name] || random_id() args = opts[:args] || [] # Run this outside of the try block so the ROLLBACK is not run if this fails :ok = exec(db, "SAVEPOINT #{sp}", opts) try do result = apply(fun, [db | args]) :ok = exec(db, "RELEASE SAVEPOINT #{sp}", opts) result catch type, value -> exec(db, "ROLLBACK TO SAVEPOINT #{sp}", opts) exec(db, "RELEASE SAVEPOINT #{sp}", opts) reraise_caught(type, value, __STACKTRACE__) end end @doc """ Run a code block inside a `SAVEPOINT` transaction. Unlike regular `BEGIN`/`COMMIT` transactions, savepoints can be nested. If the `do` block returns without raising an exception, the savepoint will be commited via `RELEASE SAVEPOINT`. Otherwise, `ROLLBACK TO SAVEPOINT` will be called. Even if an inner savepoint is committed, it may be rolled back if an outer transaction is rolled back. Since this is implemented as a macro, `Sqlcx` must be `require`d or `import`ed in order to use it. See also the `with_savepoint/3` version which takes a function as an argument. Note that this version does not take any further arguments. Thus, the savepoint will be automatically given a random name and the default timeout will be applied to the `SAVEPOINT`, `RELEASE` and `ROLLBACK` commands. Returns `{:ok, result}` if the function succeeds, `{:exception, err}` if the function raises an exception, `{:error, err}` if any of the savepoint sqlite commands fails. ## Examples ``` iex> {:ok, db} = Sqlcx.open(":memory:") iex> Sqlcx.savepoint db do ...> Sqlcx.exec(db, "create table foo(id integer)") ...> Sqlcx.exec(db, "insert into foo (id) values(42)") ...> try do ...> Sqlcx.savepoint db do ...> Sqlcx.exec(db, "insert into foo (id) values (3)") ...> :ok = Sqlcx.exec(db, "not a valid sql query") # Raises an exception ...> end # will be rolled back because the do block fails ...> rescue ...> _ -> "we don't care about the exception" ...> end ...> end iex> Sqlcx.query_rows(db, "select * from foo") {:ok, [%{id: 42}]} ``` """ defmacro savepoint(db, do: f) do quote do Sqlcx.with_savepoint(unquote(db), fn _db -> unquote(Macro.expand(f, __CALLER__)) end, []) end end ## Private Helpers # Transform `{:raw, key}` binary keys into the hex format expected by sqlcipher. # Prevents ambiguities in string keys that are in the hex format. @spec db_key({:raw, String.t()} | String.t()) :: {:ok, String.t()} | error() defp db_key({:raw, key}) when is_binary(key) do if byte_size(key) in [32, 48] do {:ok, <<"x'">> <> Base.encode16(key) <> <<"'">>} else {:error, {:badarg, 'invalid raw key size'}} end end defp db_key(password) when is_binary(password) do if String.match?(password, ~r/x'[[:xdigit:]]{64}([[:xdigit:]]{32})?'/) do {:error, {:badarg, 'password has raw key format; use {:raw, binary()}'}} else {:ok, password} end end defp db_key(_) do {:error, {:badarg, 'invalid password'}} end # Reraise caught exceptions/errors, rethrow caught values, or re-exit caught exits @spec reraise_caught(:error | :throw | :exit, any(), Exception.stacktrace()) :: none() defp reraise_caught(:error, err, stack), do: reraise(Exception.normalize(:error, err, stack), stack) defp reraise_caught(:throw, value, _), do: throw value defp reraise_caught(:exit, reason, _), do: exit(reason) # Get a random, alphanumeric 16-character name for a savepoint @spec random_id() :: String.t() defp random_id, do: "sp_" <> (:crypto.strong_rand_bytes(10) |> Base.encode32()) end