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

Commit ca2dfbe

Browse files
committed
Implement a prepared statement cache.
We'll use this soon in Sqlitex.Server. Coverage: 100%.
1 parent 91fd7d1 commit ca2dfbe

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
14+
{cache, stmt1a} = S.prepare(cache, "SELECT 42")
15+
assert %Stmt{column_names: [:"42"], column_types: [nil], statement: ""} = stmt1a
16+
17+
{cache, stmt2a} = S.prepare(cache, "SELECT 43")
18+
assert %Stmt{column_names: [:"43"], column_types: [nil], statement: ""} = stmt2a
19+
20+
{cache, stmt3} = S.prepare(cache, "SELECT 44")
21+
assert %Stmt{column_names: [:"44"], column_types: [nil], statement: ""} = stmt3
22+
23+
{cache, stmt1b} = S.prepare(cache, "SELECT 42")
24+
assert stmt1a == stmt1b # shouldn't have been purged
25+
26+
{cache, stmt4} = S.prepare(cache, "SELECT 353")
27+
assert %Stmt{column_names: [:"353"], column_types: [nil], statement: ""} = stmt4
28+
29+
{_cache, stmt2b} = S.prepare(cache, "SELECT 42")
30+
refute stmt2a == stmt2b # should have been purged
31+
end
32+
33+
test "relays error in prepare" do
34+
{:ok, db} = Sqlitex.open(":memory:")
35+
cache = S.new(db, 3)
36+
37+
assert {:error, {:sqlite_error, 'near "bogus": syntax error'}}
38+
= S.prepare(cache, "bogus")
39+
end
40+
end

0 commit comments

Comments
 (0)