Behaviour and struct for database adapters in Lotus.
An adapter wraps a data source behind a uniform interface. Instead of passing
raw Ecto.Repo modules throughout the pipeline, consumers work with an
%Adapter{} struct that carries:
name— a human-readable identifier (e.g."main","warehouse")module— the module implementing this behaviourstate— opaque connection state managed by the adaptersource_type— an atom identifying the database kind (:postgres,:mysql, etc.)
Implementing an adapter
Define a module that uses @behaviour Lotus.Source.Adapter and implements all
required callbacks. Callbacks fall into several groups:
- Query execution —
execute_query/4,transaction/3 - Introspection —
list_schemas/1,list_tables/3,describe_table/3,resolve_table_namespace/3 - SQL generation —
quote_identifier/2,query_plan/3 - Pipeline —
transform_statement/2,transform_bound_query/3,apply_filters/3,apply_sorts/3,apply_pagination/3,needs_preflight?/2,sanitize_query/3,substitute_variable/5,substitute_list_variable/5,validate_statement/3,extract_accessed_resources/2 - Name & operator validation —
parse_qualified_name/2,validate_identifier/3,supported_filter_operators/1 - Safety & visibility —
builtin_denies/1,builtin_schema_denies/1,default_schemas/1 - Lifecycle —
health_check/1,disconnect/1 - Error handling —
format_error/2 - Source identity —
source_type/1,supports_feature?/2
Pipeline Statement contract
All pipeline callbacks operate on a %Lotus.Query.Statement{} struct that
carries the adapter-native payload (:body, an opaque term), :params, and
adapter-specific :meta. Adapters return a new statement with the relevant
field updated — the pipeline is a series of pure statement -> statement
transforms.
Introspection callbacks consistently return {:ok, result} | {:error, reason}
tuples so callers can handle failures uniformly.
Relations are two-level
Everywhere Lotus names a resource it uses exactly two levels:
{schema | nil, table}. That shape is fixed — visibility rules, deny
lists, describe_table/3, resolve_table_namespace/3, the preflight
relation set and extract_accessed_resources/2 all speak it, and core
never grows a third element.
nil in the first position means "unqualified" — a source with no
namespace concept at all (SQLite tables, Elasticsearch indices), or a
name the caller left unqualified.
Engines with a deeper hierarchy flatten everything above the leaf into the schema part, keeping the separator their own query language uses:
- BigQuery
project.dataset.table→{"project.dataset", "table"} - A catalog/schema/table engine →
{"catalog.schema", "table"}
Adapters own that flattening in parse_qualified_name/2 and
resolve_table_namespace/3; core treats the schema part as an opaque
string and compares it verbatim against visibility rules. The practical
consequence for adapter authors: a deny rule the host writes must match
the flattened form your adapter produces, so document the spelling your
adapter emits.
Dispatch helpers
This module provides convenience functions that accept an %Adapter{} struct
and delegate to the underlying module, passing state where needed:
adapter = %Adapter{name: "main", module: MyPostgres, state: conn, source_type: :postgres}
Adapter.execute_query(adapter, "SELECT 1", [], [])
Adapter.list_schemas(adapter)
Adapter.quote_identifier(adapter, "users")
Summary
Types
Per-AI-feature capability declaration. Adapters use this to declare
which AI features they actually support — Lotus.AI.supports?/2
reads it directly to let UIs gate per-source button visibility.
Structured, bounded context an adapter supplies to the AI pipeline.
Structural JSON DSL schema used by lotus_web's JsonDslCompletion to
offer parent-aware completions (e.g., only must/should/filter
inside Elasticsearch's bool block; field names from the schema
inside match/term/range).
Optional count-query description placed in statement.meta[:count_spec] by
apply_pagination/3 when the caller requested count: :exact. Plain data:
:query is the count payload (SQL text, JSON DSL, whatever the adapter's
language calls "count this result set"), and :params are its bound
parameters. Lotus core runs this through the same adapter the paginated
statement ran through — the adapter does not need to remember its own
identity.
Optional SQL tokenizer spec, passed through verbatim (camelCased) to
CodeMirror 6's SQLDialect.define() so external SQL adapters can reach
tokenization parity with the built-in PG / MySQL Lezer grammars.
A capability an adapter may declare through supports_feature?/2.
Callbacks
Return the structured context the AI pipeline uses when generating queries for this source.
Apply filters to the statement, returning a new statement with the filters baked in.
Rewrite a statement to return a single page of rows, and optionally record a count query for the full result set.
Apply sorts to the statement, returning a new statement with the sort order baked in.
Return built-in deny rules for system tables (list of {schema_pattern, table_pattern} tuples).
Return schema patterns that should be hidden from schema listings.
Whether this adapter can handle the given data source entry (e.g. a repo module).
Map a database column type string to a Lotus internal type atom.
Return the default schemas when none are configured.
Return column definitions for a specific table.
Disconnect from the data source and release resources.
Return editor configuration (keywords, types, functions) for the adapter.
Return an example query string for placeholder text in the query editor.
Execute a prepared statement against the data source.
Extract the set of tables/relations a statement will access.
Format a data-source error into a human-readable string.
Check that the data source is reachable.
Return the human-readable label for the top-level hierarchy (e.g. "Tables", "Indices").
Cap a statement at a source-specific row limit.
List all schemas in the data source.
List tables (and optionally views) in the given schemas.
Whether a statement needs the visibility preflight check before execution.
Parse a qualified resource name into its hierarchy components.
Return a statement safe to pass to query_plan/3 for optimization
analysis.
Return the query language identifier for this source.
Return an execution plan for a query.
Quote a SQL identifier (column, table, schema name) using source-specific syntax.
Resolve which schema contains the named table.
Validate that a statement is safe to execute.
Return the source type atom (e.g. :postgres, :mysql).
Substitute a {{var_name}} list-variable placeholder with the given list
of values, returning a new statement.
Substitute a {{var_name}} placeholder in the statement with the given
value, returning a new statement.
Return the Lotus.Query.Filter operators this adapter's apply_filters/3
can handle.
Whether this adapter supports a given feature.
Return statistics for a relation.
Execute a function within a transaction.
Rewrite the statement after variable substitution.
Rewrite the statement before variables are extracted and bound.
Validate that a string is a safe identifier for the given kind in this adapter's query language.
Validate that a statement can be parsed and prepared by the data source without executing it.
Wrap a raw data source entry into an %Adapter{} struct.
Functions
Return the adapter's AI context, with free-form fields capped at safe sizes.
Apply filters to a statement via the adapter. Empty filters short-circuit.
Apply pagination to the statement. The paginated statement carries any
count spec in statement.meta[:count_spec]. Returns the statement
unchanged if the adapter doesn't implement apply_pagination/3.
Apply sorts to a statement via the adapter. Empty sorts short-circuit.
Built-in table-level deny rules to apply when no adapter can be resolved.
Return built-in deny rules via the adapter.
Built-in schema-level deny patterns to apply when no adapter can be resolved.
Return built-in schema denies via the adapter.
Map a database type to a Lotus type via the adapter.
Return default schemas via the adapter.
Get column definitions for a table via the adapter.
Disconnect from the data source via the adapter.
Return editor configuration via the adapter, with list sizes capped at safe limits.
Return an example query via the adapter.
Execute a SQL query via the adapter.
Extract accessed resources for preflight checks. Returns
{:unrestricted, reason} if the adapter doesn't implement the callback —
callers must consult the host-app :allow_unrestricted_resources opt-in
before allowing the statement through.
Format an error via the adapter.
Check data source health via the adapter.
Return the hierarchy label via the adapter.
Wrap a statement with a limit clause via the adapter.
List all schemas via the adapter.
List tables via the adapter.
Whether the statement needs the visibility preflight check. Returns true
if the adapter doesn't implement needs_preflight?/2 — the safer default.
Parse a qualified name into hierarchy components via the adapter.
Returns {:ok, [name]} (single-component) if the adapter does not
implement the callback.
Prepare a statement for optimization analysis via the adapter. Returns
{:error, :unsupported} if the adapter does not implement
prepare_for_analysis/2 — callers skip optimization for that adapter.
Return the query language identifier via the adapter.
Get the execution plan for a statement via the adapter.
Quote a SQL identifier via the adapter.
Resolve which schema contains a table via the adapter.
Validate statement safety via the adapter. Returns :ok if not implemented.
Return the source type via the adapter.
Substitute a {{var}} list variable via the adapter. Returns
{:error, :unsupported} when the adapter does not implement the callback.
Substitute a {{var}} scalar variable via the adapter. Returns
{:error, :unsupported} when the adapter does not implement the callback.
Return the filter operators this adapter's apply_filters/3 supports.
Defaults to all of Lotus.Query.Filter.operators/0 if the adapter does
not declare a subset.
Check feature support via the adapter.
Return statistics for a relation via the adapter.
Execute a function within a transaction via the adapter.
Rewrite the statement after variable substitution, before filters and sorts
are applied. Returns the statement unchanged if the adapter doesn't
implement transform_bound_query/3.
Rewrite the statement before variable binding. Returns the statement
unchanged if the adapter doesn't implement transform_statement/2.
Validate an identifier for the given kind via the adapter. Returns :ok
(permissive) if the adapter does not implement the callback.
Validate a statement via the adapter. Returns :ok if the adapter does
not implement the callback (trust-on-execute).
Types
@type ai_capabilities() :: %{ generation: ai_capability(), optimization: ai_capability(), explanation: ai_capability() }
@type ai_capability() :: true | {false, String.t()}
Per-AI-feature capability declaration. Adapters use this to declare
which AI features they actually support — Lotus.AI.supports?/2
reads it directly to let UIs gate per-source button visibility.
true— feature supported.{false, reason}— feature unsupported; the reason is surfaced to users. For untrusted adapters, reasons are replaced with a generic fallback at the dispatch layer.
Default (when the :capabilities key is absent from ai_context_map):
all three features true — existing adapters inherit the permissive
behavior without needing to declare anything.
@type ai_context_map() :: %{ :language => String.t(), :example_query => String.t(), :syntax_notes => String.t(), :error_patterns => [%{pattern: Regex.t(), hint: String.t()}], optional(:generation_notes) => String.t(), optional(:read_only_notes) => String.t(), optional(:capabilities) => ai_capabilities() }
Structured, bounded context an adapter supplies to the AI pipeline.
Fixed keys; free-form fields are length-capped at the dispatch layer
to bound blast radius from a compromised or noisy adapter. See
Lotus.Source.Adapter.ai_context/1 dispatch for exact limits.
The optional :capabilities map declares per-feature AI support.
@type context_schema() :: %{ :root => [String.t()], :children => %{ required(String.t()) => [String.t()] | atom() | [{String.t(), atom()}] }, optional(:value_literals) => %{required(String.t()) => [String.t()]} }
Structural JSON DSL schema used by lotus_web's JsonDslCompletion to
offer parent-aware completions (e.g., only must/should/filter
inside Elasticsearch's bool block; field names from the schema
inside match/term/range).
:root— valid top-level keys.:children— per-parent-key rules. Values are either a list of valid child keys, or one of the marker atoms:fields(use schema field names),:array_of_query(array element objects accept the same keys as"query"),:named_aggregation(user-named bucket — no key suggestions), or:range_operators(grandparent lookup for ES range-style operators).:value_literals— fixed value-position completions keyed by immediate key (e.g.,"order" => ["asc", "desc"]).
Optional count-query description placed in statement.meta[:count_spec] by
apply_pagination/3 when the caller requested count: :exact. Plain data:
:query is the count payload (SQL text, JSON DSL, whatever the adapter's
language calls "count this result set"), and :params are its bound
parameters. Lotus core runs this through the same adapter the paginated
statement ran through — the adapter does not need to remember its own
identity.
@type dialect_spec() :: %{ optional(:identifier_quotes) => String.t(), optional(:operator_chars) => String.t(), optional(:hash_comments) => boolean(), optional(:slash_comments) => boolean(), optional(:double_quoted_strings) => boolean(), optional(:double_dollar_quoted_strings) => boolean(), optional(:backslash_escapes) => boolean(), optional(:space_after_dashes) => boolean(), optional(:case_insensitive_identifiers) => boolean(), optional(:builtin) => String.t(), optional(:char_set_casts) => boolean(), optional(:plsql_quoting_mechanism) => boolean(), optional(:unquoted_bit_literals) => boolean(), optional(:treat_bits_as_bytes) => boolean(), optional(:special_var) => String.t() }
Optional SQL tokenizer spec, passed through verbatim (camelCased) to
CodeMirror 6's SQLDialect.define() so external SQL adapters can reach
tokenization parity with the built-in PG / MySQL Lezer grammars.
Field names mirror @codemirror/lang-sql's SQLDialectSpec — see
assets/node_modules/@codemirror/lang-sql/dist/index.d.ts in
lotus_web for authoritative semantics.
@type feature() :: :schema_hierarchy | :search_path | :arrays | :json | :make_interval | :dynamic_options | atom()
A capability an adapter may declare through supports_feature?/2.
These are the atoms core and the built-in UI ask about. The type is open —
an adapter may answer questions about its own atoms, and callers that
invent one get false from any adapter that does not recognise it — but
these are the ones with defined meaning:
:schema_hierarchy— the source has a real namespace level above tables, so the UI shows a schema picker. False for flat sources (SQLite, Elasticsearch); false for MySQL, whose databases are configured per source rather than browsed.:search_path— the source honours a session-level namespace search path, so a caller-supplied:search_pathoption is meaningful.:arrays— the query language has a first-class array type, so list variables can bind as one value instead of being expanded into N placeholders.:json— the source can store and query JSON documents, which the editor uses to offer JSON-aware affordances.:make_interval— SQL-specific: the engine has amake_intervalfunction, so the transformer can rewriteINTERVAL '{{n}} days'into a parameterized call instead of inlining the value.:dynamic_options— a query against this source can return a flat list of values suitable for populating a variable's dropdown, so the UI offers query-based option population alongside manual entry. True for every SQL source. False for sources whose query language returns shaped documents rather than rows (Elasticsearch), where the user enters dropdown options by hand.
Answer false for anything you do not recognise; the built-in dialects
all end with a catch-all clause that does exactly that.
@type source_type() :: :postgres | :mysql | :sqlite | :other | atom()
@type t() :: %Lotus.Source.Adapter{ module: module(), name: String.t(), source_type: source_type(), state: term() }
Callbacks
@callback ai_context(state :: term()) :: {:ok, ai_context_map()} | {:error, term()}
Return the structured context the AI pipeline uses when generating queries for this source.
The returned map has fixed keys:
:language— query-language identifier (same shape asquery_language/1:"sql:postgres","json:elasticsearch", ...). Must match a constrained character set; violating identifiers are replaced with"unknown"at the dispatch layer.:example_query— one concrete example statement showing the adapter's syntax idioms. Capped at 2048 bytes.:syntax_notes— short prose covering quoting, reserved words, or dialect-specific pitfalls. Capped at 1024 bytes.:error_patterns— up to 20%{pattern: Regex.t(), hint: binary}entries. When a query fails, the first matching:patternfeeds its:hintback into the LLM so it can self-correct.:generation_notes— optional prose telling the LLM how to shape a good query for this source. Replaces core's generic guidance rather than being appended to it. Capped at 1024 bytes.:read_only_notes— optional prose naming the operations this source treats as writes, and therefore must never be generated. Replaces core's generic guidance. Capped at 1024 bytes.
Core owns prompt structure — the workflow, the tool list, the
UNABLE_TO_GENERATE protocol, the fence. The adapter owns prompt
content about its own language. This split follows the enforcement:
sanitize_query/3 is already an adapter callback, so the adapter, not
core, decides what counts as a write.
Return {:error, :ai_not_supported} (or any {:error, term}) to opt
the source out of AI generation entirely — Lotus.AI.generate_query_with_context/1
surfaces a clean "AI not supported for source" error instead of
hallucinating syntax the adapter can't run.
Security note. Untrusted adapters can influence LLM output through
:syntax_notes, :error_patterns, :generation_notes and
:read_only_notes. Host apps opt adapters into the full context via
config :lotus, :trusted_source_adapters. Untrusted adapters see only
:language plumbed to the prompt; free-form fields are discarded, and
core falls back to its own guidance rather than to an empty string.
Default (when not implemented): {:error, :ai_not_supported} — the
adapter is opted out of AI.
@callback apply_filters( state :: term(), statement :: Lotus.Query.Statement.t(), filters :: list() ) :: Lotus.Query.Statement.t()
Apply filters to the statement, returning a new statement with the filters baked in.
@callback apply_pagination( state :: term(), statement :: Lotus.Query.Statement.t(), pagination_opts :: keyword() ) :: Lotus.Query.Statement.t()
Rewrite a statement to return a single page of rows, and optionally record a count query for the full result set.
Pipeline position: fires after apply_filters/3 and apply_sorts/3,
so the input statement already has any filters and sorts applied.
Opts
:limit(required) — page size:offset— page offset (default:0):count—:none(default) or:exact. When:exact, the adapter picks one of two strategies to surface the pre-pagination total:Strategy A — inline count. The adapter arranges for its main query to return the total as a side-effect (e.g. Elasticsearch's
track_total_hits: true, MongoDB's$facet).execute_query/4returns the count via the optional:total_countkey in its result map. The adapter does not set:count_specinstatement.meta.Strategy B — separate count query. The adapter places a
count_specinstatement.meta[:count_spec]; Lotus core runs it through the same adapter after the main query. Standard for SQL adapters (aSELECT count(*) FROM ...around the filtered query).Adapters pick one — not both. If both are present, Strategy A wins (the inline count from the main query is authoritative; the count_spec is not run).
:search_path— forwarded by callers that care about schema isolation
Return
A paginated %Statement{}. When counting is requested, the returned
statement's :meta map holds :count_spec (a count_spec() value).
Lotus core assembles any surrounding metadata (original adapter struct,
search_path, etc.) from its own scope.
Default (when not implemented): statement unchanged, no pagination.
@callback apply_sorts( state :: term(), statement :: Lotus.Query.Statement.t(), sorts :: list() ) :: Lotus.Query.Statement.t()
Apply sorts to the statement, returning a new statement with the sort order baked in.
@callback builtin_denies(state :: term()) :: [ {String.t() | nil | Regex.t(), String.t() | Regex.t()} ]
Return built-in deny rules for system tables (list of {schema_pattern, table_pattern} tuples).
Return schema patterns that should be hidden from schema listings.
Whether this adapter can handle the given data source entry (e.g. a repo module).
Map a database column type string to a Lotus internal type atom.
Return the default schemas when none are configured.
@callback describe_table(state :: term(), schema :: String.t() | nil, table :: String.t()) :: {:ok, [column_def()]} | {:error, term()}
Return column definitions for a specific table.
@callback disconnect(state :: term()) :: :ok
Disconnect from the data source and release resources.
@callback editor_config(state :: term()) :: %{ :language => String.t(), :keywords => [String.t()], :types => [String.t()], :functions => [%{name: String.t(), detail: String.t(), args: String.t()}], :context_boundaries => [String.t()], optional(:dialect_spec) => dialect_spec(), optional(:context_schema) => context_schema() }
Return editor configuration (keywords, types, functions) for the adapter.
Shape
Required fields:
:language— query-language identifier (e.g."sql:postgres","json:elasticsearch"). Drives CodeMirror language selection.:keywords,:types— flat lists feeding the "complete any keyword anywhere" fallback (used when:context_schemais absent) and the AI prompt pipeline.:functions—%{name, detail, args}entries for signature help.:context_boundaries— SQL-only; ignored for JSON DSLs.
Optional fields:
:dialect_spec— SQL tokenizer options, forwarded verbatim to CodeMirror'sSQLDialect.define(). Only meaningful for SQL languages; adapters on a built-in CM6 dialect (Postgres, MySQL, SQLite, MSSQL, MariaSQL, Cassandra, PLSQL) can omit this and get the built-in grammar.:context_schema— JSON DSL structural completion schema. Drives parent-aware autocomplete (e.g., Elasticsearch'sbool→must/should/filter). Omit for SQL adapters; for JSON DSL adapters, omitting it degrades the editor to flat keyword suggestions at every key position.
Examples
Plain SQL adapter — required fields only:
%{
language: "sql",
keywords: ["SELECT", "FROM", "WHERE"],
types: ["INTEGER", "TEXT"],
functions: [%{name: "COUNT", detail: "count(*)", args: "(*)"}],
context_boundaries: ["SELECT", "FROM", "WHERE"]
}External SQL adapter with tokenizer options:
%{
language: "sql:clickhouse",
keywords: [...],
types: [...],
functions: [...],
context_boundaries: [...],
dialect_spec: %{
identifier_quotes: "`",
hash_comments: true,
double_quoted_strings: false,
case_insensitive_identifiers: true
}
}JSON DSL adapter with a structural schema:
%{
language: "json:elasticsearch",
keywords: [...],
types: [],
functions: [],
context_boundaries: [],
context_schema: %{
root: ["query", "aggs", "sort"],
children: %{
"query" => ["match", "term", "bool"],
"bool" => ["must", "should", "filter"],
"must" => :array_of_query,
"match" => :fields
},
value_literals: %{"order" => ["asc", "desc"]}
}
}Size caps on :keywords, :types, :functions, :context_schema.root,
and :context_schema.children are enforced at the dispatch layer —
see Lotus.Source.Adapter.editor_config/1 for exact limits.
@callback example_query(state :: term(), table :: String.t(), schema :: String.t() | nil) :: String.t()
Return an example query string for placeholder text in the query editor.
@callback execute_query( state :: term(), sql :: term(), params :: list(), opts :: keyword() ) :: {:ok, %{ :columns => [String.t()], :rows => [[term()]], :num_rows => non_neg_integer(), optional(:total_count) => non_neg_integer() | nil }} | {:error, term()}
Execute a prepared statement against the data source.
This is the driver boundary: adapters receive the adapter-native statement
payload (SQL text for Ecto, a JSON body for Elasticsearch, a DSL AST for
other engines) together with any bound params, and return the usual
{columns, rows, num_rows} result shape so core can assemble a
%Lotus.Result{}.
Optional :total_count — inline count strategy
When the caller requested count: :exact via pagination and the adapter
can compute the pre-pagination row total as a side-effect of running the
main query (e.g. Elasticsearch's hits.total.value with
track_total_hits: true), include :total_count in the result map:
{:ok, %{
columns: [...],
rows: [...],
num_rows: N,
total_count: T # pre-pagination total; nil if unavailable
}}Adapters without a cheap inline count should omit the key and use
apply_pagination/3 + :count_spec to compute the total via a separate
query. See the pagination callback for the full precedence rule.
@callback extract_accessed_resources( state :: term(), statement :: Lotus.Query.Statement.t() ) :: {:ok, MapSet.t({String.t() | nil, String.t()})} | {:error, term()} | {:unrestricted, String.t()}
Extract the set of tables/relations a statement will access.
Used by Lotus.Preflight to check visibility rules before execution.
Return {:ok, MapSet} with {schema, table} tuples, {:error, reason},
or {:unrestricted, reason} when visibility cannot be enforced at this
layer (the adapter signals Lotus to consult the host-app opt-in gate
before allowing the statement through).
Default (when not implemented): {:unrestricted, "adapter does not implement extract_accessed_resources/2"}.
Format a data-source error into a human-readable string.
Adapters translate driver-specific exceptions (e.g. Postgrex.Error,
MyXQL.Error, or a non-SQL engine's error struct) into a user-facing
message. Called from Lotus.Runner when the execution phase raises.
Check that the data source is reachable.
Return the human-readable label for the top-level hierarchy (e.g. "Tables", "Indices").
@callback limit_query( state :: term(), statement :: Lotus.Query.Statement.t(), limit :: pos_integer() ) :: Lotus.Query.Statement.t()
Cap a statement at a source-specific row limit.
Takes a %Statement{} and returns a %Statement{} whose body carries a
single-page limit — a LIMIT / TOP / FETCH FIRST clause for SQL
dialects, a size key for a JSON DSL, whatever the language calls it.
Used by the UI's "preview this query" affordance to cap returned rows
without touching the underlying query. statement.params is carried
through untouched unless the adapter binds the limit itself.
Default (when not implemented): the statement unchanged. Adapters whose language has no notion of a row cap simply omit the callback — Lotus core treats it as best-effort and does not depend on it for correctness.
List all schemas in the data source.
@callback list_tables(state :: term(), schemas :: [String.t()], opts :: keyword()) :: {:ok, [{schema :: String.t() | nil, table :: String.t()}]} | {:error, term()}
List tables (and optionally views) in the given schemas.
@callback needs_preflight?(state :: term(), statement :: Lotus.Query.Statement.t()) :: boolean()
Whether a statement needs the visibility preflight check before execution.
Implementors return false for read-only introspection statements that do
not access visible relations (e.g. SQL EXPLAIN, SHOW, PRAGMA) and
true for everything else. Lotus core runs preflight when this callback
returns true and skips it when false.
Default (when not implemented): true (always preflight — safer).
@callback parse_qualified_name(state :: term(), name :: String.t()) :: {:ok, [String.t()]} | {:error, term()}
Parse a qualified resource name into its hierarchy components.
The return is an ordered list: the most-coarse component first, the leaf last. At most two components — see "Relations are two-level" above; an engine with a deeper hierarchy flattens the upper levels into the first component.
Examples across query languages:
- SQL:
"public.users"→["public", "users"] - Elasticsearch:
"logs-2025-01"→["logs-2025-01"](flat) - Mongo:
"mydb.users"→["mydb", "users"] - BigQuery:
"proj.ds.tbl"→["proj.ds", "tbl"](flattened)
Used by discovery UIs and AI actions to route a user-supplied name to the right introspection call.
Default (when not implemented): {:ok, [name]} — single-component
interpretation.
@callback prepare_for_analysis(state :: term(), statement :: Lotus.Query.Statement.t()) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Return a statement safe to pass to query_plan/3 for optimization
analysis.
Callers (Lotus.AI.QueryOptimizer) run this first so the adapter can
resolve any [[ ... ]] optional clauses and replace {{var}}
placeholders with language-appropriate null-ish literals (NULL for
SQL, null for JSON DSLs). The returned statement's :body must be
syntactically valid in the adapter's language without bound params
so the engine's EXPLAIN / profile endpoint can parse it.
Default (when not implemented): {:error, :unsupported} — the caller
skips optimization analysis for this adapter.
Return the query language identifier for this source.
Used by the AI pipeline, editor integrations, and :schema_hierarchy UI
affordances to know what kind of statement text this source accepts.
Examples:
"sql:postgres","sql:mysql","sql:sqlite"for SQL-prepared adapters"json:elasticsearch"for an Elasticsearch DSL adapter"json:mongo"for a MongoDB aggregation pipeline adapter
The part before the colon is the language family. It selects the editor
mode and the fenced-code label the AI layer asks the model to emit, so it
must name the syntax of the statement body (sql, json, ...), not the
engine.
@callback query_plan( state :: term(), statement :: Lotus.Query.Statement.t(), opts :: keyword() ) :: {:ok, String.t() | nil} | {:error, term()}
Return an execution plan for a query.
For SQL-prepared adapters, this is typically the output of the dialect's
EXPLAIN variant (e.g. EXPLAIN on Postgres, EXPLAIN QUERY PLAN on
SQLite, EXPLAIN FORMAT=JSON on MySQL) — a human-readable or structured
string describing how the server will execute the query.
Non-SQL adapters whose engines don't expose a plan (or don't expose one
cheaply) may legitimately return {:ok, nil} or {:error, :unsupported};
Lotus callers treat both as "no plan available" without surfacing an
error to the user.
The statement carries its own bound values in statement.params; there is
no separate params argument.
Quote a SQL identifier (column, table, schema name) using source-specific syntax.
@callback resolve_table_namespace( state :: term(), table :: String.t(), schemas :: [String.t()] ) :: {:ok, String.t() | nil} | {:error, term()}
Resolve which schema contains the named table.
@callback sanitize_query( state :: term(), statement :: Lotus.Query.Statement.t(), opts :: keyword() ) :: :ok | {:error, String.t()}
Validate that a statement is safe to execute.
Called before execution to enforce single-statement and deny-list rules.
Return :ok to allow or {:error, reason} to block.
Options
:read_only— whentrue, block write operations
Default (when not implemented): :ok (allow all statements).
@callback source_type(state :: term()) :: source_type()
Return the source type atom (e.g. :postgres, :mysql).
@callback substitute_list_variable( state :: term(), statement :: Lotus.Query.Statement.t(), var_name :: String.t(), values :: [term()], type :: atom() | nil ) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Substitute a {{var_name}} list-variable placeholder with the given list
of values, returning a new statement.
Adapters choose the natural shape for their query language: SQL expands
into a placeholder group ($1, $2, $3); JSON DSLs emit a JSON array.
Callers must not rely on the substituted text form beyond "the variable
has been expanded into the statement's native list representation".
values is a non-empty list of already-cast values. type is the
resolved Lotus internal type atom shared by all list elements.
Default (when not implemented): {:error, :unsupported}.
@callback substitute_variable( state :: term(), statement :: Lotus.Query.Statement.t(), var_name :: String.t(), value :: term(), type :: atom() | nil ) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Substitute a {{var_name}} placeholder in the statement with the given
value, returning a new statement.
Adapters own their substitution strategy because it depends on the query language:
- SQL (prepared-statement) drivers add a placeholder (
$1,?, ...) tostatement.body, append the value tostatement.params, and leave binding to the driver. - JSON / DSL adapters (Elasticsearch, Mongo) inline the value as a
properly-escaped literal inside
statement.body.
value has already been type-cast by Lotus core. type is the resolved
Lotus internal type atom (e.g. :integer, :uuid) — adapters that care
about type-specific placeholders use it; others may ignore it.
Return {:error, :unsupported} when the adapter has no {{var}} mental
model (e.g. an adapter whose statement is a fully pre-built term and does
not accept user variables at all).
Security note. Adapters that inline values are the only defense
against injection at this layer. Never interpolate raw strings —
delegate to Lotus.JSON or an equivalent escaper for the target
language.
Default (when not implemented): {:error, :unsupported}.
Return the Lotus.Query.Filter operators this adapter's apply_filters/3
can handle.
Core validates filter operators against this list before dispatching.
Unsupported operators raise Lotus.UnsupportedOperatorError rather than
silently degrading. lotus_web reads this through
Lotus.Source.supported_filter_operators/1 to gate the filter operator
dropdown per source.
Default (when not implemented): all operators in Lotus.Query.Filter.operators/0
— the permissive choice, which existing adapters inherit without change.
Adapters that cannot implement the full set must override and declare
their actual support.
Whether this adapter supports a given feature.
See feature/0 for the atoms core asks about and what each one means.
@callback table_stats(state :: term(), schema :: String.t() | nil, table :: String.t()) :: {:ok, map()} | {:error, term()}
Return statistics for a relation.
At minimum a :row_count; adapters may add their own keys (on-disk size,
segment counts, a last-analyzed timestamp) and callers should tolerate
extras.
Lotus.Schema.get_table_stats/3 calls this when the adapter implements
it. Otherwise it falls back to SELECT COUNT(*) against the quoted
relation name, which only makes sense for SQL sources — a non-SQL adapter
should implement this callback rather than inherit that fallback.
Return {:error, :unsupported} for an engine that exposes no such
statistic; callers treat it as "no stats available".
Default (when not implemented): {:error, :unsupported}.
@callback transaction(state :: term(), fun :: (term() -> any()), opts :: keyword()) :: {:ok, any()} | {:error, any()}
Execute a function within a transaction.
@callback transform_bound_query( state :: term(), statement :: Lotus.Query.Statement.t(), opts :: keyword() ) :: Lotus.Query.Statement.t()
Rewrite the statement after variable substitution.
Pipeline position: fires inside the execution pipeline after
{{var}} placeholders have been resolved into statement.params, and
before apply_filters, apply_sorts, and apply_pagination mutate
the statement.
Use when you need access to the bound parameter values — for example, to
inline values into statement.body for a transport that can't carry
prepared-statement parameters, or to apply a transformation that depends on
the bound values.
The statement payload is whatever the adapter understands (SQL text, JSON
DSL, AST, etc.); this callback is language-agnostic. For rewrites that only
need the raw statement text (before variables are bound), implement
transform_statement/2 instead.
Default (when not implemented): statement unchanged.
@callback transform_statement(state :: term(), statement :: Lotus.Query.Statement.t()) :: Lotus.Query.Statement.t()
Rewrite the statement before variables are extracted and bound.
Pipeline position: fires inside Lotus.Storage.Query.compile/2
before {{var}} placeholders are extracted from statement.body and
before any value is bound. The statement's :params is [] at this point.
Use for language-specific syntax normalization of the stored template (e.g., wildcard rewriting, quoted-variable stripping). Works for any query language: SQL text, JSON DSL, Cypher, etc.
For rewrites that need access to the resolved param values (post-binding),
implement transform_bound_query/3 instead.
Default (when not implemented): statement unchanged.
@callback validate_identifier( state :: term(), kind :: :schema | :table | :column, value :: String.t() ) :: :ok | {:error, String.t()}
Validate that a string is a safe identifier for the given kind in this adapter's query language.
Each adapter declares what characters are allowed:
- Ecto SQL dialects:
[a-zA-Z_][a-zA-Z0-9_]*for:schema,:table, and:column. - Elasticsearch:
:table(index) allows hyphens and leading digits;:column(field path) allows dots for nested fields. - Mongo:
:columnallows dot paths for embedded document fields.
Called from the pipeline before dispatching filter/sort column names to the adapter, and from AI actions that take user-supplied names.
Default (when not implemented): :ok — permissive; the adapter trusts
its caller to validate identifiers.
@callback validate_statement( state :: term(), statement :: Lotus.Query.Statement.t(), opts :: keyword() ) :: :ok | {:error, term()}
Validate that a statement can be parsed and prepared by the data source without executing it.
SQL-prepared adapters typically implement this via EXPLAIN (the server
parses + type-checks the query without running it). Non-SQL engines
might use a _validate endpoint (Elasticsearch) or return :ok
unconditionally as a trust-on-execute fallback.
Called by lotus_web's "validate before run" feature and AI actions that
want to sanity-check a draft before surfacing it to the user. Callers
are responsible for neutralizing any unbound {{var}} placeholders
before calling this — adapters see the statement as-is.
Default (when not implemented): :ok — trust-on-execute; errors surface
at run time.
Wrap a raw data source entry into an %Adapter{} struct.
Functions
@spec ai_context(t()) :: {:ok, ai_context_map()} | {:error, term()}
Return the adapter's AI context, with free-form fields capped at safe sizes.
Returns {:error, :ai_not_supported} if the adapter does not implement
ai_context/1 — the host can branch on this to disable AI features for
the source.
Oversized :example_query, :syntax_notes, or :error_patterns are
truncated at the dispatch layer with a one-time Logger.warning/1 per
adapter module. A :language value that doesn't match the allowed
character set (^[a-z0-9]+:[a-z0-9_-]+$) is replaced with "unknown".
@spec apply_filters(t(), Lotus.Query.Statement.t(), list()) :: Lotus.Query.Statement.t()
Apply filters to a statement via the adapter. Empty filters short-circuit.
@spec apply_pagination(t(), Lotus.Query.Statement.t(), keyword()) :: Lotus.Query.Statement.t()
Apply pagination to the statement. The paginated statement carries any
count spec in statement.meta[:count_spec]. Returns the statement
unchanged if the adapter doesn't implement apply_pagination/3.
@spec apply_sorts(t(), Lotus.Query.Statement.t(), list()) :: Lotus.Query.Statement.t()
Apply sorts to a statement via the adapter. Empty sorts short-circuit.
Built-in table-level deny rules to apply when no adapter can be resolved.
Returns a conservative shotgun-union list covering Postgres, MySQL, and
SQLite system-schema patterns plus Lotus's own storage tables. Callers with
an %Adapter{} struct should prefer builtin_denies/1 (the struct dispatch
helper), which routes to the adapter's own rules.
Return built-in deny rules via the adapter.
Built-in schema-level deny patterns to apply when no adapter can be resolved.
Callers with an %Adapter{} struct should prefer builtin_schema_denies/1.
Return built-in schema denies via the adapter.
Map a database type to a Lotus type via the adapter.
Return default schemas via the adapter.
@spec describe_table(t(), String.t() | nil, String.t()) :: {:ok, [column_def()]} | {:error, term()}
Get column definitions for a table via the adapter.
@spec disconnect(t()) :: :ok
Disconnect from the data source via the adapter.
Return editor configuration via the adapter, with list sizes capped at safe limits.
Unknown top-level keys are dropped; oversized :keywords, :types,
:functions, :context_schema.root, and :context_schema.children
are truncated with a one-time Logger.warning/1 per adapter module.
Prevents a misbehaving adapter from bloating every editor session's
LiveView payload.
Return an example query via the adapter.
Execute a SQL query via the adapter.
@spec extract_accessed_resources(t(), Lotus.Query.Statement.t()) :: {:ok, MapSet.t({String.t() | nil, String.t()})} | {:error, term()} | {:unrestricted, String.t()}
Extract accessed resources for preflight checks. Returns
{:unrestricted, reason} if the adapter doesn't implement the callback —
callers must consult the host-app :allow_unrestricted_resources opt-in
before allowing the statement through.
Format an error via the adapter.
Check data source health via the adapter.
Return the hierarchy label via the adapter.
@spec limit_query(t(), Lotus.Query.Statement.t(), pos_integer()) :: Lotus.Query.Statement.t()
Wrap a statement with a limit clause via the adapter.
List all schemas via the adapter.
@spec list_tables(t(), [String.t()], keyword()) :: {:ok, [{String.t() | nil, String.t()}]} | {:error, term()}
List tables via the adapter.
@spec needs_preflight?(t(), Lotus.Query.Statement.t()) :: boolean()
Whether the statement needs the visibility preflight check. Returns true
if the adapter doesn't implement needs_preflight?/2 — the safer default.
Parse a qualified name into hierarchy components via the adapter.
Returns {:ok, [name]} (single-component) if the adapter does not
implement the callback.
@spec prepare_for_analysis(t(), Lotus.Query.Statement.t()) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Prepare a statement for optimization analysis via the adapter. Returns
{:error, :unsupported} if the adapter does not implement
prepare_for_analysis/2 — callers skip optimization for that adapter.
Return the query language identifier via the adapter.
@spec query_plan(t(), Lotus.Query.Statement.t(), keyword()) :: {:ok, String.t() | nil} | {:error, term()}
Get the execution plan for a statement via the adapter.
Quote a SQL identifier via the adapter.
@spec resolve_table_namespace(t(), String.t(), [String.t()]) :: {:ok, String.t() | nil} | {:error, term()}
Resolve which schema contains a table via the adapter.
@spec sanitize_query(t(), Lotus.Query.Statement.t(), keyword()) :: :ok | {:error, String.t()}
Validate statement safety via the adapter. Returns :ok if not implemented.
@spec source_type(t()) :: source_type()
Return the source type via the adapter.
@spec substitute_list_variable( t(), Lotus.Query.Statement.t(), String.t(), [term()], atom() | nil ) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Substitute a {{var}} list variable via the adapter. Returns
{:error, :unsupported} when the adapter does not implement the callback.
@spec substitute_variable( t(), Lotus.Query.Statement.t(), String.t(), term(), atom() | nil ) :: {:ok, Lotus.Query.Statement.t()} | {:error, term()}
Substitute a {{var}} scalar variable via the adapter. Returns
{:error, :unsupported} when the adapter does not implement the callback.
Return the filter operators this adapter's apply_filters/3 supports.
Defaults to all of Lotus.Query.Filter.operators/0 if the adapter does
not declare a subset.
Check feature support via the adapter.
Return statistics for a relation via the adapter.
Execute a function within a transaction via the adapter.
@spec transform_bound_query(t(), Lotus.Query.Statement.t(), keyword()) :: Lotus.Query.Statement.t()
Rewrite the statement after variable substitution, before filters and sorts
are applied. Returns the statement unchanged if the adapter doesn't
implement transform_bound_query/3.
@spec transform_statement(t(), Lotus.Query.Statement.t()) :: Lotus.Query.Statement.t()
Rewrite the statement before variable binding. Returns the statement
unchanged if the adapter doesn't implement transform_statement/2.
@spec validate_identifier(t(), :schema | :table | :column, String.t()) :: :ok | {:error, String.t()}
Validate an identifier for the given kind via the adapter. Returns :ok
(permissive) if the adapter does not implement the callback.
@spec validate_statement(t(), Lotus.Query.Statement.t(), keyword()) :: :ok | {:error, term()}
Validate a statement via the adapter. Returns :ok if the adapter does
not implement the callback (trust-on-execute).