Skip to content

SQL AST

OrionBelt generates SQL through a typed AST rather than string concatenation, which is what makes the output injection-safe by construction.

SQL AST Nodes

orionbelt.ast.nodes.Select dataclass

A complete SELECT statement.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class Select:
    """A complete SELECT statement."""

    columns: list[Expr] = field(default_factory=list)
    from_: From | None = None
    joins: list[Join | Unnest] = field(default_factory=list)
    """Join clauses and unnests, in the order the planner walked the path.

    One list rather than two, because the order between them matters: an unnest
    names its parent, so it has to follow whatever put that parent in scope.
    Keeping them apart would make the planner interleave them again at render
    time, from information it no longer has.
    """
    where: Expr | None = None
    group_by: list[Expr] = field(default_factory=list)
    having: Expr | None = None
    order_by: list[OrderByItem] = field(default_factory=list)
    limit: int | None = None
    offset: int | None = None
    ctes: list[CTE] = field(default_factory=list)
    distinct: bool = False
    grouping: str | None = None
    """Hierarchical grouping modifier: 'rollup' or 'cube'.

    When set, the dialect emits ``GROUP BY ROLLUP(...)`` / ``GROUP BY CUBE(...)``
    (or ClickHouse-style ``GROUP BY ... WITH ROLLUP``) instead of plain
    ``GROUP BY``. The planner is responsible for appending the
    ``GROUPING(dim) AS _g_<dim>`` columns to the SELECT projection."""

joins = field(default_factory=list) class-attribute instance-attribute

Join clauses and unnests, in the order the planner walked the path.

One list rather than two, because the order between them matters: an unnest names its parent, so it has to follow whatever put that parent in scope. Keeping them apart would make the planner interleave them again at render time, from information it no longer has.

grouping = None class-attribute instance-attribute

Hierarchical grouping modifier: 'rollup' or 'cube'.

When set, the dialect emits GROUP BY ROLLUP(...) / GROUP BY CUBE(...) (or ClickHouse-style GROUP BY ... WITH ROLLUP) instead of plain GROUP BY. The planner is responsible for appending the GROUPING(dim) AS _g_<dim> columns to the SELECT projection.

orionbelt.ast.nodes.ColumnRef dataclass

Reference to a column, optionally qualified by table/alias.

abstract_type is the OBML type of the column this names, when the node was built somewhere that knew it - the two places that resolve a name against the model, resolution.make_column_expr for the columns: form and compiler.expr_parser for the expression: one. A ref invented by a planner or a wrapper (a CTE alias, a projected measure) leaves it None, because at that point the type genuinely is not known. Carried for the same reason :class:NestedField carries one: a dialect sometimes has to know whether it is looking at a number, and the compiler models no types over expression bodies.

It is excluded from equality and hashing. Structural comparison of expressions is load-bearing in the planner - cfl_projection, grain_dedup, filter_wrap, total_wrap and the three wrappers all compare an expression against a freshly built one - and a ref that came through the funnel would otherwise stop matching a hand-built one naming the same column. That failure would be silent and would change results, which is a worse defect than any this field exists to fix.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class ColumnRef:
    """Reference to a column, optionally qualified by table/alias.

    ``abstract_type`` is the OBML type of the column this names, when the node
    was built somewhere that knew it - the two places that resolve a name against
    the model, ``resolution.make_column_expr`` for the ``columns:`` form and
    ``compiler.expr_parser`` for the ``expression:`` one. A ref invented by a
    planner or a wrapper (a CTE alias, a projected measure) leaves it ``None``,
    because at that point the type genuinely is not known. Carried for the same
    reason :class:`NestedField` carries one: a dialect sometimes has to know
    whether it is looking at a number, and the compiler models no types over
    expression bodies.

    It is **excluded from equality and hashing**. Structural comparison of
    expressions is load-bearing in the planner - ``cfl_projection``,
    ``grain_dedup``, ``filter_wrap``, ``total_wrap`` and the three wrappers all
    compare an expression against a freshly built one - and a ref that came
    through the funnel would otherwise stop matching a hand-built one naming the
    same column. That failure would be silent and would change results, which is
    a worse defect than any this field exists to fix.
    """

    name: str
    table: str | None = None
    abstract_type: str | None = field(default=None, compare=False)

orionbelt.ast.nodes.FunctionCall dataclass

SQL function call, e.g. SUM(col), DATE_TRUNC('month', col).

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class FunctionCall:
    """SQL function call, e.g. SUM(col), DATE_TRUNC('month', col)."""

    name: str
    args: list[Expr] = field(default_factory=list)
    distinct: bool = False
    order_by: list[OrderByItem] = field(default_factory=list)
    separator: str | None = None

orionbelt.ast.nodes.BinaryOp dataclass

