Skip to main content

Plan Visitors

Everything the engine does with a logical plan is a visitor over the plan tree: inferring dependencies, renaming attributes, building executable Calculations, and finally running the query. Dispatch keys on the operator's type and on the language that instantiated it, which is what lets a language change or extend the algebra without touching the core; the mechanism is described in Architecture.

The practical consequence is this page: when your language introduces its own plan operators, every pass that can encounter them needs an implementation from you.

The passes

A language descriptor's visitors map supplies these (see PlanVisitors). Most have a working default in @dortdb/core that handles the core operators, so you only override a pass when your language adds operators or needs different behavior.

VisitorPurposeProvide it when...
LogicalPlanBuilderTurns the parsed AST into an initial planAlways; it is the entry point of a language
ExecutorEvaluates the plan and yields result itemsAlways
CalculationBuilderFolds expression subtrees into one callableYou add new operators, or want to modify behavior
TransitiveDependenciesFinds identifiers bound in an outer scopeSame as above
AttributeRenamerApplies a rename map to a subtreeSame as above
AttributeRenameCheckerPre-checks whether a rename is safeSame as above
EqualityCheckerStructural equality of two subtreesSame as above
VariableMapperResolves names to numeric indices for the executorSame as above

LogicalPlanBuilder

The LogicalPlanBuilder is the one visitor that runs over the AST, not the plan. Its buildPlan lowers a parsed query into unified-algebra operators. Unlike the other passes it has no core default: every language must supply one, and it is the only visitor strictly required to register a language.

It also participates in cross-language schema inference. When a language is nested inside another, the outer language may not yet know the schema of a source the inner query references. The builder receives such identifiers tagged with the toInfer symbol in its context, and returns the concrete identifiers it discovered so inference can complete across the language boundary. See Authoring a Language.

CalculationBuilder

The CalculationBuilder collapses a tree of expression operators (FnCall, Literal, and so on) into a single Calculation: one callable with a clearly specified set of inputs. This is also where constant folding happens. A pure function called with constant arguments is evaluated once, at plan time, and replaced by its result. If your language adds operators that can appear inside an expression, this pass needs to know how to compile them.

TransitiveDependencies

TransitiveDependencies computes, for each subtree, the identifiers it uses but that are bound in an outer scope: its free variables. The optimizer relies on this to decide, for example, whether a subquery is correlated or whether a Selection can be pushed past a Join. Results are cached per operator, so any pass that mutates the plan must invalidate the cache for the changed subtree.

AttributeRenamer & AttributeRenameChecker

These two cooperate whenever the optimizer renames attributes, most prominently during Selection pushdown, where a predicate must be rewritten to match renamed columns underneath a Projection.

  • AttributeRenameChecker answers "would applying this rename map be safe?" and rejects renames that would shadow or collide with existing attributes.
  • AttributeRenamer applies the rename map to a subtree in place, then invalidates the affected transitive-dependency caches.

Any operator that stores attribute references must handle both passes so renames stay correct through it.

EqualityChecker

The EqualityChecker tests two plan subtrees for structural equality, optionally ignoring the lang tag or applying a rename map first. Optimizer rules use it to recognize equivalent expressions, index matching among them. Provide an implementation for new operators the optimizer may compare.

VariableMapper

Before execution, the VariableMapper rewrites named identifiers into numeric indices scoped to each operator's output, which the executor uses for fast, name-free lookups. Operators that introduce or read variables need to participate so their bindings are indexed correctly.

Executor

The Executor evaluates the final plan, pulling result items lazily through the operator tree. The core default is abstract; a language must provide concrete implementations of generateTuplesFromValues and visitItemSource.

Wiring it up

List your implementations in the language descriptor's visitors map, keyed by pass name. Each is typically a subclass of the matching core visitor, so it inherits the handling of every core operator and overrides only the methods for its own:

class MyLangCalcBuilder extends CalculationBuilder {
// override only the visitXxx methods for MyLang's own operators
}

export function MyLang(config?: MyLangConfig): Language<'mylang'> {
return {
name: 'mylang',
// ...parser, serializer, functions...
visitors: {
logicalPlanBuilder: MyLangPlanBuilder, // required
executor: MyLangExecutor, // required
calculationBuilder: MyLangCalcBuilder, // needed once you add new operators
// ...other passes your operators participate in...
},
};
}

When a language provides no visitor for a pass at all, the core visitor handles that language's operators directly. That is fine as long as the language introduced no new operator types that pass would encounter.

The provided language packages (@dortdb/lang-sql, @dortdb/lang-cypher, @dortdb/lang-xquery) are the worked references; XQuery in particular extends these visitors to handle its TreeJoin operator.