Current section
Files
Jump to
Current section
Files
lib/ex_sql/ecto/connection.ex
defmodule ExSQL.Ecto.Connection do
@moduledoc false
use DBConnection
alias ExSQL.{Database, Executor, FileFormat, Registry}
alias ExSQL.Ecto.{Error, Query, Result}
defstruct db: nil,
database: nil,
dirty?: false,
transaction_status: :idle,
status: :idle,
journal_mode: :memory,
synced_version: 0
@type t :: %__MODULE__{
db: Database.t(),
database: String.t() | :memory | nil,
dirty?: boolean(),
transaction_status: :idle | :transaction,
status: :idle | :busy,
journal_mode: atom() | String.t(),
synced_version: non_neg_integer() | :no_registry
}
@impl true
def connect(opts) do
database = Keyword.get(opts, :database, :memory)
journal_mode = Keyword.get(opts, :journal_mode, :memory)
# Capture the version *before* reading the file: if a write lands in
# between, we record the older version and reload on our next read rather
# than silently trusting a snapshot that is already one commit stale.
synced_version = Registry.current_version(database)
with {:ok, db} <- open_database(database) do
{:ok,
%__MODULE__{
db: db,
database: database,
journal_mode: journal_mode,
synced_version: synced_version
}}
end
end
@impl true
def disconnect(_err, state) do
_ = persist(state)
:ok
end
@impl true
def checkout(%__MODULE__{status: :idle} = state) do
{:ok, %{state | status: :busy}}
end
def checkout(%__MODULE__{status: :busy} = state) do
{:disconnect, %Error{message: "Database is busy"}, state}
end
@impl true
def ping(state), do: {:ok, state}
@impl true
def handle_prepare(%Query{} = query, _opts, state) do
{:ok, query, state}
end
@impl true
def handle_execute(%Query{} = query, params, _opts, state) do
execute_query(query, params, state)
end
@impl true
def handle_begin(opts, state) do
state = maybe_reload(state)
mode = Keyword.get(opts, :mode, :deferred)
sql =
if state.transaction_status == :idle do
begin_sql(mode)
else
"SAVEPOINT exsql_savepoint"
end
run_transaction_sql(sql, state, :transaction, :without_query)
end
@impl true
def handle_commit(opts, state) do
mode = Keyword.get(opts, :mode, :deferred)
sql =
if mode == :savepoint do
"RELEASE SAVEPOINT exsql_savepoint"
else
"COMMIT"
end
status =
if outer_transaction_end?(state, mode) do
:idle
else
:transaction
end
run_transaction_sql(sql, state, status, :without_query)
end
@impl true
def handle_rollback(opts, state) do
mode = Keyword.get(opts, :mode, :deferred)
sql =
if mode == :savepoint do
"ROLLBACK TO SAVEPOINT exsql_savepoint; RELEASE SAVEPOINT exsql_savepoint"
else
"ROLLBACK"
end
status =
if outer_transaction_end?(state, mode) do
:idle
else
:transaction
end
case run_transaction_sql(sql, state, status, :without_query) do
{:ok, result, state} ->
if mode == :savepoint do
{:ok, result, state}
else
{:ok, result, %{state | dirty?: false}}
end
other ->
other
end
end
@impl true
def handle_status(_opts, state), do: {state.transaction_status, state}
@impl true
def handle_close(_query, _opts, state), do: {:ok, nil, state}
@impl true
def handle_declare(%Query{} = query, params, _opts, state) do
state = maybe_reload(state)
statement = IO.iodata_to_binary(query.statement)
case Executor.run(state.db, statement, params) do
{:ok, [result], db} ->
cursor = %{result: Result.from_exsql(result), offset: 0}
state = %{state | db: db, dirty?: state.dirty? or mutating?(result.command, false)}
{:ok, query, cursor, state}
{:ok, results, db} ->
{:error,
%Error{
message: "expected one statement, got #{length(results)}",
statement: statement
}, %{state | db: db, dirty?: true}}
{:error, error, db} ->
{:error, Error.from_exsql(error, statement), %{state | db: db, dirty?: true}}
end
end
@impl true
def handle_fetch(_query, %{result: result, offset: offset} = cursor, opts, state) do
max_rows = Keyword.get(opts, :max_rows, 500)
rows = Enum.slice(result.rows, offset, max_rows)
next_offset = offset + length(rows)
result = %{result | rows: rows, num_rows: length(rows)}
if next_offset >= length(cursor.result.rows) do
{:halt, result, state}
else
{:cont, result, state}
end
end
@impl true
def handle_deallocate(_query, _cursor, _opts, state), do: {:ok, nil, state}
defp execute_query(%Query{} = query, params, state) do
state = maybe_reload(state)
statement = IO.iodata_to_binary(query.statement)
mutating_statement = mutating_statement?(statement)
case Executor.run(state.db, statement, params) do
{:ok, [result], db} ->
state =
%{state | db: db, dirty?: state.dirty? or mutating?(result.command, mutating_statement)}
|> maybe_persist_after(result.command, mutating_statement)
{:ok, query, Result.from_exsql(result), state}
{:ok, results, db} ->
{:error,
%Error{
message: "expected one statement, got #{length(results)}",
statement: statement
}, %{state | db: db, dirty?: true}}
{:error, error, db} ->
{:error, Error.from_exsql(error, statement), %{state | db: db, dirty?: true}}
end
end
defp run_transaction_sql(sql, state, status, return_shape) do
query = Query.build(statement: sql)
mutating_statement = mutating_statement?(sql)
case Executor.run(state.db, sql, []) do
{:ok, [result], db} ->
command = result.command
result = Result.from_exsql(result)
state =
%{state | db: db, transaction_status: status, dirty?: true}
|> maybe_persist_after(command, mutating_statement)
transaction_result(query, result, state, return_shape)
{:ok, results, db} ->
result = results |> List.last() |> Result.from_exsql()
state = %{state | db: db, transaction_status: status, dirty?: true}
transaction_result(query, result, state, return_shape)
{:error, error, db} ->
{:error, Error.from_exsql(error, sql), %{state | db: db, dirty?: true}}
end
end
defp transaction_result(_query, result, state, :without_query), do: {:ok, result, state}
defp open_database(database) when database in [:memory, ":memory:"] do
{:ok, Database.new()}
end
defp open_database(path) when is_binary(path) do
if File.exists?(path) do
case FileFormat.read(path) do
{:ok, db} -> {:ok, db}
{:error, message} -> {:error, %Error{message: message}}
end
else
path |> Path.dirname() |> File.mkdir_p!()
{:ok, Database.new()}
end
end
defp open_database(_database), do: {:ok, Database.new()}
defp maybe_reload(
%__MODULE__{database: database, transaction_status: :idle, dirty?: false} = state
)
when is_binary(database) and database != ":memory:" do
current = Registry.current_version(database)
cond do
current != :no_registry and current == state.synced_version ->
# Our in-memory database already reflects the latest committed write
# (no other connection has committed since), so skip re-parsing the
# whole file. This is the common single-writer hot path.
state
File.exists?(database) ->
case FileFormat.read(database) do
{:ok, db} -> %{state | db: db, synced_version: current}
{:error, _message} -> state
end
true ->
state
end
end
defp maybe_reload(state), do: state
defp persist(%__MODULE__{database: database, dirty?: true} = state)
when is_binary(database) and database != ":memory:" do
case FileFormat.write(state.db, database, journal_mode: state.journal_mode) do
{:ok, _path} -> {:ok, Registry.bump(database)}
{:error, message} -> {:error, %Error{message: message}}
end
end
defp persist(_state), do: :ok
defp maybe_persist_after(state, command, mutating_statement) do
if state.transaction_status == :idle and mutating?(command, mutating_statement) do
case persist(state) do
{:ok, version} -> %{state | dirty?: false, synced_version: version}
:ok -> %{state | dirty?: false}
{:error, _error} -> state
end
else
state
end
end
defp mutating?(_command, true), do: true
defp mutating?(command, false) do
command not in [
nil,
:select,
:pragma,
:explain,
:begin,
:savepoint,
:rollback
]
end
defp mutating_statement?(statement) when is_binary(statement) do
statement =
statement
|> String.trim()
|> String.upcase()
Regex.match?(
~r/\A(?:WITH\b[\s\S]*?\b(INSERT|UPDATE|DELETE|REPLACE)\b|\b(INSERT|UPDATE|DELETE|REPLACE)\b)/,
statement
)
end
defp mutating_statement?(_statement), do: false
defp begin_sql(:immediate), do: "BEGIN IMMEDIATE TRANSACTION"
defp begin_sql(:exclusive), do: "BEGIN EXCLUSIVE TRANSACTION"
defp begin_sql(_mode), do: "BEGIN TRANSACTION"
defp outer_transaction_end?(state, mode) do
state.transaction_status == :transaction and mode != :savepoint
end
end