Skip to content
This repository was archived by the owner on Mar 19, 2021. It is now read-only.

Commit 42655f6

Browse files
authored
Merge pull request #53 from scouten/server+prepared-stmt-cache
Add prepared statement cache to Sqlitex.Server.
2 parents 6e6475b + a6ed594 commit 42655f6

File tree

3 files changed

+203
-16
lines changed

3 files changed

+203
-16
lines changed

lib/sqlitex/server.ex

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ defmodule Sqlitex.Server do
1717
{:ok, [%{a: 1, b: 1}]}
1818
iex> Sqlitex.Server.query_rows(:example, "SELECT * FROM t ORDER BY a LIMIT 2")
1919
{:ok, %{rows: [[1, 1], [2, 2]], columns: [:a, :b], types: [:INTEGER, :INTEGER]}}
20+
iex> Sqlitex.Server.prepare(:example, "SELECT * FROM t")
21+
{:ok, %{columns: [:a, :b], types: [:INTEGER, :INTEGER]}}
22+
# Subsequent queries using this exact statement will now operate more efficiently
23+
# because this statement has been cached.
24+
iex> Sqlitex.Server.prepare(:example, "INVALID SQL")
25+
{:error, {:sqlite_error, 'near "INVALID": syntax error'}}
2026
iex> Sqlitex.Server.stop(:example)
2127
:ok
2228
iex> :timer.sleep(10) # wait for the process to exit asynchronously
@@ -39,44 +45,68 @@ defmodule Sqlitex.Server do
3945

4046
use GenServer
4147

48+
alias Sqlitex.Statement
49+
alias Sqlitex.Server.StatementCache, as: Cache
50+
51+
@doc """
52+
Starts a SQLite Server (GenServer) instance.
53+
54+
In addition to the options that are typically provided to `GenServer.start_link/3`,
55+
you can also specify `stmt_cache_size: (positive_integer)` to override the default
56+
limit (20) of statements that are cached when calling `prepare/3`.
57+
"""
4258
def start_link(db_path, opts \\ []) do
43-
GenServer.start_link(__MODULE__, db_path, opts)
59+
stmt_cache_size = Keyword.get(opts, :stmt_cache_size, 20)
60+
GenServer.start_link(__MODULE__, {db_path, stmt_cache_size}, opts)
4461
end
4562

4663
## GenServer callbacks
4764

48-
def init(db_path) do
65+
def init({db_path, stmt_cache_size})
66+
when is_integer(stmt_cache_size) and stmt_cache_size > 0
67+
do
4968
case Sqlitex.open(db_path) do
50-
{:ok, db} -> {:ok, db}
69+
{:ok, db} -> {:ok, {db, __MODULE__.StatementCache.new(db, stmt_cache_size)}}
5170
{:error, reason} -> {:stop, reason}
5271
end
5372
end
5473

55-
def handle_call({:exec, sql}, _from, db) do
74+
def handle_call({:exec, sql}, _from, {db, stmt_cache}) do
5675
result = Sqlitex.exec(db, sql)
57-
{:reply, result, db}
76+
{:reply, result, {db, stmt_cache}}
77+
end
78+
79+
def handle_call({:query, sql, opts}, _from, {db, stmt_cache}) do
80+
case query_impl(sql, opts, stmt_cache) do
81+
{:ok, result, new_cache} -> {:reply, {:ok, result}, {db, new_cache}}
82+
err -> {:reply, err, {db, stmt_cache}}
83+
end
5884
end
5985

60-
def handle_call({:query, sql, opts}, _from, db) do
61-
rows = Sqlitex.query(db, sql, opts)
62-
{:reply, rows, db}
86+
def handle_call({:query_rows, sql, opts}, _from, {db, stmt_cache}) do
87+
case query_rows_impl(sql, opts, stmt_cache) do
88+
{:ok, result, new_cache} -> {:reply, {:ok, result}, {db, new_cache}}
89+
err -> {:reply, err, {db, stmt_cache}}
90+
end
6391
end
6492

65-
def handle_call({:query_rows, sql, opts}, _from, db) do
66-
rows = Sqlitex.query_rows(db, sql, opts)
67-
{:reply, rows, db}
93+
def handle_call({:prepare, sql}, _from, {db, stmt_cache}) do
94+
case prepare_impl(sql, stmt_cache) do
95+
{:ok, result, new_cache} -> {:reply, {:ok, result}, {db, new_cache}}
96+
err -> {:reply, err, {db, stmt_cache}}
97+
end
6898
end
6999

