Lotus.Source.Adapters.Ecto.Dialect behaviour (Lotus v1.3.0)

Copy Markdown View Source

The SQL-specific half of an Ecto-backed data source.

Lotus.Source.Adapter is the adapter-agnostic contract every data source implements. A dialect is narrower: it supplies the parts that differ between SQL engines behind an Ecto.Repo — identifier quoting, parameter placeholders, EXPLAIN syntax, catalogue introspection queries, and the built-in deny rules for each engine's system tables.

Pair a dialect with the Lotus.Source.Adapters.Ecto macro and the adapter is a one-liner:

defmodule LotusMSSql.Dialect do
  @behaviour Lotus.Source.Adapters.Ecto.Dialect

  # ... callbacks ...
end

defmodule LotusMSSql.Adapter do
  use Lotus.Source.Adapters.Ecto, dialect: LotusMSSql.Dialect
end

The macro injects every Lotus.Source.Adapter callback, routing the shared Ecto plumbing through Lotus.Source.Adapters.Ecto and the engine-specific parts through your dialect. All of them are defoverridable, so a dialect that needs to escape the macro's assumptions can implement the adapter callback directly.

When not to write a dialect

A dialect is for SQL over Ecto. A data source that is not SQL, or not reached through an Ecto.Repo, implements Lotus.Source.Adapter directly instead — see the Source Adapters guide. Elasticsearch is the worked example of that path.

Statement shape

Pipeline callbacks take and return %Lotus.Query.Statement{}. For a dialect, statement.body is always SQL text and statement.params the bound values in the order the driver expects.

Optional callbacks

Everything in @optional_callbacks has a safe default, so a minimal dialect only implements what its engine actually needs. Notably set_statement_timeout/2 and set_search_path/2 are optional: engines with no session-level timeout or schema search path simply omit them rather than defining no-op clauses.

Summary

Callbacks

Return the adapter-shaped AI context for this dialect.

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

Functions a read-only statement may not call: those with effects that outlast the transaction or reach other sessions (locks, session settings, signalling backends, files, other connections), or that run SQL text preflight can't see.

Extract the set of tables/relations a statement will access.

Whether the statement needs the visibility preflight check before execution.

Apply a session-level schema search path.

Apply a session-level statement timeout.

Row count estimates for the relations in schemas, from the engine's statistics (see Lotus.Source.Adapter.table_row_estimates/2).

Rewrite the statement before variables are extracted and bound.

Types

repo()

@type repo() :: Ecto.Repo.t()

Callbacks

ai_context()

(optional)
@callback ai_context() :: {:ok, Lotus.Source.Adapter.ai_context_map()} | {:error, term()}

Return the adapter-shaped AI context for this dialect.

Supplies the four Lotus.Source.Adapter.ai_context_map keys — :language, :example_query, :syntax_notes, :error_patterns — with dialect-specific content. The built-in SQL dialects populate Postgres / MySQL / SQLite quirks here.

Default (when not implemented): generic-SQL context synthesized from query_language/0, with an empty error-pattern list.

apply_filters(statement, filters)

@callback apply_filters(
  statement :: Lotus.Query.Statement.t(),
  filters :: [Lotus.Query.Filter.t()]
) ::
  Lotus.Query.Statement.t()

apply_sorts(statement, sorts)

@callback apply_sorts(
  statement :: Lotus.Query.Statement.t(),
  sorts :: [Lotus.Query.Sort.t()]
) ::
  Lotus.Query.Statement.t()

builtin_denies(repo)

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

builtin_schema_denies(repo)

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

db_type_to_lotus_type(db_type)

(optional)
@callback db_type_to_lotus_type(db_type :: String.t()) :: atom()

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

Each dialect knows its own type system (e.g. Postgres uuid, MySQL char(36), SQLite INTEGER).

Default (when not implemented): :text.

default_schemas(repo)

@callback default_schemas(repo()) :: [String.t()]

denied_functions()

(optional)
@callback denied_functions() :: [String.t()]

Functions a read-only statement may not call: those with effects that outlast the transaction or reach other sessions (locks, session settings, signalling backends, files, other connections), or that run SQL text preflight can't see.

Names are lowercase; * matches any run of word characters ("pg_advisory_*"). Sanitization refuses a call to any of them, with or without a schema qualifier. A deny list is never complete: it is defense in depth behind a database role that can only read.

Default (when not implemented): [].

describe_table(repo, schema, table)

