Skip to content

Commit 69a6c27

Browse files
authored
Support loading extensions (closes #136) (#160)
* Implements SQLite extensions loading As initial selection for SQLITE extensions we take https://github.com/nalgeon/sqlean with some additions in this fork (https://github.com/mindreframer/sqlean) + packaged as Hex package here: https://hex.pm/packages/ex_sqlean. To keep the usage for Exqlite somewhat simpler and consistent, a new module is introduced: BasicAPI. It allows simple operations without the confusion between Exqlite.Sqlite3 and Exqlite.Connection modules to execute queries and dealing with results. * Bump the oldest supported elixir version to 1.9 (from 1.8) * Rename module: BasicAPI -> Basic * Bump lowest supported OTP to `21` + Elixir `1.8`
1 parent b7852b6 commit 69a6c27

File tree

9 files changed

+181
-2
lines changed

9 files changed

+181
-2
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
push:
66
branches:
77
- main
8+
- "*"
89

910
jobs:
1011
lint:
@@ -42,7 +43,7 @@ jobs:
4243
- elixir: "1.10"
4344
otp: "23"
4445
- elixir: "1.8"
45-
otp: "20"
46+
otp: "21"
4647
steps:
4748
- uses: actions/checkout@v2
4849
- uses: erlef/setup-elixir@v1

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,30 @@ The `Exqlite.Sqlite3` module usage is fairly straight forward.
8585
:ok = Exqlite.Sqlite3.release(conn, statement)
8686
```
8787

88+
### Using SQLite3 native extensions
89+
90+
Exqlite supports loading [run-time loadable SQLite3 extensions](https://www.sqlite.org/loadext.html).
91+
A selection of precompiled extensions for popular CPU types / architectures is available by installing the [ExSqlean](https://github.com/mindreframer/ex_sqlean) package. This package wraps [SQLean: all the missing SQLite functions](https://github.com/nalgeon/sqlean).
92+
93+
```elixir
94+
alias Exqlite.Basic
95+
{:ok, conn} = Basic.open("db.sqlite3")
96+
:ok = Basic.enable_load_extension(conn)
97+
98+
# load the regexp extension - https://github.com/nalgeon/sqlean/blob/main/docs/re.md
99+
Basic.load_extension(conn, ExSqlean.path_for("re"))
100+
101+
# run some queries to test the new `regexp_like` function
102+
{:ok, [[1]], ["value"]} = Basic.exec(conn, "select regexp_like('the year is 2021', ?) as value", ["2021"]) |> Basic.rows()
103+
{:ok, [[0]], ["value"]} = Basic.exec(conn, "select regexp_like('the year is 2021', ?) as value", ["2020"]) |> Basic.rows()
104+
105+
# prevent loading further extensions
106+
:ok = Basic.disable_load_extension(conn)
107+
{:error, %Exqlite.Error{message: "not authorized"}, _} = Basic.load_extension(conn, ExSqlean.path_for("re"))
108+
109+
# close connection
110+
Basic.close(conn)
111+
```
88112

89113
## Why SQLite3
90114

c_src/sqlite3_nif.c

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,38 @@ on_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info)
798798
return 0;
799799
}
800800

801+
802+
//
803+
// Enable extension loading
804+
//
805+
806+
static ERL_NIF_TERM
807+
exqlite_enable_load_extension(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
808+
{
809+
assert(env);
810+
connection_t* conn = NULL;
811+
int rc = SQLITE_OK;
812+
int enable_load_extension_value;
813+
814+
if (argc != 2) {
815+
return enif_make_badarg(env);
816+
}
817+
818+
if (!enif_get_resource(env, argv[0], connection_type, (void**)&conn)) {
819+
return make_error_tuple(env, "invalid_connection");
820+
}
821+
822+
if (!enif_get_int(env, argv[1], &enable_load_extension_value)) {
823+
return make_error_tuple(env, "invalid_enable_load_extension_value");
824+
}
825+
826+
rc = sqlite3_enable_load_extension(conn->db, enable_load_extension_value);
827+
if (rc != SQLITE_OK) {
828+
return make_sqlite3_error_tuple(env, rc, conn->db);
829+
}
830+
return make_atom(env, "ok");
831+
}
832+
801833
//
802834
// Most of our nif functions are going to be IO bounded
803835
//
@@ -817,6 +849,7 @@ static ErlNifFunc nif_funcs[] = {
817849
{"serialize", 2, exqlite_serialize, ERL_NIF_DIRTY_JOB_IO_BOUND},
818850
{"deserialize", 3, exqlite_deserialize, ERL_NIF_DIRTY_JOB_IO_BOUND},
819851
{"release", 2, exqlite_release, ERL_NIF_DIRTY_JOB_IO_BOUND},
852+
{"enable_load_extension", 2, exqlite_enable_load_extension, ERL_NIF_DIRTY_JOB_IO_BOUND},
820853
};
821854

822855
ERL_NIF_INIT(Elixir.Exqlite.Sqlite3NIF, nif_funcs, on_load, NULL, NULL, NULL)

lib/exqlite/basic.ex

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule Exqlite.Basic do
2+
@moduledoc """
3+
A very basis API without lots of options to allow simpler usage for basic needs.
4+
"""
5+
6+
alias Exqlite.Connection
7+
alias Exqlite.Query
8+
alias Exqlite.Sqlite3
9+
alias Exqlite.Error
10+
alias Exqlite.Result
11+
12+
def open(path) do
13+
Connection.connect(database: path)
14+
end
15+
16+
def close(conn = %Connection{}) do
17+
with :ok <- Sqlite3.close(conn.db) do
18+
:ok
19+
else
20+
{:error, reason} -> {:error, %Error{message: reason}}
21+
end
22+
end
23+
24+
def exec(conn = %Connection{}, stmt, args \\ []) do
25+
%Query{statement: stmt} |> Connection.handle_execute(args, [], conn)
26+
end
27+
28+
def rows(exec_result) do
29+
case exec_result do
30+
{:ok, %Query{}, %Result{rows: rows, columns: columns}, %Connection{}} ->
31+
{:ok, rows, columns}
32+
33+
{:error, %Error{message: message}, %Connection{}} ->
34+
{:error, message}
35+
end
36+
end
37+
38+
def load_extension(conn, path) do
39+
exec(conn, "select load_extension(?)", [path])
40+
end
41+
42+
def enable_load_extension(conn) do
43+
Sqlite3.enable_load_extension(conn.db, true)
44+
end
45+
46+
def disable_load_extension(conn) do
47+
Sqlite3.enable_load_extension(conn.db, false)
48+
end
49+
end

lib/exqlite/sqlite3.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ defmodule Exqlite.Sqlite3 do
184184
Sqlite3NIF.release(conn, statement)
185185
end
186186

187+
@doc """
188+
Allow loading native extensions.
189+
"""
190+
@spec enable_load_extension(db(), boolean) :: :ok | {:error, any}
191+
def enable_load_extension(conn, flag) do
192+
if flag do
193+
Sqlite3NIF.enable_load_extension(conn, 1)
194+
else
195+
Sqlite3NIF.enable_load_extension(conn, 0)
196+
end
197+
end
198+
187199
defp convert(%Date{} = val), do: Date.to_iso8601(val)
188200
defp convert(%Time{} = val), do: Time.to_iso8601(val)
189201
defp convert(%NaiveDateTime{} = val), do: NaiveDateTime.to_iso8601(val)

lib/exqlite/sqlite3_nif.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,8 @@ defmodule Exqlite.Sqlite3NIF do
5959
@spec release(db(), statement()) :: :ok | {:error, reason()}
6060
def release(_conn, _statement), do: :erlang.nif_error(:not_loaded)
6161

62+
@spec enable_load_extension(db(), boolean()) :: :ok | {:error, reason()}
63+
def enable_load_extension(_conn, _flag), do: :erlang.nif_error(:not_loaded)
64+
6265
# TODO: add statement inspection tooling https://sqlite.org/c3ref/expanded_sql.html
6366
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ defmodule Exqlite.MixProject do
3737
defp deps do
3838
[
3939
{:db_connection, "~> 2.1"},
40+
{:ex_sqlean, "~> 0.8.4", only: [:dev, :test]},
4041
{:elixir_make, "~> 0.6", runtime: false},
4142
{:ex_doc, "~> 0.24", only: :dev, runtime: false},
42-
{:temp, "~> 0.4", only: [:test]}
43+
{:temp, "~> 0.4", only: [:dev, :test]}
4344
]
4445
end
4546

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"},
55
"elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
66
"ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"},
7+
"ex_sqlean": {:hex, :ex_sqlean, "0.8.4", "eaef82ac52e8525e597cf258471fe7bdb11cbed52b9bfba4ff9492bff80e4bab", [:mix], [], "hexpm", "7225ff5461d6d5ea8cf664278ccaa432cba233216d6805cd76a34ff2c533656d"},
78
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
89
"makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"},
910
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},

test/exqlite/extensions_test.exs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
defmodule Exqlite.ExtensionsTest do
2+
use ExUnit.Case
3+
alias Exqlite.Basic
4+
5+
describe "enable_load_extension" do
6+
test "loading can be enabled / disabled" do
7+
{:ok, path} = Temp.path()
8+
{:ok, conn} = Basic.open(path)
9+
:ok = Basic.enable_load_extension(conn)
10+
11+
{:ok, [[nil]], _} =
12+
Basic.load_extension(conn, ExSqlean.path_for("re")) |> Basic.rows()
13+
14+
{:ok, [[1]], _} =
15+
Basic.exec(conn, "select regexp_like('the year is 2021', '2021')")
16+
|> Basic.rows()
17+
18+
:ok = Basic.disable_load_extension(conn)
19+
20+
{:error, "not authorized"} =
21+
Basic.load_extension(conn, ExSqlean.path_for("re")) |> Basic.rows()
22+
end
23+
24+
test "works for 're' (regex)" do
25+
{:ok, path} = Temp.path()
26+
{:ok, conn} = Basic.open(path)
27+
28+
:ok = Basic.enable_load_extension(conn)
29+
30+
{:ok, [[nil]], _} =
31+
Basic.load_extension(conn, ExSqlean.path_for("re")) |> Basic.rows()
32+
33+
{:ok, [[0]], _} =
34+
Basic.exec(conn, "select regexp_like('the year is 2021', '2k21')")
35+
|> Basic.rows()
36+
37+
{:ok, [[1]], _} =
38+
Basic.exec(conn, "select regexp_like('the year is 2021', '2021')")
39+
|> Basic.rows()
40+
end
41+
42+
test "stats extension" do
43+
{:ok, path} = Temp.path()
44+
{:ok, conn} = Basic.open(path)
45+
46+
:ok = Basic.enable_load_extension(conn)
47+
Basic.load_extension(conn, ExSqlean.path_for("stats"))
48+
Basic.load_extension(conn, ExSqlean.path_for("series"))
49+
50+
{:ok, [[50.5]], ["median(value)"]} =
51+
Basic.exec(conn, "select median(value) from generate_series(1, 100)")
52+
|> Basic.rows()
53+
end
54+
end
55+
end

0 commit comments

Comments
 (0)