70-
def handle_call({:create_table, name, table_opts, cols}, _from, db) do
100+
def handle_call({:create_table, name, table_opts, cols}, _from, {db, stmt_cache}) do
71101
result = Sqlitex.create_table(db, name, table_opts, cols)
72-
{:reply, result, db}
102+
{:reply, result, {db, stmt_cache}}
73103
end
74104

75-
def handle_cast(:stop, db) do
76-
{:stop, :normal, db}
105+
def handle_cast(:stop, {db, stmt_cache}) do
106+
{:stop, :normal, {db, stmt_cache}}
77107
end
78108

79-
def terminate(_reason, db) do
109+
def terminate(_reason, {db, _stmt_cache}) do
80110
Sqlitex.close(db)
81111
:ok
82112
end
@@ -95,6 +125,28 @@ defmodule Sqlitex.Server do
95125
GenServer.call(pid, {:query_rows, sql, opts}, timeout(opts))
96126
end
97127

128+
@doc """
129+
Prepares a SQL statement for future use.
130+
131+
This causes a call to [`sqlite3_prepare_v2`](https://sqlite.org/c3ref/prepare.html)
132+
to be executed in the Server process. To protect the reference to the corresponding
133+
[`sqlite3_stmt` struct](https://sqlite.org/c3ref/stmt.html) from misuse in other
134+
processes, that reference is not passed back. Instead, prepared statements are
135+
cached in the Server process. If a subsequent call to `query/3` or `query_rows/3`
136+
is made with a matching SQL statement, the prepared statement is reused.
137+
138+
Prepared statements are purged from the cache when the cache exceeds a pre-set
139+
limit (20 statements by default).
140+
141+
Returns summary information about the prepared statement
142+
`{:ok, %{columns: [:column1_name, :column2_name,... ], types: [:column1_type, ...]}}`
143+
on success or `{:error, {:reason_code, 'SQLite message'}}` if the statement
144+
could not be prepared.
145+
"""
146+
def prepare(pid, sql, opts \\ []) do
147+
GenServer.call(pid, {:prepare, sql}, timeout(opts))
148+
end
149+
98150
def create_table(pid, name, table_opts \\ [], cols) do
99151
GenServer.call(pid, {:create_table, name, table_opts, cols})
100152
end
@@ -105,5 +157,26 @@ defmodule Sqlitex.Server do
105157

106158
## Helpers
107159

