Lotus.Runner (Lotus v1.1.0)

Copy Markdown View Source

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

assigns()

@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.

authorization()

@type authorization() :: %{relations: relations(), assigns: assigns()}

What authorizing a statement produced: the relations preflight proved it touches, and the assigns its :before_execute plugs left.

execution()

@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.

executor()

@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.

opts()

@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()
]

origin()

@type origin() :: :executed | :cached

Where a result came from: executed against the source, or served from the result cache.

outcome()

@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.

query_result()

@type query_result() :: Lotus.Result.t()

relations()

@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

after_query(adapter, outcome, opts \\ [])

@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.

before_execute(adapter, statement, relations, origin, opts \\ [])

@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.

before_query(adapter, statement, opts \\ [])

@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.

execute_statement(adapter, statement, opts \\ [])

@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.

run(adapter, statement, opts, executor \\ nil)

@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:

  1. :before_query, which may rewrite the statement.
  2. The execute step, with the statement :before_query returned. The default step is execute_statement/3, which runs sanitization, preflight and :before_execute itself.
  3. :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.
  4. :after_query, with the assigns :before_execute left 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}.

run_statement(adapter, statement, opts \\ [])

@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.