# `Lotus.Source.Adapter`
[🔗](https://github.com/elixir-lotus/lotus/blob/v1.0.0/lib/lotus/source/adapter.ex#L1)

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 behaviour
  * `state`       — opaque connection state managed by the adapter
  * `source_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")

# `ai_capabilities`

```elixir
@type ai_capabilities() :: %{
  generation: ai_capability(),
  optimization: ai_capability(),
  explanation: ai_capability()
}
```

# `ai_capability`

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

# `ai_context_map`

```elixir
@type ai_context_map() :: %{
  :language =&gt; String.t(),
  :example_query =&gt; String.t(),
  :syntax_notes =&gt; String.t(),
  :error_patterns =&gt; [%{pattern: Regex.t(), hint: String.t()}],
  optional(:generation_notes) =&gt; String.t(),
  optional(:read_only_notes) =&gt; String.t(),
  optional(:capabilities) =&gt; 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.

# `column_def`

```elixir
@type column_def() :: %{
  name: String.t(),
  type: String.t(),
  nullable: boolean(),
  default: String.t() | nil,
  primary_key: boolean()
}
```

# `context_schema`

```elixir
@type context_schema() :: %{
  :root =&gt; [String.t()],
  :children =&gt; %{
    required(String.t()) =&gt; [String.t()] | atom() | [{String.t(), atom()}]
  },
  optional(:value_literals) =&gt; %{required(String.t()) =&gt; [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"]`).

# `count_spec`

```elixir
@type count_spec() :: %{query: term(), params: list()}
```

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.

# `dialect_spec`

```elixir
@type dialect_spec() :: %{
  optional(:identifier_quotes) =&gt; String.t(),
  optional(:operator_chars) =&gt; String.t(),
  optional(:hash_comments) =&gt; boolean(),
  optional(:slash_comments) =&gt; boolean(),
  optional(:double_quoted_strings) =&gt; boolean(),
  optional(:double_dollar_quoted_strings) =&gt; boolean(),
  optional(:backslash_escapes) =&gt; boolean(),
  optional(:space_after_dashes) =&gt; boolean(),
  optional(:case_insensitive_identifiers) =&gt; boolean(),
  optional(:builtin) =&gt; String.t(),
  optional(:char_set_casts) =&gt; boolean(),
  optional(:plsql_quoting_mechanism) =&gt; boolean(),
  optional(:unquoted_bit_literals) =&gt; boolean(),
  optional(:treat_bits_as_bytes) =&gt; boolean(),
  optional(:special_var) =&gt; 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.

# `feature`

```elixir
@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_path` option 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 a `make_interval`
    function, so the transformer can rewrite `INTERVAL '{{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.

# `source_type`

```elixir
@type source_type() :: :postgres | :mysql | :sqlite | :other | atom()
```

# `t`

```elixir
@type t() :: %Lotus.Source.Adapter{
  module: module(),
  name: String.t(),
  source_type: source_type(),
  state: term()
}
```

# `ai_context`
*optional* 

```elixir
@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 as
    `query_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 `:pattern` feeds
    its `:hint` back 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.

# `apply_filters`
*optional* 

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

# `apply_pagination`
*optional* 

```elixir
@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/4`
    returns the count via the optional `:total_count` key in its result
    map. The adapter does *not* set `:count_spec` in `statement.meta`.

    **Strategy B — separate count query.** The adapter places a
    `count_spec` in `statement.meta[:count_spec]`; Lotus core runs it
    through the same adapter after the main query. Standard for SQL
    adapters (a `SELECT 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.

# `apply_sorts`
*optional* 

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

# `builtin_denies`

```elixir
@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).

# `builtin_schema_denies`
*optional* 

```elixir
@callback builtin_schema_denies(state :: term()) :: [String.t() | Regex.t()]
```

Return schema patterns that should be hidden from schema listings.

# `can_handle?`
*optional* 

```elixir
@callback can_handle?(term()) :: boolean()
```

Whether this adapter can handle the given data source entry (e.g. a repo module).

# `db_type_to_lotus_type`
*optional* 

```elixir
@callback db_type_to_lotus_type(state :: term(), db_type :: String.t()) :: atom()
```

Map a database column type string to a Lotus internal type atom.

# `default_schemas`
*optional* 

```elixir
@callback default_schemas(state :: term()) :: [String.t()]
```

Return the default schemas when none are configured.

# `describe_table`

```elixir
@callback describe_table(state :: term(), schema :: String.t() | nil, table :: String.t()) ::
  {:ok, [column_def()]} | {:error, term()}
```

Return column definitions for a specific table.

# `disconnect`

```elixir
@callback disconnect(state :: term()) :: :ok
```

Disconnect from the data source and release resources.

# `editor_config`
*optional* 

```elixir
@callback editor_config(state :: term()) :: %{
  :language =&gt; String.t(),
  :keywords =&gt; [String.t()],
  :types =&gt; [String.t()],
  :functions =&gt; [%{name: String.t(), detail: String.t(), args: String.t()}],
  :context_boundaries =&gt; [String.t()],
  optional(:dialect_spec) =&gt; dialect_spec(),
  optional(:context_schema) =&gt; 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_schema` is
    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's `SQLDialect.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's `bool` →
    `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.

# `example_query`
*optional* 

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

# `execute_query`

```elixir
@callback execute_query(
  state :: term(),
  sql :: term(),
  params :: list(),
  opts :: keyword()
) ::
  {:ok,
   %{
     :columns =&gt; [String.t()],
     :rows =&gt; [[term()]],
     :num_rows =&gt; non_neg_integer(),
     optional(:total_count) =&gt; 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.

# `extract_accessed_resources`
*optional* 

```elixir
@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_error`

```elixir
@callback format_error(state :: term(), any()) :: String.t()
```

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.

# `health_check`

```elixir
@callback health_check(state :: term()) :: :ok | {:error, term()}
```

Check that the data source is reachable.

# `hierarchy_label`
*optional* 

```elixir
@callback hierarchy_label(state :: term()) :: String.t()
```

Return the human-readable label for the top-level hierarchy (e.g. "Tables", "Indices").

# `limit_query`
*optional* 

```elixir
@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_schemas`
*optional* 

```elixir
@callback list_schemas(state :: term()) :: {:ok, [String.t()]} | {:error, term()}
```

List all schemas in the data source.

# `list_tables`

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

# `needs_preflight?`
*optional* 

```elixir
@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).

# `parse_qualified_name`
*optional* 

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

# `prepare_for_analysis`
*optional* 

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

# `query_language`
*optional* 

```elixir
@callback query_language(state :: term()) :: String.t()
```

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.

# `query_plan`
*optional* 

```elixir
@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_identifier`
*optional* 

```elixir
@callback quote_identifier(state :: term(), String.t()) :: String.t()
```

Quote a SQL identifier (column, table, schema name) using source-specific syntax.

# `resolve_table_namespace`
*optional* 

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

# `sanitize_query`
*optional* 

```elixir
@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` — when `true`, block write operations

Default (when not implemented): `:ok` (allow all statements).

# `source_type`

```elixir
@callback source_type(state :: term()) :: source_type()
```

Return the source type atom (e.g. `:postgres`, `:mysql`).

# `substitute_list_variable`
*optional* 

```elixir
@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}`.

# `substitute_variable`
*optional* 

```elixir
@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`, `?`, ...) to
    `statement.body`, append the value to `statement.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}`.

# `supported_filter_operators`
*optional* 

```elixir
@callback supported_filter_operators(state :: term()) :: [atom()]
```

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.

# `supports_feature?`
*optional* 

```elixir
@callback supports_feature?(state :: term(), feature()) :: boolean()
```

Whether this adapter supports a given feature.

See `t:feature/0` for the atoms core asks about and what each one means.

# `table_stats`
*optional* 

```elixir
@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}`.

# `transaction`

```elixir
@callback transaction(state :: term(), fun :: (term() -&gt; any()), opts :: keyword()) ::
  {:ok, any()} | {:error, any()}