@callback describe_table(repo(), schema :: String.t() | nil, table :: String.t()) :: [
  %{
    name: String.t(),
    type: String.t(),
    nullable: boolean(),
    default: String.t() | nil,
    primary_key: boolean()
  }
]

ecto_adapter()

@callback ecto_adapter() :: module() | nil

editor_config()

(optional)
@callback editor_config() :: %{
  :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) => Lotus.Source.Adapter.dialect_spec(),
  optional(:context_schema) => Lotus.Source.Adapter.context_schema()
}

example_query(table, schema)

(optional)
@callback example_query(table :: String.t(), schema :: String.t() | nil) :: String.t()

execute_in_transaction(repo, function, keyword)

@callback execute_in_transaction(repo(), (-> any()), keyword()) ::
  {:ok, any()} | {:error, any()}

extract_accessed_resources(repo, statement)

(optional)
@callback extract_accessed_resources(repo(), 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 the dialect cannot enforce visibility at this layer.

Default (when not implemented): {:unrestricted, ...}.

format_error(any)

@callback format_error(any()) :: String.t()

hierarchy_label()

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

limit_offset_placeholders(limit_index, offset_index)

@callback limit_offset_placeholders(
  limit_index :: pos_integer(),
  offset_index :: pos_integer()
) ::
  {limit_placeholder :: String.t(), offset_placeholder :: String.t()}

limit_query(statement, limit)

@callback limit_query(statement :: Lotus.Query.Statement.t(), limit :: pos_integer()) ::
  Lotus.Query.Statement.t()

list_schemas(repo)

@callback list_schemas(repo()) :: [String.t()]

list_tables(repo, schemas, include_views?)

@callback list_tables(repo(), schemas :: [String.t()], include_views? :: boolean()) :: [
  {schema :: String.t() | nil, table :: String.t()}
]

needs_preflight?(statement)

(optional)
@callback needs_preflight?(statement :: Lotus.Query.Statement.t()) :: boolean()

Whether the statement needs the visibility preflight check before execution.

Used by the Ecto adapter's built-in needs_preflight?/2 default to skip introspection statements (EXPLAIN, SHOW, PRAGMA) that don't access visible relations. Dialects may override with a language-specific check.

Default (when not implemented): true (always preflight).

param_placeholder(index, var, type)

@callback param_placeholder(
  index :: pos_integer(),
  var :: String.t(),
  type :: atom() | nil
) :: String.t()

query_language()

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

query_plan(repo, statement, opts)

@callback query_plan(repo(), statement :: Lotus.Query.Statement.t(), opts :: keyword()) ::
  {:ok, String.t() | nil} | {:error, term()}

quote_identifier(t)

@callback quote_identifier(String.t()) :: String.t()

resolve_table_namespace(repo, table, schemas)

@callback resolve_table_namespace(repo(), table :: String.t(), schemas :: [String.t()]) ::
  String.t() | nil

set_search_path(repo, t)

(optional)
@callback set_search_path(repo(), String.t()) :: :ok | no_return()

Apply a session-level schema search path.

Optional. Called inside the query transaction when the caller supplies :search_path. Engines without a search path omit it, and Lotus ignores the option for that source.

set_statement_timeout(repo, non_neg_integer)

(optional)
@callback set_statement_timeout(repo(), non_neg_integer()) :: :ok | no_return()

Apply a session-level statement timeout.

Optional. Engines with no such notion omit it and Lotus relies on the driver-level :timeout passed to execute_query/4 instead.

source_type()

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

supports_feature?(feature)

(optional)
@callback supports_feature?(feature :: atom()) :: boolean()

table_row_estimates(repo, schemas)

(optional)
@callback table_row_estimates(repo(), schemas :: [String.t()]) :: %{
  required({String.t() | nil, String.t()}) => non_neg_integer()
}

Row count estimates for the relations in schemas, from the engine's statistics (see Lotus.Source.Adapter.table_row_estimates/2).

transform_statement(statement)

(optional)
@callback transform_statement(statement :: Lotus.Query.Statement.t()) ::
  Lotus.Query.Statement.t()

Rewrite the statement before variables are extracted and bound.

Fires in Lotus.Storage.Query.compile/2 before {{var}} placeholders are resolved. Use for dialect-specific syntax rewrites (wildcard handling, INTERVAL, CONCAT vs ||, etc.). Return the rewritten statement.

Default (when not implemented): statement unchanged.