Lotus.Source.Adapter behaviour (Lotus v1.0.0)

Copy Markdown View Source

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:

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.

t()

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

ai_capabilities()

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

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.

ai_context_map()

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

column_def()

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

context_schema()

@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"]).

count_spec()

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

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

feature()

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

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

t()

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

Callbacks

ai_context(state)

(optional)
@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(state, statement, filters)

(optional)
@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(state, statement, pagination_opts)

(optional)
@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(state, statement, sorts)

(optional)
@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(state)

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

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

Return schema patterns that should be hidden from schema listings.

can_handle?(term)

(optional)
@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(state, db_type)

(optional)
@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(state)

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

Return the default schemas when none are configured.

describe_table(state, schema, table)

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

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

Disconnect from the data source and release resources.

editor_config(state)

(optional)
@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_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 boolmust/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(state, table, schema)

(optional)
@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(state, sql, params, opts)

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

extract_accessed_resources(state, statement)

(optional)
@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(state, any)

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

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

Check that the data source is reachable.

hierarchy_label(state)

(optional)
@callback hierarchy_label(state :: term()) :: String.t()

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

limit_query(state, statement, limit)

(optional)
@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(state)

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

List all schemas in the data source.

list_tables(state, schemas, opts)

@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?(state, statement)

(optional)
@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(state, name)

(optional)
@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(state, statement)

(optional)
@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(state)

(optional)
@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(state, statement, opts)

(optional)
@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(state, t)

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

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

resolve_table_namespace(state, table, schemas)

(optional)
@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(state, statement, opts)

(optional)
@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(state)

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

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

substitute_list_variable(state, statement, var_name, values, type)

(optional)
@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(state, statement, var_name, value, type)

(optional)
@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(state)

(optional)
@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?(state, feature)

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

Whether this adapter supports a given feature.

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

table_stats(state, schema, table)

(optional)
@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(state, fun, opts)

@callback transaction(state :: term(), fun :: (term() -> any()), opts :: keyword()) ::
  {:ok, any()} | {:error, any()}

Execute a function within a transaction.

transform_bound_query(state, statement, opts)

(optional)
@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(state, statement)

(optional)
@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(state, kind, value)

(optional)
@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(state, statement, opts)

(optional)
@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(name, term)

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

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

Functions

ai_context(adapter)

@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(adapter, statement, filters)

@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(adapter, statement, opts)

@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(adapter, statement, sorts)

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

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

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

Return built-in deny rules via the adapter.

builtin_schema_denies()

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

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

Return built-in schema denies via the adapter.

db_type_to_lotus_type(adapter, db_type)

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

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

default_schemas(adapter)

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

Return default schemas via the adapter.

describe_table(adapter, schema, table)

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

Get column definitions for a table via the adapter.

disconnect(adapter)

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

Disconnect from the data source via the adapter.

editor_config(adapter)

@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(adapter, table, schema)

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

Return an example query via the adapter.

execute_query(adapter, sql, params, opts)

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

Execute a SQL query via the adapter.

extract_accessed_resources(adapter, statement)

@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(adapter, error)

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

Format an error via the adapter.

health_check(adapter)

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

Check data source health via the adapter.

hierarchy_label(adapter)

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

Return the hierarchy label via the adapter.

limit_query(adapter, statement, limit)

Wrap a statement with a limit clause via the adapter.

list_schemas(adapter)

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

List all schemas via the adapter.

list_tables(adapter, schemas, opts)

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

List tables via the adapter.

needs_preflight?(adapter, statement)

@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(adapter, name)

@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(adapter, statement)

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

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

Return the query language identifier via the adapter.

query_plan(adapter, statement, opts)

@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(adapter, identifier)

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

Quote a SQL identifier via the adapter.

resolve_table_namespace(adapter, table, schemas)

@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(adapter, statement, opts)

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

@spec source_type(t()) :: source_type()

Return the source type via the adapter.

substitute_list_variable(adapter, statement, var_name, values, type)

@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(adapter, statement, var_name, value, type)

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

@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?(adapter, feature)

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

Check feature support via the adapter.

table_stats(adapter, schema, table)

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

Return statistics for a relation via the adapter.

transaction(adapter, fun, opts)

@spec transaction(t(), (term() -> any()), keyword()) :: {:ok, any()} | {:error, any()}

Execute a function within a transaction via the adapter.

transform_bound_query(adapter, statement, opts)

@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(adapter, statement)

@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(adapter, kind, value)

@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(adapter, statement, opts)

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