From 85c76ec0aaf7e14bd34645d54d5136054081e60e Mon Sep 17 00:00:00 2001 From: Dalton Alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:38:37 +0000 Subject: [PATCH 1/2] feat: finer-grained :mode support for Sqlite3.open (align with sqlite3_open_v2) - `:readwrite` atom keeps backward-compatible "create if needed" behavior. - Lists now allow precise control: - `[:readwrite]` for read/write without CREATE (errors if file missing). - `[:readwrite, :create]` (or default list form) for with create. - `:create` supported in lists. - Updated types, docs, error messages, and tests. - Added test covering no-create list vs compat default. Refs #347 (proposal accepted in spirit by maintainer @warmwaffles). This is a non-breaking enhancement for users who opt into list forms. --- CHANGELOG.md | 2 ++ lib/exqlite/sqlite3.ex | 37 ++++++++++++++++++++++++++--------- test/exqlite/sqlite3_test.exs | 22 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c06be67..4583ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: Finer-grained `:mode` support when opening databases (e.g. `[:readwrite]` for read/write without implicit CREATE, `[:readwrite, :create]`). The atom `:readwrite` default is preserved for backward compatibility (still creates if needed). This aligns the API more closely with sqlite3_open_v2 flags. See #347. + ## v0.37.0 - added: `Exqlite.Sqlite3.cancel/1` to cancel a running query, waking both the busy handler and VDBE execution. diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index e582f55..8509c85 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -19,7 +19,7 @@ defmodule Exqlite.Sqlite3 do @type statement() :: reference() @type reason() :: atom() | String.t() @type row() :: list() - @type open_mode :: :readwrite | :readonly | :nomutex + @type open_mode :: :readwrite | :readonly | :nomutex | :create @type open_opt :: {:mode, :readwrite | :readonly | [open_mode()]} @doc """ @@ -29,11 +29,23 @@ defmodule Exqlite.Sqlite3 do ## 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. + * `:mode` - controls the flags for sqlite3_open_v2 (see + https://www.sqlite.org/c3ref/c_open_autoproxy.html). Defaults to + `:readwrite` (opens for reading and writing and creates the file if it + does not exist — this atom form is preserved for backward compatibility). + + Finer-grained control (to align with sqlite3_open_v2 flags, see + https://github.com/elixir-sqlite/exqlite/issues/347): + + - `:readwrite` (atom) — read/write + create if needed (compat default). + - `:readonly` — read-only (file must exist). + - Lists (recommended for new code): + - `[:readwrite]` — read/write only; will *not* create the file. + - `[:readwrite, :create]` — read/write + create if needed. + - `[:readonly, :nomutex]`, `[:readwrite, :nomutex]`, `[:create]` (in lists), etc. + - `:create` is now a valid list element to request the CREATE flag. + + Note: `[:readwrite, :nomutex]` is not recommended. """ @spec open(String.t(), [open_opt()]) :: {:ok, db()} | {:error, reason()} def open(path, opts \\ []) do @@ -46,8 +58,10 @@ defmodule Exqlite.Sqlite3 do "expected mode to be `:readwrite` or `:readonly`, can't use a single :nomutex mode" end + # Atom `:readwrite` keeps the historical "also create the file" behavior + # for backward compatibility (most existing code and the default rely on it). defp flags_from_mode(:readwrite), - do: do_flags_from_mode([:readwrite], []) + do: do_flags_from_mode([:readwrite, :create], []) defp flags_from_mode(:readonly), do: do_flags_from_mode([:readonly], []) @@ -60,8 +74,10 @@ defmodule Exqlite.Sqlite3 do "expected mode to be `:readwrite`, `:readonly` or list of modes, but received #{inspect(mode)}" end + # List context: `:readwrite` adds *only* READWRITE (no implicit CREATE). + # Users must explicitly list `:create` when they want it. defp do_flags_from_mode([:readwrite | tail], acc), - do: do_flags_from_mode(tail, [:sqlite_open_readwrite, :sqlite_open_create | acc]) + do: do_flags_from_mode(tail, [:sqlite_open_readwrite | acc]) defp do_flags_from_mode([:readonly | tail], acc), do: do_flags_from_mode(tail, [:sqlite_open_readonly | acc]) @@ -69,9 +85,12 @@ defmodule Exqlite.Sqlite3 do defp do_flags_from_mode([:nomutex | tail], acc), do: do_flags_from_mode(tail, [:sqlite_open_nomutex | acc]) + defp do_flags_from_mode([:create | tail], acc), + do: do_flags_from_mode(tail, [:sqlite_open_create | acc]) + defp do_flags_from_mode([mode | _tail], _acc) do raise ArgumentError, - "expected mode to be `:readwrite`, `:readonly` or `:nomutex`, but received #{inspect(mode)}" + "expected mode to be `:readwrite`, `:readonly`, `:nomutex` or `:create`, but received #{inspect(mode)}" end defp do_flags_from_mode([], acc), diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 7ae9adb..408fae2 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -102,12 +102,32 @@ defmodule Exqlite.Sqlite3Test do {:ok, path} = Temp.path() msg = - "expected mode to be `:readwrite`, `:readonly` or `:nomutex`, but received :notarealmode" + "expected mode to be `:readwrite`, `:readonly`, `:nomutex` or `:create`, but received :notarealmode" assert_raise ArgumentError, msg, fn -> Sqlite3.open(path, mode: [:notarealmode]) end end + + test "opens with list [:readwrite] (no implicit create) vs default atom (creates)" do + {:ok, path} = Temp.path() + + # Pure :readwrite list should not create the file + assert {:error, _reason} = Sqlite3.open(path, mode: [:readwrite]) + + # Atom default (and [:readwrite, :create]) still create for compat + {:ok, conn} = Sqlite3.open(path) + :ok = Sqlite3.close(conn) + assert File.exists?(path) + File.rm!(path) + + # Explicit list with create also works + {:ok, path2} = Temp.path() + {:ok, conn2} = Sqlite3.open(path2, mode: [:readwrite, :create]) + :ok = Sqlite3.close(conn2) + assert File.exists?(path2) + File.rm!(path2) + end end describe ".close/2" do From ce5fb0edd30188055160d70b52532c35f50a8145 Mon Sep 17 00:00:00 2001 From: Dalton Alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:16:05 +0000 Subject: [PATCH 2/2] fix: address sqlite open mode review feedback --- lib/exqlite/connection.ex | 10 +++++----- lib/exqlite/sqlite3.ex | 36 ++++++++++++++++------------------- test/exqlite/sqlite3_test.exs | 7 ++++--- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/lib/exqlite/connection.ex b/lib/exqlite/connection.ex index ed4dad6..261c4b5 100644 --- a/lib/exqlite/connection.ex +++ b/lib/exqlite/connection.ex @@ -98,11 +98,11 @@ defmodule Exqlite.Connection do * `: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. + * `:mode` - controls the sqlite3_open_v2 flags. Defaults to + `[:readwrite, :create]` (read/write + create if needed). Use + `:readwrite` for read/write without create, `:readonly` for read-only, or + a list such as `[:readwrite, :create]` or `[:readonly, :nomutex]`. + 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 diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 8509c85..345dc3e 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -20,7 +20,7 @@ defmodule Exqlite.Sqlite3 do @type reason() :: atom() | String.t() @type row() :: list() @type open_mode :: :readwrite | :readonly | :nomutex | :create - @type open_opt :: {:mode, :readwrite | :readonly | [open_mode()]} + @type open_opt :: {:mode, :readwrite | :readonly | :create | [open_mode()]} @doc """ Opens a new sqlite database at the Path provided. @@ -31,25 +31,26 @@ defmodule Exqlite.Sqlite3 do * `:mode` - controls the flags for sqlite3_open_v2 (see https://www.sqlite.org/c3ref/c_open_autoproxy.html). Defaults to - `:readwrite` (opens for reading and writing and creates the file if it - does not exist — this atom form is preserved for backward compatibility). + `[:readwrite, :create]` (opens for reading and writing and creates the + file if it does not exist). - Finer-grained control (to align with sqlite3_open_v2 flags, see - https://github.com/elixir-sqlite/exqlite/issues/347): + Single modes are permitted: + - `:readwrite` - read/write to the database. Does not create the database + if it is not present. Use in combination with `:create` to create the + database if it does not exist. + - `:readonly` - read-only (file must exist). + - `:create` - creates the database if it does not exist. - - `:readwrite` (atom) — read/write + create if needed (compat default). - - `:readonly` — read-only (file must exist). - - Lists (recommended for new code): - - `[:readwrite]` — read/write only; will *not* create the file. - - `[:readwrite, :create]` — read/write + create if needed. - - `[:readonly, :nomutex]`, `[:readwrite, :nomutex]`, `[:create]` (in lists), etc. - - `:create` is now a valid list element to request the CREATE flag. + Combinations are permitted: + - `[:readwrite, :create]` - read/write + create if needed. This is the + default if not specified. + - `[:readonly, :nomutex]` 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) + mode = opts[:mode] || [:readwrite, :create] Sqlite3NIF.open(path, flags_from_mode(mode)) end @@ -58,13 +59,8 @@ defmodule Exqlite.Sqlite3 do "expected mode to be `:readwrite` or `:readonly`, can't use a single :nomutex mode" end - # Atom `:readwrite` keeps the historical "also create the file" behavior - # for backward compatibility (most existing code and the default rely on it). - defp flags_from_mode(:readwrite), - do: do_flags_from_mode([:readwrite, :create], []) - - defp flags_from_mode(:readonly), - do: do_flags_from_mode([:readonly], []) + defp flags_from_mode(mode) when mode in [:readwrite, :readonly, :create], + do: do_flags_from_mode(List.wrap(mode), []) defp flags_from_mode([_ | _] = modes), do: do_flags_from_mode(modes, []) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 408fae2..069d6b4 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -109,13 +109,14 @@ defmodule Exqlite.Sqlite3Test do end end - test "opens with list [:readwrite] (no implicit create) vs default atom (creates)" do + test "opens with default create but explicit readwrite does not create" do {:ok, path} = Temp.path() - # Pure :readwrite list should not create the file + # Pure readwrite modes should not create the file. + assert {:error, _reason} = Sqlite3.open(path, mode: :readwrite) assert {:error, _reason} = Sqlite3.open(path, mode: [:readwrite]) - # Atom default (and [:readwrite, :create]) still create for compat + # The default (and [:readwrite, :create]) still creates. {:ok, conn} = Sqlite3.open(path) :ok = Sqlite3.close(conn) assert File.exists?(path)