Lotus.Source.Adapters.Ecto.Dialect behaviour (Lotus v1.0.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

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

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

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.