```

Execute a function within a transaction.

# `transform_bound_query`
*optional* 

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

# `transform_statement`
*optional* 

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

# `validate_identifier`
*optional* 

```elixir
@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: `:column` allows 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.

# `validate_statement`
*optional* 

```elixir
@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`
*optional* 

```elixir
@callback wrap(name :: String.t(), term()) :: t()
```

Wrap a raw data source entry into an `%Adapter{}` struct.

# `ai_context`

```elixir
@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"`.

# `apply_filters`

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

# `apply_pagination`

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

# `apply_sorts`

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

# `builtin_denies`

```elixir
@spec builtin_denies() :: [{String.t() | nil | Regex.t(), String.t() | Regex.t()}]
```

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.

# `builtin_denies`

```elixir
@spec builtin_denies(t()) :: [{String.t() | nil | Regex.t(), String.t() | Regex.t()}]
```

Return built-in deny rules via the adapter.

# `builtin_schema_denies`

```elixir
@spec builtin_schema_denies() :: [String.t() | Regex.t()]
```

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

# `builtin_schema_denies`

```elixir
@spec builtin_schema_denies(t()) :: [String.t() | Regex.t()]
```

Return built-in schema denies via the adapter.

# `db_type_to_lotus_type`

```elixir
@spec db_type_to_lotus_type(t(), String.t()) :: atom()
```

