Statement execution with safety checks, param binding, and result shaping.
By default, all statements are read-only. Destructive operations (writes,
schema changes — INSERT, UPDATE, DELETE and DDL in SQL terms) are blocked
at both the application and database level. Pass read_only: false to
allow write operations.
What counts as a write is the adapter's judgement, via
Lotus.Source.Adapter.sanitize_query/3.
Summary
Types
What the :before_execute plugs of a call left under :assigns, handed to
the :after_query plugs of the same call. It belongs to that call alone, so
the result cache never stores it.
What authorizing a statement produced: the relations preflight proved it
touches, and the assigns its :before_execute plugs left.
What executing a statement produced: the result, and the relations preflight proved the statement touches.
The execute step of a run: takes the statement :before_query returned and
produces the outcome. run/4 supplies one that executes against the source;
a caller that caches executions supplies one that consults the cache first.
Where a result came from: executed against the source, or served from the result cache.
What a run produced, as :after_query sees it: the statement that ran, its
result, the relations, where the result came from, and the assigns of
:before_execute.
What preflight knows about the relations a statement touches.
Functions
Runs the :after_query pipeline on an outcome and returns the result it
yields.
Runs the :before_execute pipeline for a statement and the relations it
touches.
Runs the :before_query pipeline and returns the statement to execute.
Executes a statement: sanitization, preflight, :before_execute, the query
itself and column policy enforcement.
Runs the phases of a statement run around an execute step.
Runs a statement through the full pipeline: :before_query, sanitization,
preflight, :before_execute, execution, column policy enforcement and
:after_query. See run/4.
Types
@type assigns() :: map()
What the :before_execute plugs of a call left under :assigns, handed to
the :after_query plugs of the same call. It belongs to that call alone, so
the result cache never stores it.
What authorizing a statement produced: the relations preflight proved it
touches, and the assigns its :before_execute plugs left.
@type execution() :: %{result: query_result(), relations: relations()}
What executing a statement produced: the result, and the relations preflight proved the statement touches.
This is the unit the result cache stores, so that a caller serving a result
from the cache can still hand the relations to :before_execute.
@type executor() :: (Lotus.Query.Statement.t() -> {:ok, outcome()} | {:error, term()})
The execute step of a run: takes the statement :before_query returned and
produces the outcome. run/4 supplies one that executes against the source;
a caller that caches executions supplies one that consults the cache first.
A step that reports origin :executed has run :before_execute and returns
its assigns. A step that reports :cached has not, and returns none: run/4
runs the gate and takes the assigns from it.
@type opts() :: [ timeout: non_neg_integer(), statement_timeout_ms: non_neg_integer(), read_only: boolean(), search_path: String.t() | nil, scope: term(), context: term(), vars: map() ]
@type origin() :: :executed | :cached
Where a result came from: executed against the source, or served from the result cache.
@type outcome() :: %{ :statement => Lotus.Query.Statement.t(), :result => query_result(), :relations => relations(), :origin => origin(), optional(:assigns) => assigns() }
What a run produced, as :after_query sees it: the statement that ran, its
result, the relations, where the result came from, and the assigns of
:before_execute.
@type query_result() :: Lotus.Result.t()
@type relations() :: Lotus.Preflight.Relations.outcome()
What preflight knows about the relations a statement touches.
A list is a proven set of {schema, table} pairs, and an empty list means
the statement touches no relation (SELECT 1). A tuple means nothing is
known: {:unrestricted, reason} when the adapter cannot name relations and
the host opted in, {:skipped, reason} when the adapter does not preflight
the statement at all. A plug that gates on tables matches when is_list/1
and treats any tuple as unknown.
Functions
@spec after_query(Lotus.Source.Adapter.t(), outcome(), opts()) :: {:ok, query_result()} | {:error, term()}
Runs the :after_query pipeline on an outcome and returns the result it
yields.
The payload carries the statement that ran, the result, the relations
preflight found for it — the same value :before_execute received — the
origin, :executed or :cached, and the assigns the :before_execute plugs
of this call left, %{} when the outcome has none. It runs whether the
result came from the cache or from the source. A plug that changes the result
changes what this call returns; the stored entry keeps the raw execution.
@spec before_execute( Lotus.Source.Adapter.t(), Lotus.Query.Statement.t(), relations(), origin(), opts() ) :: {:ok, assigns()} | {:error, term()}
Runs the :before_execute pipeline for a statement and the relations it
touches.
execute_statement/3 runs this itself, with the relations preflight just
found and origin :executed. run/4 runs it with the relations stored
alongside a cached result and origin :cached, so the gate fires on every
call: a plug that authorizes a statement against its tables is an access
control, and an access control that a warm cache skips is no control at all.
The payload starts with assigns: %{}. Returns the :assigns of the payload
the last plug continued with, for :after_query; %{} when that value is not
a map. The other keys a plug changes are ignored: :before_execute decides
whether the statement proceeds, it does not rewrite it.
@spec before_query(Lotus.Source.Adapter.t(), Lotus.Query.Statement.t(), opts()) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Runs the :before_query pipeline and returns the statement to execute.
:before_query runs first because a plug may rewrite the statement — that is
the point of the hook, for row-level security and tenant predicates.
Sanitization and preflight then apply to what will actually execute, rather
than to the text the caller originally supplied.
A caller that caches the execution runs this phase outside the cache
callback, and builds the cache key from the statement returned here: a plug
that varies on :context must see every call, not only the one that fills
the cache, and two plugs that rewrite differently must not share an entry.
@spec execute_statement(Lotus.Source.Adapter.t(), Lotus.Query.Statement.t(), opts()) :: {:ok, %{result: query_result(), relations: relations(), assigns: assigns()}} | {:error, term()}
Executes a statement: sanitization, preflight, :before_execute, the query
itself and column policy enforcement.
Returns the result, the relations preflight proved the statement touches and
the assigns :before_execute left. The result cache stores the result and the
relations only — see execution/0. The query middleware around this phase
lives in before_query/3 and after_query/4, and [:lotus, :query, *]
telemetry covers this phase only, so a statement served from the cache emits
no query events.
@spec run( Lotus.Source.Adapter.t(), Lotus.Query.Statement.t(), opts(), executor() | nil ) :: {:ok, query_result()} | {:error, term()}
Runs the phases of a statement run around an execute step.
This is the one place the phase order lives. run_statement/3 uses it with
the default step, which executes against the source; Lotus uses it with a
step that consults the result cache. Both get the same phases, the same
payloads and the same telemetry:
:before_query, which may rewrite the statement.- The execute step, with the statement
:before_queryreturned. The default step isexecute_statement/3, which runs sanitization, preflight and:before_executeitself. :before_execute, when the step served a cached result — the relations stored with the entry stand in for preflight, so the gate fires on a hit as it does on a miss.:after_query, with the assigns:before_executeleft on this call.
[:lotus, :run, *] telemetry brackets all of it: :start before the first
phase, :stop after :after_query with the origin and the relations, and
:exception when any phase fails, with the :phase that failed. It fires on
a cache hit and on a halt alike, so a consumer that records who ran what and
whether it was refused attaches to these three events and nothing else.
A step may return {:error, {Lotus.Runner, phase, reason}} to name the phase
that failed; the caller receives {:error, reason}.
@spec run_statement(Lotus.Source.Adapter.t(), Lotus.Query.Statement.t(), opts()) :: {:ok, query_result()} | {:error, term()}
Runs a statement through the full pipeline: :before_query, sanitization,
preflight, :before_execute, execution, column policy enforcement and
:after_query. See run/4.