View Source Dequel.Semantic.Analyzer (Dequel v0.7.0)

Semantic analyzer that transforms parser AST into typed AST.

The parser produces syntax-only AST with :block nodes for relation filtering and string field names. This analyzer walks the AST and:

  1. Converts string field names to atoms (safely via schema lookup)
  2. Resolves :block nodes to their semantic equivalents based on schema info:
    • has_many relations → :exists with [cardinality: :many]
    • belongs_to / has_one relations → :join with [cardinality: :one]
    • embeds_many:embedded with [cardinality: :many]
    • embeds_one:embedded with [cardinality: :one]
  3. Coerces field values to their proper types based on schema info

Example

# Parser output (untyped AST with string field names)
{:block, [], ["items", {:==, [], ["name", "foo"]}]}

# After semantic analysis with has_many relation
{:exists, [cardinality: :many], [:items, {:==, [], [:name, "foo"]}]}

# After semantic analysis with belongs_to relation
{:join, [cardinality: :one], [:author, {:==, [], [:name, "foo"]}]}

# Type coercion
{:==, [], ["age", "25"]}    {:==, [], [:age, 25]}

The semantic layer also enables LSP support by providing type information that can be used for validation, completion, and hover information.

Schema Resolver

The analyzer accepts a schema resolver function that returns field/relation info:

resolver = fn field ->
  case field do
    :items -> %{kind: :has_many, resolver: nested_resolver}
    :author -> %{kind: :belongs_to, resolver: nested_resolver}
    :address -> %{kind: :embeds_one, resolver: nested_resolver}
    :age -> %{kind: :field, type: :integer}
    _ -> nil
  end
end

Analyzer.analyze(ast, resolver)

For Ecto schemas, use Analyzer.ecto_resolver/1 to create a resolver.

Summary

Functions

Analyzes the AST and resolves block nodes to their typed equivalents.

Creates a resolver function for an Ecto schema module.

Types

@type ast() :: tuple() | atom()
@type field_info() :: %{kind: :field, type: atom()}
@type relation_info() :: %{kind: relation_kind(), resolver: resolver()}
@type relation_kind() ::
  :has_many | :has_one | :belongs_to | :embeds_one | :embeds_many
@type resolver() :: (atom() -> field_info() | relation_info() | nil) | nil

Functions

Link to this function

analyze(ast, resolver \\ nil)

View Source
@spec analyze(ast(), resolver()) :: ast()

Analyzes the AST and resolves block nodes to their typed equivalents.

Takes an optional resolver function that maps field names to field/relation info. The resolver should return one of:

  • %{kind: :has_many | :has_one | :belongs_to | :embeds_one | :embeds_many, resolver: fn} for relations

  • %{kind: :field, type: atom} for typed fields
  • nil for unknown fields

If no resolver is provided, block nodes are left unchanged and no type coercion occurs.

Examples

Block nodes are transformed based on relation type (strings converted to atoms):

iex> ast = {:block, [], ["items", {:==, [], ["name", "foo"]}]}
iex> resolver = fn :items -> %{kind: :has_many, resolver: nil}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:exists, [cardinality: :many], [:items, {:==, [], [:name, "foo"]}]}

iex> ast = {:block, [], ["author", {:==, [], ["name", "Tolkien"]}]}
iex> resolver = fn :author -> %{kind: :belongs_to, resolver: nil}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:join, [cardinality: :one], [:author, {:==, [], [:name, "Tolkien"]}]}

iex> ast = {:block, [], ["address", {:==, [], ["city", "NYC"]}]}
iex> resolver = fn :address -> %{kind: :embeds_one, resolver: nil}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:embedded, [cardinality: :one], [:address, {:==, [], [:city, "NYC"]}]}

Field values are coerced based on type:

iex> ast = {:==, [], ["age", "25"]}
iex> resolver = fn :age -> %{kind: :field, type: :integer}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:==, [], [:age, 25]}

iex> ast = {:==, [], ["active", "true"]}
iex> resolver = fn :active -> %{kind: :field, type: :boolean}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:==, [], [:active, true]}

Comparison operators are coerced based on field type:

iex> ast = {:>, [], ["age", "18"]}
iex> resolver = fn :age -> %{kind: :field, type: :integer}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:>, [], [:age, 18]}

iex> ast = {:between, [], ["price", "10", "50"]}
iex> resolver = fn :price -> %{kind: :field, type: :integer}; _ -> nil end
iex> Dequel.Semantic.Analyzer.analyze(ast, resolver)
{:between, [], [:price, 10, 50]}

Without a resolver, AST passes through unchanged (fields stay as strings):

iex> Dequel.Semantic.Analyzer.analyze({:==, [], ["name", "foo"]}, nil)
{:==, [], ["name", "foo"]}

iex> Dequel.Semantic.Analyzer.analyze({:block, [], ["items", {:==, [], ["name", "foo"]}]}, nil)
{:block, [], ["items", {:==, [], ["name", "foo"]}]}
@spec ecto_resolver(module()) :: resolver()

Creates a resolver function for an Ecto schema module.

The schema must have __schema__/2 support for associations, embeds, and types.

Example

resolver = Analyzer.ecto_resolver(MyApp.ItemSchema)
Analyzer.analyze(ast, resolver)