Skip to content

Commit

Permalink
Query named params (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
visciang authored Mar 31, 2022
1 parent ec7865b commit 9e25531
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 17 deletions.
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,45 @@ Few little things to work directly with `Postgrex`.

## Exql.Query

### Results as a list of maps
### Postgrex.Result as a list of maps

```elixir
res = Postgrex.query!(@postgrex_conn, q, "select x, y from table")
[%{"x" => x, "y" => y}, ...] = Query.result(res)
res = Postgrex.query!(conn, "select x, y from table", [])
[%{"x" => x, "y" => y}, ...] = Exql.Query.result_to_map(res)
```

### Named parameters query

```elixir
{:ok, q, p} = Exql.Query.named_params("insert into a_table (x, y1, y2) values (:x, :y, :y)", %{x: "X", y: "Y"})

Postgrex.query!(conn, q, p)
```

### Usage

You may define a convenient wrapper around the two functions above:

```elixir
def query!(conn, stmt, args \\ %{}, opts \\ []) do
{:ok, q, p} = Exql.Query.named_params(stmt, args)
res = Postgrex.query!(conn, q, p, opts)
Query.result_to_map(res)
end
```

so that this:

```elixir
{:ok, q, p} = Exql.Query.named_params("insert into a_table (x, y1, y2) values (:x, :y, :y)", %{x: "X", y: "Y"})
res = Postgrex.query!(conn, q, p)
Exql.Query.result_to_map(res)
```

become this:

```elixir
query!("insert into a_table (x, y1, y2) values (:x, :y, :y)", %{x: "X", y: "Y"})
```

## Exql.Migration
Expand All @@ -34,8 +68,8 @@ in a transaction and acquire a `'LOCK ... SHARE MODE'` ensuring that one and onl
In your application you can call the `Exql.Migration.create_db` and `Exql.Migration.migrate` functions:

```elixir
Exql.Migration.create_db(postgres_credentials, "db_name")
Exql.Migration.migrate(mydb_credentials, "priv/migrations/db_name")
Exql.Migration.create_db(postgres_credentials, "db_name")
Exql.Migration.migrate(mydb_credentials, "priv/migrations/db_name")
```

Check the sample app under `./sample_app` for more details.
Expand Down
77 changes: 75 additions & 2 deletions lib/exql/query.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,83 @@
defmodule Exql.Query do
@moduledoc false

@spec result(Postgrex.Result.t()) :: [map()]
def result(%Postgrex.Result{columns: columns, rows: rows}) do
@named_params_re ~r/
(?:"[^"]+") |
(?:'[^']*') |
(?:[^:])(?P<var>:[\w-]+)(?:[^:]?)
/x

@spec result_to_map(Postgrex.Result.t()) :: [map()]
def result_to_map(%Postgrex.Result{columns: nil}), do: []

def result_to_map(%Postgrex.Result{columns: columns, rows: rows}) do
Enum.map(rows, fn row ->
Enum.zip(columns, row) |> Map.new()
end)
end

@type named_param :: atom() | String.t()
@type named_param_value :: term()
@type named_param_slice :: {named_param(), {integer(), integer()}}

@spec named_params(String.t(), %{named_param() => named_param_value()}) ::
{:error, term()} | {:ok, String.t(), [named_param_value()]}
def named_params(query_stmt, query_args) do
query_args = Map.new(query_args, fn {k, v} -> {to_string(k), v} end)
var_slices = named_params_slices(query_stmt)
var_names = named_params_var_names(var_slices)
var_name_to_pos = var_names |> Enum.with_index(1) |> Map.new()
var_args_bindings = query_args |> Map.keys() |> MapSet.new()

if MapSet.subset?(var_names, var_args_bindings) do
positional_query_stmt = positional_query_stmt(query_stmt, var_slices, var_name_to_pos)
positional_query_args = positional_query_args(query_args, var_name_to_pos)

{:ok, positional_query_stmt, positional_query_args}
else
missing_var_bindings = MapSet.difference(var_names, var_args_bindings) |> Enum.to_list()
{:error, {:missing_var_bindings, missing_var_bindings}}
end
end

@spec named_params_var_names([named_param_slice()]) :: MapSet.t(named_param())
defp named_params_var_names(var_slices) do
MapSet.new(var_slices, fn {var_name, _} -> var_name end)
end

@spec positional_query_stmt(String.t(), [named_param_slice()], %{named_param() => integer()}) :: String.t()
defp positional_query_stmt(query_stmt, var_slices, var_name_to_pos) do
var_slices
|> Enum.reduce({0, []}, fn
{var_name, {offset, len}}, {start_at, acc} ->
idx = Map.fetch!(var_name_to_pos, var_name)
next_acc = ["$#{idx}", String.slice(query_stmt, start_at, offset - start_at) | acc]
next_start_at = offset + len
{next_start_at, next_acc}
end)
|> then(fn {start_at, acc} -> [String.slice(query_stmt, start_at..-1) | acc] end)
|> Enum.reverse()
|> IO.iodata_to_binary()
end

@spec positional_query_args(%{named_param() => named_param_value()}, %{named_param() => integer()}) :: [
named_param_value()
]
defp positional_query_args(query_args, var_name_to_pos) do
var_name_to_pos
|> Map.to_list()
|> Enum.sort_by(fn {_var_name, var_idx} -> var_idx end)
|> Enum.map(fn {var_name, _var_idx} -> Map.fetch!(query_args, var_name) end)
end

@spec named_params_slices(String.t()) :: [named_param_slice()]
defp named_params_slices(query_stmt) do
@named_params_re
|> Regex.scan(query_stmt, capture: :all_but_first, return: :index)
|> List.flatten()
|> Enum.map(fn {offset, len} = slice ->
var_name = String.slice(query_stmt, offset + 1, len - 1)
{var_name, slice}
end)
end
end
10 changes: 5 additions & 5 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
%{
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"credo": {:hex, :credo, "1.6.2", "2f82b29a47c0bb7b72f023bf3a34d151624f1cbe1e6c4e52303b05a11166a701", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ae9dc112bc368e7b145c547bec2ed257ef88955851c15057c7835251a17211c6"},
"db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"},
"credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"},
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"dep_from_hexpm": {:hex, :dep_from_hexpm, "0.3.0", "e820a753364b715e84457836317107c340d3bdcaa21b469272da79f29ef5f5cb", [:mix], [], "hexpm", "55b0c9db6c5666a4358e1d8e799f43f3fa091ef036dc0d09bf5ee9f091f07b6d"},
"dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"postgrex": {:hex, :postgrex, "0.16.1", "f94628a32c571266f53cd1e5fca705e626e2417bf1eee6f868985d14e874160a", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "6b225df32c857b9430619dbe30200a7ae664e23415a771ae9209396ee8eeee64"},
"postgrex": {:hex, :postgrex, "0.16.2", "0f83198d0e73a36e8d716b90f45f3bde75b5eebf4ade4f43fa1f88c90a812f74", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "a9ea589754d9d4d076121090662b7afe155b374897a6550eb288f11d755acfa0"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
Expand Down
2 changes: 1 addition & 1 deletion test/exql_migration_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Test.Exql.Migration do
use ExUnit.Case
use ExUnit.Case, async: true
alias Exql.Migration

@timeout 1_000
Expand Down
33 changes: 29 additions & 4 deletions test/exql_query_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Test.Exql.Query do
use ExUnit.Case
use ExUnit.Case, async: true
alias Exql.Query

@postgrex_conf [
Expand All @@ -26,10 +26,35 @@ defmodule Test.Exql.Query do
end)
end

test "results zip", %{conn: conn} do
Postgrex.query!(conn, "insert into #{@test_table} (x, y) values ('a', 'b')", [])
test "result to map", %{conn: conn} do
res = Postgrex.query!(conn, "select * from #{@test_table}", [])
assert [] = Query.result_to_map(res)

Postgrex.query!(conn, "insert into #{@test_table} (x, y) values ('a', 'b')", [])
res = Postgrex.query!(conn, "select * from #{@test_table}", [])
assert [%{"x" => "a", "y" => "b"}] = Query.result(res)
assert [%{"x" => "a", "y" => "b"}] = Query.result_to_map(res)
end

test "named params", %{conn: conn} do
assert [] = query!(conn, "select * from #{@test_table}")

query!(conn, "insert into #{@test_table} (x, y) values (:x, :y)", %{x: "a", y: "b"})
assert [%{"x" => "a", "y" => "b"}] = query!(conn, "select * from #{@test_table}")
end

test "named params escaping" do
assert {:ok, ~s/insert into ":table" (x, y) values (':literal'::text, $1)/, ["b"]} =
Exql.Query.named_params(~s/insert into ":table" (x, y) values (':literal'::text, :y)/, %{y: "b"})
end

test "named params with missing bindings" do
assert {:error, {:missing_var_bindings, ["y"]}} =
Exql.Query.named_params("insert into a_table values (:x, :y)", %{x: "a"})
end

defp query!(conn, stmt, args \\ %{}, opts \\ []) do
{:ok, q, p} = Exql.Query.named_params(stmt, args)
res = Postgrex.query!(conn, q, p, opts)
Query.result_to_map(res)
end
end

0 comments on commit 9e25531

Please sign in to comment.