160+
defp query_impl(sql, opts, stmt_cache) do
161+
with {%Cache{} = new_cache, stmt} <- Cache.prepare(stmt_cache, sql),
162+
{:ok, stmt} <- Statement.bind_values(stmt, Keyword.get(opts, :bind, [])),
163+
{:ok, rows} <- Statement.fetch_all(stmt, Keyword.get(opts, :into, [])),
164+
do: {:ok, rows, new_cache}
165+
end
166+
167+
defp query_rows_impl(sql, opts, stmt_cache) do
168+
with {%Cache{} = new_cache, stmt} <- Cache.prepare(stmt_cache, sql),
169+
{:ok, stmt} <- Statement.bind_values(stmt, Keyword.get(opts, :bind, [])),
170+
{:ok, rows} <- Statement.fetch_all(stmt, :raw_list),
171+
do: {:ok,
172+
%{rows: rows, columns: stmt.column_names, types: stmt.column_types},
173+
new_cache}
174+
end
175+
176+
defp prepare_impl(sql, stmt_cache) do
177+
with {%Cache{} = new_cache, stmt} <- Cache.prepare(stmt_cache, sql),
178+
do: {:ok, %{columns: stmt.column_names, types: stmt.column_types}, new_cache}
179+
end
180+
108181
defp timeout(kwopts), do: Keyword.get(kwopts, :timeout, 5000)
109182
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
defmodule Sqlitex.Server.StatementCache do
2+
@moduledoc """
3+
Implements a least-recently used (LRU) cache for prepared SQLite statements.
4+
5+
Caches a fixed number of prepared statements and purges the statements which
6+
were least-recently used when that limit is exceeded.
7+
"""
8+
9+
defstruct db: false, size: 0, limit: 1, cached_stmts: %{}, lru: []
10+
11+
@doc """
12+
Creates a new prepared statement cache.
13+
"""
14+
def new({:connection, _, _} = db, limit) when is_integer(limit) and limit > 0 do
15+
%__MODULE__{db: db, limit: limit}
16+
end
17+
18+
@doc """
19+
Given a statement cache and an SQL statement (string), returns a tuple containing
20+
the updated statement cache and a prepared SQL statement.
21+
22+
If possible, reuses an existing prepared statement; if not, prepares the statement
23+
and adds it to the cache, possibly removing the least-recently used prepared
24+
statement if the designated cache size limit would be exceeded.
25+
26+
Will return `{:error, reason}` if SQLite is unable to prepare the statement.
27+
"""
28+
def prepare(%__MODULE__{cached_stmts: cached_stmts} = cache, sql)
29+
when is_binary(sql) and byte_size(sql) > 0
30+
do
31+
case Map.fetch(cached_stmts, sql) do
32+
{:ok, stmt} -> {update_cache_for_read(cache, sql), stmt}
33+
:error -> prepare_new_statement(cache, sql)
34+
end
35+
end
36+
37+
defp prepare_new_statement(%__MODULE__{db: db} = cache, sql) do
38+
case Sqlitex.Statement.prepare(db, sql) do
39+
{:ok, prepared} ->
40+
cache = cache
41+
|> store_new_stmt(sql, prepared)
42+
|> purge_cache_if_full
43+
|> update_cache_for_read(sql)
44+
45+
{cache, prepared}
46+
error -> error
47+
end
48+
end
49+
50+
defp store_new_stmt(%__MODULE__{size: size, cached_stmts: cached_stmts} = cache,
51+
sql, prepared)
52+
do
53+
%{cache | size: size + 1, cached_stmts: Map.put(cached_stmts, sql, prepared)}
54+
end
55+
56+
defp purge_cache_if_full(%__MODULE__{size: size,
57+
limit: limit,
58+
cached_stmts: cached_stmts,
59+
lru: [purge_victim | lru]} = cache)
60+
when size > limit
61+
do
62+
%{cache | size: size - 1,
63+
cached_stmts: Map.drop(cached_stmts, [purge_victim]),
64+
lru: lru}
65+
end
66+
defp purge_cache_if_full(cache), do: cache
67+
68+
defp update_cache_for_read(%__MODULE__{lru: lru} = cache, sql) do
69+
lru = lru
70+
|> Enum.reject(&(&1 == sql))
71+
|> Kernel.++([sql])
72+
73+
%{cache | lru: lru}
74+
end
75+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule Sqlitex.Server.StatementCacheTest do
2+
use ExUnit.Case
3+
4+
alias Sqlitex.Server.StatementCache, as: S
5+
alias Sqlitex.Statement, as: Stmt
6+
7+
test "basic happy path" do
8+
{:ok, db} = Sqlitex.open(":memory:")
9+
10+
cache = S.new(db, 3)
11+
assert %S{cached_stmts: %{}, db: _, limit: 3, lru: [], size: 0} = cache
12+
13+
{cache, stmt1a} = S.prepare(cache, "SELECT 42")
14+
assert %Stmt{column_names: [:"42"], column_types: [nil], statement: ""} = stmt1a
15+
16+
{cache, stmt2a} = S.prepare(cache, "SELECT 43")
17+
assert %Stmt{column_names: [:"43"], column_types: [nil], statement: ""} = stmt2a
18+
19+
{cache, stmt3} = S.prepare(cache, "SELECT 44")
20+
assert %Stmt{column_names: [:"44"], column_types: [nil], statement: ""} = stmt3
21+
22+
{cache, stmt1b} = S.prepare(cache, "SELECT 42")
23+
assert stmt1a == stmt1b # shouldn't have been purged
24+
25+
{cache, stmt4} = S.prepare(cache, "SELECT 353")
26+
assert %Stmt{column_names: [:"353"], column_types: [nil], statement: ""} = stmt4
27+
28+
{_cache, stmt2b} = S.prepare(cache, "SELECT 42")
29+
refute stmt2a == stmt2b # should have been purged
30+
end
31+
32+
test "relays error in prepare" do
33+
{:ok, db} = Sqlitex.open(":memory:")
34+
cache = S.new(db, 3)
35+
36+
assert {:error, {:sqlite_error, 'near "bogus": syntax error'}}
37+
= S.prepare(cache, "bogus")
38+
end
39+
end

0 commit comments

Comments
 (0)