Binary operation: left op right.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class BinaryOp:
    """Binary operation: left op right."""

    left: Expr
    op: str  # +, -, *, /, =, <>, AND, OR, LIKE, etc.
    right: Expr

orionbelt.ast.nodes.Literal dataclass

A literal value: number, string, boolean, or NULL.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class Literal:
    """A literal value: number, string, boolean, or NULL."""

    value: str | int | float | bool | None

    @classmethod
    def string(cls, v: str) -> Literal:
        return cls(value=v)

    @classmethod
    def number(cls, v: int | float) -> Literal:
        return cls(value=v)

    @classmethod
    def null(cls) -> Literal:
        return cls(value=None)

    @classmethod
    def boolean(cls, v: bool) -> Literal:
        return cls(value=v)

AST Builder

orionbelt.ast.builder.QueryBuilder

Fluent builder for ergonomic AST construction.

Source code in src/orionbelt/ast/builder.py
class QueryBuilder:
    """Fluent builder for ergonomic AST construction."""

    def __init__(self) -> None:
        self._columns: list[Expr] = []
        self._from: From | None = None
        self._joins: list[Join | Unnest] = []
        self._where: Expr | None = None
        self._group_by: list[Expr] = []
        self._having: Expr | None = None
        self._order_by: list[OrderByItem] = []
        self._limit: int | None = None
        self._offset: int | None = None
        self._ctes: list[CTE] = []
        self._distinct: bool = False
        self._grouping: str | None = None

    def select(self, *columns: Expr) -> Self:
        self._columns.extend(columns)
        return self

    def select_aliased(self, expr: Expr, alias: str) -> Self:
        self._columns.append(AliasedExpr(expr=expr, alias=alias))
        return self

    def from_(self, table: str, alias: str | None = None) -> Self:
        self._from = From(source=table, alias=alias)
        return self

    def from_subquery(self, subquery: Select, alias: str) -> Self:
        self._from = From(source=subquery, alias=alias)
        return self

    def join(
        self,
        table: str | Select,
        on: Expr,
        join_type: JoinType = JoinType.LEFT,
        alias: str | None = None,
    ) -> Self:
        self._joins.append(Join(join_type=join_type, source=table, alias=alias, on=on))
        return self

    def unnest(self, node: Unnest) -> Self:
        """Append an unnest in path order, alongside the joins.

        Its parent has to already be in scope - the base object, or an earlier
        join - because the fragment names it.
        """
        self._joins.append(node)
        return self

    def where(self, condition: Expr) -> Self:
        if self._where is None:
            self._where = condition
        else:
            self._where = BinaryOp(left=self._where, op="AND", right=condition)
        return self

    def group_by(self, *exprs: Expr) -> Self:
        self._group_by.extend(exprs)
        return self

    def having(self, condition: Expr) -> Self:
        if self._having is None:
            self._having = condition
        else:
            self._having = BinaryOp(left=self._having, op="AND", right=condition)
        return self

    def order_by(self, expr: Expr, desc: bool = False, nulls_last: bool | None = None) -> Self:
        self._order_by.append(OrderByItem(expr=expr, desc=desc, nulls_last=nulls_last))
        return self

    def limit(self, n: int) -> Self:
        self._limit = n
        return self

    def offset(self, n: int) -> Self:
        self._offset = n
        return self

    def with_cte(self, name: str, query: Select | UnionAll | Except | RawSQL) -> Self:
        self._ctes.append(CTE(name=name, query=query))
        return self

    def distinct(self, value: bool = True) -> Self:
        self._distinct = value
        return self

    def grouping(self, mode: str | None) -> Self:
        """Set the hierarchical grouping modifier ('rollup' or 'cube')."""
        self._grouping = mode
        return self

    def build(self) -> Select:
        return Select(
            columns=self._columns,
            from_=self._from,
            joins=self._joins,
            where=self._where,
            group_by=self._group_by,
            having=self._having,
            order_by=self._order_by,
            limit=self._limit,
            offset=self._offset,
            ctes=self._ctes,
            distinct=self._distinct,
            grouping=self._grouping,
        )

unnest(node)

Append an unnest in path order, alongside the joins.

Its parent has to already be in scope - the base object, or an earlier join - because the fragment names it.

Source code in src/orionbelt/ast/builder.py
def unnest(self, node: Unnest) -> Self:
    """Append an unnest in path order, alongside the joins.

    Its parent has to already be in scope - the base object, or an earlier
    join - because the fragment names it.
    """
    self._joins.append(node)
    return self

grouping(mode)

Set the hierarchical grouping modifier ('rollup' or 'cube').

Source code in src/orionbelt/ast/builder.py
def grouping(self, mode: str | None) -> Self:
    """Set the hierarchical grouping modifier ('rollup' or 'cube')."""
    self._grouping = mode
    return self