From 4bad227fcb7aa59e07ef34e9a5fb5e570eb2f05d Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 01:40:18 +0300 Subject: [PATCH 1/8] add named params --- c_src/sqlite3_nif.c | 25 ++++++++++++++ lib/exqlite/sqlite3.ex | 27 +++++++++++++-- lib/exqlite/sqlite3_nif.ex | 3 ++ test/exqlite/sqlite3_test.exs | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/c_src/sqlite3_nif.c b/c_src/sqlite3_nif.c index 1984e1d..812de16 100644 --- a/c_src/sqlite3_nif.c +++ b/c_src/sqlite3_nif.c @@ -561,6 +561,30 @@ exqlite_bind_parameter_count(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[] return enif_make_int(env, bind_parameter_count); } +/// +/// Get the bind parameter index +/// +ERL_NIF_TERM +exqlite_bind_parameter_index(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) +{ + statement_t* statement; + if (!enif_get_resource(env, argv[0], statement_type, (void**)&statement)) { + return raise_badarg(env, argv[0]); + } + + ERL_NIF_TERM eos = enif_make_int(env, 0); + ErlNifBinary name; + + if (!enif_inspect_iolist_as_binary(env, enif_make_list2(env, argv[1], eos), &name)) { + return raise_badarg(env, argv[1]); + } + + statement_acquire_lock(statement); + int index = sqlite3_bind_parameter_index(statement->statement, (const char*)name.data); + statement_release_lock(statement); + return enif_make_int(env, index); +} + /// /// Binds a text parameter /// @@ -1423,6 +1447,7 @@ static ErlNifFunc nif_funcs[] = { {"prepare", 2, exqlite_prepare, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"reset", 1, exqlite_reset, ERL_NIF_DIRTY_JOB_CPU_BOUND}, {"bind_parameter_count", 1, exqlite_bind_parameter_count}, + {"bind_parameter_index", 2, exqlite_bind_parameter_index}, {"bind_text", 3, exqlite_bind_text}, {"bind_blob", 3, exqlite_bind_blob}, {"bind_integer", 3, exqlite_bind_integer}, diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 64acd12..61acbaa 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -173,11 +173,20 @@ defmodule Exqlite.Sqlite3 do iex> Sqlite3.bind(stmt, [:erlang.list_to_pid(~c"<0.0.0>")]) ** (ArgumentError) unsupported type: #PID<0.0.0> + iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly]) + iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT :a, :b") + iex> Sqlite3.bind(stmt, %{":a" => 42, ":b" => "Alice"}) + iex> Sqlite3.step(conn, stmt) + {:row, [42, "Alice"]} + """ - @spec bind(statement, [bind_value] | nil) :: :ok + @spec bind( + statement, + [bind_value] | %{optional(String.t()) => bind_value} | nil + ) :: :ok def bind(stmt, nil), do: bind(stmt, []) - def bind(stmt, args) do + def bind(stmt, args) when is_list(args) do params_count = bind_parameter_count(stmt) args_count = length(args) @@ -188,6 +197,20 @@ defmodule Exqlite.Sqlite3 do end end + def bind(stmt, args) when is_map(args) do + args = + Enum.map(args, fn {name, value} -> + case Sqlite3NIF.bind_parameter_index(stmt, name) do + 0 -> raise ArgumentError, "unknown parameter: #{inspect(name)}" + idx -> {value, idx} + end + end) + |> Enum.sort_by(fn {_param, idx} -> idx end, :asc) + |> Enum.map(fn {param, _idx} -> param end) + + bind(stmt, args) + end + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp bind_all([param | params], stmt, idx) do case convert(param) do diff --git a/lib/exqlite/sqlite3_nif.ex b/lib/exqlite/sqlite3_nif.ex index 79874fe..d9416a2 100644 --- a/lib/exqlite/sqlite3_nif.ex +++ b/lib/exqlite/sqlite3_nif.ex @@ -72,6 +72,9 @@ defmodule Exqlite.Sqlite3NIF do @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) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 22f4b98..6af2f30 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -298,6 +298,66 @@ defmodule Exqlite.Sqlite3Test do Sqlite3.bind(statement, [other_tz]) end end + + test "binds named parameters like :VVV" do + {:ok, conn} = Sqlite3.open(":memory:") + + {:ok, statement} = + Sqlite3.prepare(conn, "select :42, :pi, :name, :emoji, :blob, :null") + + :ok = + Sqlite3.bind(statement, %{ + ":42" => 42, + ":pi" => 3.14, + ":name" => "Alice", + ":emoji" => "👋", + ":blob" => {:blob, <<0, 1, 2>>}, + ":null" => nil + }) + + assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = + Sqlite3.step(conn, statement) + end + + test "binds named parameters like @VVV" do + {:ok, conn} = Sqlite3.open(":memory:") + + {:ok, statement} = + Sqlite3.prepare(conn, "select @42, @pi, @name, @emoji, @blob, @null") + + :ok = + Sqlite3.bind(statement, %{ + "@42" => 42, + "@pi" => 3.14, + "@name" => "Alice", + "@emoji" => "👋", + "@blob" => {:blob, <<0, 1, 2>>}, + "@null" => nil + }) + + assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = + Sqlite3.step(conn, statement) + end + + test "binds named parameters like $VVV" do + {:ok, conn} = Sqlite3.open(":memory:") + + {:ok, statement} = + Sqlite3.prepare(conn, "select $42, $pi, $name, $emoji, $blob, $null") + + :ok = + Sqlite3.bind(statement, %{ + "$42" => 42, + "$pi" => 3.14, + "$name" => "Alice", + "$emoji" => "👋", + "$blob" => {:blob, <<0, 1, 2>>}, + "$null" => nil + }) + + assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = + Sqlite3.step(conn, statement) + end end describe ".bind_text/3" do @@ -334,6 +394,11 @@ defmodule Exqlite.Sqlite3Test do Sqlite3.bind_text(stmt, 1, _not_text = 1) end end + + test "handled null bytes in text", %{conn: conn, stmt: stmt} do + assert :ok = Sqlite3.bind_text(stmt, 1, "hello\0world") + assert {:row, ["hello\0world"]} = Sqlite3.step(conn, stmt) + end end describe ".bind_blob/3" do From 12732e18d3f22969b84923ebcfd3f2770821db28 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 01:53:25 +0300 Subject: [PATCH 2/8] try out repeating params --- test/exqlite/sqlite3_test.exs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 6af2f30..9edad2f 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -358,6 +358,20 @@ defmodule Exqlite.Sqlite3Test do assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = Sqlite3.step(conn, statement) end + + test "handles repeating named parameters" do + {:ok, conn} = Sqlite3.open(":memory:") + + {:ok, statement} = + Sqlite3.prepare(conn, "select :name, :name, :name") + + :ok = + Sqlite3.bind(statement, %{ + ":name" => "Alice" + }) + + assert {:row, ["Alice", "Alice", "Alice"]} = Sqlite3.step(conn, statement) + end end describe ".bind_text/3" do From 19bc1950cbc82a37cae87e288e428f76c6b4dc8e Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 02:05:24 +0300 Subject: [PATCH 3/8] more tests --- lib/exqlite/sqlite3.ex | 51 +++++++++++++++++++----------- test/exqlite/sqlite3_test.exs | 59 ++++++++++------------------------- 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 61acbaa..478a367 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -174,10 +174,10 @@ defmodule Exqlite.Sqlite3 do ** (ArgumentError) unsupported type: #PID<0.0.0> iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly]) - iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT :a, :b") - iex> Sqlite3.bind(stmt, %{":a" => 42, ":b" => "Alice"}) + iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT :a, @b, $c") + iex> Sqlite3.bind(stmt, %{":a" => 42, "@b" => "Alice", "$c" => nil}) iex> Sqlite3.step(conn, stmt) - {:row, [42, "Alice"]} + {:row, [42, "Alice", nil]} """ @spec bind( @@ -198,21 +198,38 @@ defmodule Exqlite.Sqlite3 do end def bind(stmt, args) when is_map(args) do - args = - Enum.map(args, fn {name, value} -> - case Sqlite3NIF.bind_parameter_index(stmt, name) do - 0 -> raise ArgumentError, "unknown parameter: #{inspect(name)}" - idx -> {value, idx} - end - end) - |> Enum.sort_by(fn {_param, idx} -> idx end, :asc) - |> Enum.map(fn {param, _idx} -> param end) - - bind(stmt, args) + params_count = bind_parameter_count(stmt) + 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 - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 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 + + 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) @@ -225,12 +242,8 @@ defmodule Exqlite.Sqlite3 do {:blob, b} when is_list(b) -> bind_blob(stmt, idx, IO.iodata_to_binary(b)) _other -> raise ArgumentError, "unsupported type: #{inspect(param)}" end - - bind_all(params, stmt, idx + 1) end - defp bind_all([], _stmt, _idx), do: :ok - @spec columns(db(), statement()) :: {:ok, [binary()]} | {:error, reason()} def columns(conn, statement), do: Sqlite3NIF.columns(conn, statement) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 9edad2f..7d0baf3 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -303,74 +303,49 @@ defmodule Exqlite.Sqlite3Test do {:ok, conn} = Sqlite3.open(":memory:") {:ok, statement} = - Sqlite3.prepare(conn, "select :42, :pi, :name, :emoji, :blob, :null") + Sqlite3.prepare(conn, "select :42, @pi, :name, $emoji, :blob, :null") :ok = Sqlite3.bind(statement, %{ ":42" => 42, - ":pi" => 3.14, - ":name" => "Alice", - ":emoji" => "👋", - ":blob" => {:blob, <<0, 1, 2>>}, - ":null" => nil - }) - - assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = - Sqlite3.step(conn, statement) - end - - test "binds named parameters like @VVV" do - {:ok, conn} = Sqlite3.open(":memory:") - - {:ok, statement} = - Sqlite3.prepare(conn, "select @42, @pi, @name, @emoji, @blob, @null") - - :ok = - Sqlite3.bind(statement, %{ - "@42" => 42, "@pi" => 3.14, - "@name" => "Alice", - "@emoji" => "👋", - "@blob" => {:blob, <<0, 1, 2>>}, - "@null" => nil + :":name" => "Alice", + "$emoji" => "👋", + ":blob" => {:blob, <<0, 1, 2>>}, + ~c":null" => nil }) assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = Sqlite3.step(conn, statement) end - test "binds named parameters like $VVV" do + test "handles repeating named parameters" do {:ok, conn} = Sqlite3.open(":memory:") {:ok, statement} = - Sqlite3.prepare(conn, "select $42, $pi, $name, $emoji, $blob, $null") + Sqlite3.prepare(conn, "select :name, :name, :name") :ok = Sqlite3.bind(statement, %{ - "$42" => 42, - "$pi" => 3.14, - "$name" => "Alice", - "$emoji" => "👋", - "$blob" => {:blob, <<0, 1, 2>>}, - "$null" => nil + ":name" => "Alice" }) - assert {:row, [42, 3.14, "Alice", "👋", <<0, 1, 2>>, nil]} = - Sqlite3.step(conn, statement) + assert {:row, ["Alice", "Alice", "Alice"]} = Sqlite3.step(conn, statement) end - test "handles repeating named parameters" do + test "raises an error when too few or too many named parameters" do {:ok, conn} = Sqlite3.open(":memory:") {:ok, statement} = - Sqlite3.prepare(conn, "select :name, :name, :name") + Sqlite3.prepare(conn, "select :name, :age") - :ok = - Sqlite3.bind(statement, %{ - ":name" => "Alice" - }) + assert_raise ArgumentError, ~r"expected 2 named arguments, got 1", fn -> + Sqlite3.bind(statement, %{":name" => "Alice"}) + end - assert {:row, ["Alice", "Alice", "Alice"]} = Sqlite3.step(conn, statement) + assert_raise ArgumentError, ~r"expected 2 named arguments, got 3", fn -> + Sqlite3.bind(statement, %{":name" => "Alice", ":age" => 30, ":extra" => "value"}) + end end end From 8eaf8f1cd4d0383f7d013452127c009b5c17ca8c Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 02:08:55 +0300 Subject: [PATCH 4/8] add .query/3 test --- CHANGELOG.md | 2 ++ test/exqlite/query_test.exs | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b7d60..8d6e87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: Added named params support + ## v0.31.0 - changed: Update sqlite to `3.50.0`. diff --git a/test/exqlite/query_test.exs b/test/exqlite/query_test.exs index 34a0613..cc0e0c8 100644 --- a/test/exqlite/query_test.exs +++ b/test/exqlite/query_test.exs @@ -31,6 +31,16 @@ defmodule Exqlite.QueryTest do assert Enum.to_list(columns["y"]) == ["a", "b", "c"] end + test "named params", %{conn: conn} do + assert Exqlite.query!(conn, "select :a, @b, $c", %{":a" => 1, "@b" => 2, "$c" => 3}) == + %Exqlite.Result{ + command: :execute, + columns: [":a", "@b", "$c"], + rows: [[1, 2, 3]], + num_rows: 1 + } + end + defp create_conn!(_) do opts = [database: "#{Temp.path!()}.db"] From 8ff7dd5a2dc089c7241bdfe0783dacbe4cf373bd Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 02:10:20 +0300 Subject: [PATCH 5/8] rm stray test --- test/exqlite/sqlite3_test.exs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 7d0baf3..7b9431f 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -383,11 +383,6 @@ defmodule Exqlite.Sqlite3Test do Sqlite3.bind_text(stmt, 1, _not_text = 1) end end - - test "handled null bytes in text", %{conn: conn, stmt: stmt} do - assert :ok = Sqlite3.bind_text(stmt, 1, "hello\0world") - assert {:row, ["hello\0world"]} = Sqlite3.step(conn, stmt) - end end describe ".bind_blob/3" do From fa6b2eb477e3ae47a3f9a38108008f30d5544337 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 02:14:46 +0300 Subject: [PATCH 6/8] mv named param @doc example higher --- lib/exqlite/sqlite3.ex | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 478a367..d70e745 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -158,6 +158,12 @@ defmodule Exqlite.Sqlite3 do 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"]) @@ -173,12 +179,6 @@ defmodule Exqlite.Sqlite3 do iex> Sqlite3.bind(stmt, [:erlang.list_to_pid(~c"<0.0.0>")]) ** (ArgumentError) unsupported type: #PID<0.0.0> - iex> {:ok, conn} = Sqlite3.open(":memory:", [:readonly]) - iex> {:ok, stmt} = Sqlite3.prepare(conn, "SELECT :a, @b, $c") - iex> Sqlite3.bind(stmt, %{":a" => 42, "@b" => "Alice", "$c" => nil}) - iex> Sqlite3.step(conn, stmt) - {:row, [42, "Alice", nil]} - """ @spec bind( statement, @@ -229,6 +229,7 @@ defmodule Exqlite.Sqlite3 do 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) From 3cc8665d38a59b53ef3778c37f45d59114fb4370 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 22:25:00 +0300 Subject: [PATCH 7/8] shorter test name --- test/exqlite/sqlite3_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 7b9431f..9379e1e 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -299,7 +299,7 @@ defmodule Exqlite.Sqlite3Test do end end - test "binds named parameters like :VVV" do + test "binds named parameters" do {:ok, conn} = Sqlite3.open(":memory:") {:ok, statement} = From b101183592a1b178dec133b3c9bf2c17a391857e Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Fri, 20 Jun 2025 22:26:43 +0300 Subject: [PATCH 8/8] try emoji param name --- test/exqlite/sqlite3_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 9379e1e..7cd7e07 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -303,14 +303,14 @@ defmodule Exqlite.Sqlite3Test do {:ok, conn} = Sqlite3.open(":memory:") {:ok, statement} = - Sqlite3.prepare(conn, "select :42, @pi, :name, $emoji, :blob, :null") + Sqlite3.prepare(conn, "select :42, @pi, :name, $👋, :blob, :null") :ok = Sqlite3.bind(statement, %{ ":42" => 42, "@pi" => 3.14, :":name" => "Alice", - "$emoji" => "👋", + "$👋" => "👋", ":blob" => {:blob, <<0, 1, 2>>}, ~c":null" => nil })