Map a database type to a Lotus type via the adapter.

# `default_schemas`

```elixir
@spec default_schemas(t()) :: [String.t()]
```

Return default schemas via the adapter.

# `describe_table`

```elixir
@spec describe_table(t(), String.t() | nil, String.t()) ::
  {:ok, [column_def()]} | {:error, term()}
```

Get column definitions for a table via the adapter.

# `disconnect`

```elixir
@spec disconnect(t()) :: :ok
```

Disconnect from the data source via the adapter.

# `editor_config`

```elixir
@spec editor_config(t()) :: map()
```

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.

# `example_query`

```elixir
@spec example_query(t(), String.t(), String.t() | nil) :: String.t()
```

Return an example query via the adapter.

# `execute_query`

```elixir
@spec execute_query(t(), String.t(), list(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Execute a SQL query via the adapter.

# `extract_accessed_resources`

```elixir
@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_error`

```elixir
@spec format_error(t(), any()) :: String.t()
```

Format an error via the adapter.

# `health_check`

```elixir
@spec health_check(t()) :: :ok | {:error, term()}
```

Check data source health via the adapter.

# `hierarchy_label`

```elixir
@spec hierarchy_label(t()) :: String.t()
```

Return the hierarchy label via the adapter.

# `limit_query`

```elixir
@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_schemas`

```elixir
@spec list_schemas(t()) :: {:ok, [String.t()]} | {:error, term()}
```

List all schemas via the adapter.

# `list_tables`

```elixir
@spec list_tables(t(), [String.t()], keyword()) ::
  {:ok, [{String.t() | nil, String.t()}]} | {:error, term()}
```

List tables via the adapter.

# `needs_preflight?`

```elixir
@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_qualified_name`

```elixir
@spec parse_qualified_name(t(), String.t()) :: {:ok, [String.t()]} | {:error, term()}
```

Parse a qualified name into hierarchy components via the adapter.
Returns `{:ok, [name]}` (single-component) if the adapter does not
implement the callback.

# `prepare_for_analysis`

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

# `query_language`

```elixir
@spec query_language(t()) :: String.t()
```

Return the query language identifier via the adapter.

# `query_plan`

```elixir
@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_identifier`

```elixir
@spec quote_identifier(t(), String.t()) :: String.t()
```

Quote a SQL identifier via the adapter.

# `resolve_table_namespace`

```elixir
@spec resolve_table_namespace(t(), String.t(), [String.t()]) ::
  {:ok, String.t() | nil} | {:error, term()}
```

Resolve which schema contains a table via the adapter.

# `sanitize_query`

```elixir
@spec sanitize_query(t(), Lotus.Query.Statement.t(), keyword()) ::
  :ok | {:error, String.t()}
```

Validate statement safety via the adapter. Returns `:ok` if not implemented.

# `source_type`

```elixir
@spec source_type(t()) :: source_type()
```

Return the source type via the adapter.

# `substitute_list_variable`

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

# `substitute_variable`

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

# `supported_filter_operators`

```elixir
@spec supported_filter_operators(t()) :: [atom()]
```

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.

# `supports_feature?`

```elixir
@spec supports_feature?(t(), atom()) :: boolean()
```

Check feature support via the adapter.

# `table_stats`

```elixir
@spec table_stats(t(), String.t() | nil, String.t()) ::
  {:ok, map()} | {:error, term()}
```

Return statistics for a relation via the adapter.

# `transaction`

```elixir
@spec transaction(t(), (term() -&gt; any()), keyword()) :: {:ok, any()} | {:error, any()}
```

Execute a function within a transaction via the adapter.

# `transform_bound_query`

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

# `transform_statement`

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

# `validate_identifier`

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

# `validate_statement`

```elixir
@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).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
