Documentation
¶
Overview ¶
Package plan is ursus's logical plan IR.
A plan is a tree of relational operations — scan, filter, project — over expressions. It is what the optimizer rewrites and what the physical planner lowers. It is deliberately a separate IR from expr: expressions describe column-level computation, plans describe row-set-level dataflow, and keeping them apart is what lets a rule like projection pushdown reason about columns without understanding arithmetic.
Two invariants ¶
IMMUTABLE. Nothing ever mutates a Node or a slice reachable from one. Rewrites allocate. This is what makes `base.Filter(x)` and `base.Filter(y)` independent even though they share a subtree, and it is what lets the optimizer keep the pre-rewrite tree around for Explain.
SCHEMA WITHOUT DATA. Every node can compute its output schema from its children's schemas alone. That is the property behind CollectSchema, plan-time type errors, and projection pushdown. It only holds for a RESOLVED plan — see Resolve.
Resolved vs unresolved ¶
A freshly built plan may contain multi-column expressions (`All()`, `ColDType(Float64)`) whose meaning depends on the schema. Resolve walks the tree bottom-up, expands them, and type-checks. Everything downstream — Schema, the optimizer, the physical planner — assumes a resolved plan and may treat anything else as an internal error.
Index ¶
- Constants
- func Explain(n Node, opts ExplainOptions) (string, error)
- func Expressions(n Node) []expr.Node
- func IsResolved(n Node) bool
- func Walk(n Node, fn func(Node) bool)
- func WalkWithColumns(in *dtype.Schema, exprs []expr.Node, ...) (*dtype.Schema, error)
- type Aggregate
- type AsOfJoin
- type AsOfStrategy
- type Caps
- type CoalesceMode
- type ConcatMode
- type ConstEvaluator
- type Distinct
- type ExplainOptions
- type Filter
- type Flags
- type HStack
- type Join
- type JoinColumn
- type JoinKind
- type JoinLayout
- type JoinSide
- type JoinValidation
- type Limit
- type LocalRule
- type MergeSorted
- type Node
- type Optimizer
- type PredicatePushdown
- type Project
- type Pushdown
- type Reverse
- type RowIndex
- type Rule
- type RunMode
- type Scan
- type Slice
- type Sort
- type SortKey
- type Source
- type Tail
- type TemporalGroup
- type Union
- type Window
- type WithColumns
Constants ¶
const DefaultJoinSuffix = "_right"
DefaultJoinSuffix is appended to a right-side column whose name collides.
Variables ¶
This section is empty.
Functions ¶
func Explain ¶
func Explain(n Node, opts ExplainOptions) (string, error)
Explain renders a plan as an indented tree.
The output is deliberately stable and diff-friendly: it is snapshot-tested in golden files, which is how optimizer regressions get caught. A query that returns the right answer by reading forty columns instead of two is still a regression, and the only thing that notices is a plan diff.
func Expressions ¶
Expressions returns every expression a node holds directly.
What actually consumes it ¶
The column-liveness SAFETY TEST, and nothing else. It used to claim projection pushdown as a consumer; that rule reads t.Exprs, t.Preds, t.Keys and the join's two key lists directly, and the WARNING on the *Join arm below explains why it must — unioning both sides here would ask each side for the other's columns.
It still has to gain an arm when a node type gains an expression slot, or the liveness check silently stops seeing under that node. That is not hypothetical: *Window went four steps without one.
func IsResolved ¶
IsResolved reports whether every Scan in the plan has its source schema, which is the observable marker of a resolved plan.
func Walk ¶
Walk calls fn for every node, parents before children. Returning false stops descent into that subtree.
func WalkWithColumns ¶
func WalkWithColumns(in *dtype.Schema, exprs []expr.Node, visit func(e expr.Node, f dtype.Field, pos int, after *dtype.Schema) error, ) (*dtype.Schema, error)
WalkWithColumns performs the running-schema walk that defines WithColumns.
Each expression resolves against the schema produced by the ones before it, so `WithColumns(a.Alias("x"), Col("x").Mul(2))` works. A name that already exists is REPLACED IN PLACE, keeping its column position — users notice column order, and recomputing a column should not move it to the end.
This exists as one function because it had been written out four separate times (Schema, Resolve, physical planning, predicate pushdown) and two of those copies had already diverged: one resolved against the input schema and one against the running schema, so a self-referencing WithColumns resolved successfully and then failed its own Schema(). Four copies of a subtle walk is four chances to be wrong.
visit, if non-nil, is called for each expression with its resolved field and the position it replaces (or -1 when it appends).
Types ¶
type Aggregate ¶
type Aggregate struct {
Input Node
Keys []expr.Node
Aggs []expr.Node
// MaintainOrder emits groups in first-appearance order rather than in hash
// order. Without it the output order is genuinely unspecified, and tests that
// assume otherwise are flaky.
//
// # What it costs, stated as a number rather than as "time and memory"
//
// Nothing at all when the aggregation fits in memory: the group table is already
// built in first-appearance order, so the flag changes no work.
//
// When it SPILLS the cost is two things. Every routed row carries an extra int64
// input ordinal into its partition file, and the result cannot stream — first
// appearance interleaves resident and spilled groups, so emitting the group
// created at input row 3 requires partition 9's aggregation to be finished. The
// whole answer is materialised and permuted at the end, so an ordered spilling
// group-by peaks at budget + result rather than at budget. The result is
// O(distinct keys), which is what a non-spilling aggregation holds anyway.
//
// BenchmarkGroupBySpillingOrdered beside BenchmarkGroupBySpilling is the measured
// figure.
MaintainOrder bool
}
Aggregate groups rows and reduces each group.
The output is one row per distinct key: the key columns first, in the order written, then the aggregate columns.
Also a PIPELINE BREAKER, and the one that motivates Accumulator.Merge: a parallel or spilling implementation computes partial aggregates independently and combines them, so every aggregate must be expressible as (init, step, merge, finish) rather than as a single pass.
func (*Aggregate) WithChildren ¶
type AsOfJoin ¶
type AsOfJoin struct {
Left, Right Node
// LeftOn and RightOn are the ORDERING key — exactly one column per side, the one
// the nearest-key search runs over.
LeftOn, RightOn expr.Node
// By are exact-match keys applied BEFORE the search, so a trade matches only
// quotes for its own symbol. Empty means one global group.
LeftBy, RightBy []expr.Node
Strategy AsOfStrategy
// Tolerance bounds how far the search may reach. Zero means unbounded.
Tolerance dtype.Interval
// AllowExactMatches lets a right key EQUAL to the left key match. Default true,
// so the zero value of the option struct is the ordinary behaviour; the physical
// planner carries the resolved value.
AllowExactMatches bool
Suffix string
}
AsOfJoin matches each left row to the NEAREST right row rather than to an equal one.
It is a LEFT join with a different match rule ¶
Every left row survives; the right columns are null when nothing is near enough. That is why Layout below delegates to a synthesised Join rather than deriving a second output schema: join_layout.go calls itself "the single authority on a join's output", and the collision, suffixing and key-merging algorithm is exactly what that sentence exists to stop being copied.
Why a separate node rather than another JoinKind ¶
Join has no slot for a strategy, a tolerance or an exact-match flag, and its predicates — Keyed, filtersLeft, leftCanBeNull — all read as statements about EQUI semantics. A kind that quietly meant "nearest" would make every one of them subtly wrong rather than obviously absent.
Both sides must be sorted on On, and it is CHECKED ¶
The nearest-key search is a binary search over a sorted run. ursus-api.md's example calls SetSorted, an unchecked assertion; step 14 already decided that question for GroupByDynamic and the answer does not change here. Pushdown's doc puts it best: a capability claim that is trusted but wrong returns wrong rows silently.
func (*AsOfJoin) Layout ¶
func (a *AsOfJoin) Layout() (*JoinLayout, error)
Layout delegates, so there is exactly one implementation of a join's output shape.
func (*AsOfJoin) WithChildren ¶
type AsOfStrategy ¶
type AsOfStrategy uint8
AsOfStrategy picks which neighbouring right row a left row matches.
const ( // AsOfBackward is the default: the LAST right key at or before the left key. // "Attach the most recent quote to each trade." AsOfBackward AsOfStrategy = iota // AsOfForward is the first right key at or after the left key. AsOfForward // AsOfNearest is whichever of those two is closer, ties going backward. AsOfNearest )
func (AsOfStrategy) String ¶
func (s AsOfStrategy) String() string
type Caps ¶
type Caps struct {
// Projection: the source can return a subset of columns.
Projection bool
}
Caps describes what a source can do for itself.
A source that cannot honour a pushdown is not penalised: the physical planner inserts the equivalent operator above the scan. The logical plan always records the projection and predicate regardless, so Explain shows the user's intent and the optimizer's reasoning stays source-independent.
Predicate support is deliberately NOT here — see PredicatePushdown. A single bool cannot express it correctly, because whether a source can honour a predicate depends on the predicate.
type CoalesceMode ¶
type CoalesceMode uint8
CoalesceMode is tri-state, and that is not over-engineering.
The user-facing option has three states — unset, on, off — and unset is not equal to either: it means "merge for Inner/Left/Right/Semi/Anti, keep both for Full". A bool field would make a hand-built node's zero value mean Coalesce(false), which is not what the public API produces for the same input. CoalesceAuto as the zero value makes the node and the API agree by construction, which is the same reason Pushdown's zero value is Unsupported.
const ( CoalesceAuto CoalesceMode = iota CoalesceOn CoalesceOff )
type ConcatMode ¶
type ConcatMode uint8
ConcatMode says how much a Union will reconcile.
const ( // ConcatStrict requires the same columns, in the same order. Types still // promote and nullability still widens — see Union's doc for why those are not // a relaxation. ConcatStrict ConcatMode = iota // ConcatDiagonal takes the union of the columns, filling a frame's missing ones // with nulls. ConcatDiagonal )
func (ConcatMode) String ¶
func (m ConcatMode) String() string
type ConstEvaluator ¶
type ConstEvaluator interface {
// Fold evaluates n, which is guaranteed to contain no column references, and
// returns the equivalent literal.
//
// Returning (nil, false, nil) declines: an expression the folder does not
// handle is left exactly as it was. An ERROR is different and must be
// propagated as "do not fold" by the caller, never as a query failure — see
// foldExpr.
Fold(n expr.Node, in *dtype.Schema) (expr.Node, bool, error)
}
ConstEvaluator folds an all-literal expression to a single literal.
Why this is an interface rather than a call into internal/kernel ¶
Folding needs a real evaluator, and there is exactly one correct one — the kernel the executor itself uses. Anything else is a second implementation of promotion, overflow and null semantics, and the whole point of folding is that the answer cannot differ from what the query would have computed.
But internal/kernel is L30 and reaching it from here would drag Arrow, the allocator and GOEXPERIMENT=simd into a package that today imports none of them. design/logical.md §13 states what that buys: this layer's tests run "without an executor, without Arrow, and without a Parquet file", so a plan can be built, resolved, optimized and explained with no data anywhere. TestPlanPackageNeedsNoArrow asserts it rather than trusting it.
So the evaluator is INJECTED, from a layer that already has Arrow. Nothing in this signature names an Arrow type, which is what makes that work: the rule below is written once and simplifies with or without a folder attached. Without one — which is what this package's own tests see — every structural rewrite still runs, and only Lit⊕Lit is skipped.
type Distinct ¶
type Distinct struct {
Input Node
// Subset restricts the comparison to these columns. nil means every column.
Subset []string
}
Distinct removes duplicate rows.
It shares the hash table and row encoder with Aggregate — a distinct is an aggregation with no aggregates — but is a separate node so the optimizer can reason about it (it preserves the schema exactly, which Aggregate does not). Distinct keeps the first row of each distinct key.
There is deliberately no MaintainOrder here ¶
There used to be, copied from Aggregate, and it was never assigned, never read and not even rendered by Label(). Unlike Sort.Limit — which step 9 found dead and WOKE UP, because ArgTopK was waiting behind it — this one had no implementation to switch to: distinctOp is a streaming filter that keeps the first row per key and emits in input order unconditionally, so `MaintainOrder = false` meant nothing. A flag whose only evidence of purpose is its own doc comment is worse than no flag, because the next reader believes it.
func (*Distinct) WithChildren ¶
type ExplainOptions ¶
type ExplainOptions struct {
// Schema annotates each node with its output schema. Verbose, but it is the
// fastest way to see where a type went wrong.
Schema bool
// Projection shows the column pruning a scan ended up with, and how many of
// the source's columns that is. This is the visible evidence that pushdown
// happened, so it is on by default.
Projection bool
}
ExplainOptions controls Explain's output.
func DefaultExplainOptions ¶
func DefaultExplainOptions() ExplainOptions
DefaultExplainOptions shows projections but not schemas.
type Filter ¶
Filter keeps rows where every predicate is true.
Preds is a conjunct slice rather than a single AND tree so that predicate pushdown can move conjuncts independently — the common case is that some conjuncts can reach the scan and others cannot.
Null semantics ¶
A row is kept iff the predicate is VALID AND TRUE. A null predicate drops the row, matching SQL's WHERE. This is worth stating explicitly because the alternative (treating null as true) is a plausible-looking choice that silently changes results, and because `Not(p)` therefore does not partition the rows: a row where p is null is dropped by both `Filter(p)` and `Filter(Not(p))`.
func (*Filter) Schema ¶
Schema is the input schema unchanged: filtering removes rows, never columns.
func (*Filter) WithChildren ¶
type Flags ¶
type Flags struct {
ProjectionPushdown bool
PredicatePushdown bool
LimitPushdown bool
SimplifyExprs bool
// JoinPredicatePushdown gates the Join ARM of predicate pushdown, separately
// from the rest of the rule. It is the newest and by far the most dangerous
// piece of the optimizer — the legality table has four barrier cells whose
// violation returns different rows with no error — so it gets its own switch.
JoinPredicatePushdown bool
}
Flags switches individual rules off.
This exists for two reasons that both matter: benchmarking the value of a rule, and bisecting a wrong answer down to the rule that caused it. An optimizer you cannot turn off one piece at a time is an optimizer you cannot debug.
type HStack ¶
type HStack struct{ Inputs []Node }
HStack places frames side by side: same rows, columns concatenated.
Structurally unlike Union, despite reading like its twin ¶
Union appends rows and can stream, because a row belongs to exactly one child. HStack pairs rows ACROSS children, and nothing in the engine aligns batch boundaries between independent pipelines — child A may deliver 8192 rows while child B delivers 100. So it buffers, and it is the only operation here that has to.
Column names must be distinct across the inputs. Silently suffixing, as the join does, would be wrong: a join has a principled left/right asymmetry to name the suffix after, and here the inputs are peers.
func (*HStack) WithChildren ¶
type Join ¶
type Join struct {
Left, Right Node
// LeftOn[i] is matched against RightOn[i]. Equal lengths, enforced by
// resolveJoin. Empty for JoinCross and non-empty for every other kind.
LeftOn, RightOn []expr.Node
Kind JoinKind
// Suffix is appended to a colliding right column name. "" means
// DefaultJoinSuffix, so the zero value is the ordinary behaviour.
Suffix string
Coalesce CoalesceMode
NullsEqual bool // zero value false: a null key matches nothing, as in SQL
Validate JoinValidation
}
Join combines two inputs.
The first two-child node ¶
Every other plan node has exactly one child. The generic traversals — Walk, TransformUp, Explain, and both pushdown rules' default branches — were already written N-ary and need nothing. What does need care is anything that reasons about a SIDE, because left keys name left columns and right keys name right columns; see Expressions and rule_projection.
Self-joins ¶
lf.Join(lf, ...) is legal. The plan is then a shared subtree rather than a DAG, so Walk and TransformUp visit it twice, Resolve resolves it twice (idempotent — resolveScan short-circuits on Full != nil), Explain prints it twice, and the physical planner opens the source twice. All acceptable, none surprising once stated. Under a memory limit the build side is partitioned to disk on the join key and the buckets are replayed one at a time, so an equi-join over more data than memory works. The ORDER of the output then depends on the limit — see WithMemoryLimit — and there is deliberately no flag to opt out, because restoring probe order would mean holding an output that can be larger than both inputs.
func (*Join) CoalesceSide ¶
CoalesceSide says which input a merged key column takes its value from.
Why this is a side and not a merge ¶
coalesce(left.k, right.k) sounds like it needs a real two-column merge, and for one kind it does. For the rest the answer is a single side, exactly:
Inner both keys are present and EQUAL, so either side works
Left the left key is always present, so it is the answer
Right the left key is null on an unmatched right row, and the right key is
always present — and on a matched row the two are equal, so the RIGHT
side is the answer for every row
Full a row can be unmatched on EITHER side, so neither column alone has it
So only Full needs a merge, and Full does not coalesce by default. That is what lets the operator gather a coalesced key with one ordinary Take rather than a merge kernel that does not exist yet.
func (*Join) Label ¶
Label renders the join without touching a schema.
It must be total and deterministic: predicatePushdown.Apply detects whether it changed anything by comparing Explain output before and after, so a Label that could fail would make the optimizer fail.
The effective coalesce state is ALWAYS rendered, even at its default, because coalescing changes the output schema — and step 2 had to fix Aggregate.Label for printing "2 aggregates", which made two structurally different plans indistinguishable in a golden file. Everything else renders only when it differs from the default, so goldens stay terse.
func (*Join) Layout ¶
func (j *Join) Layout() (*JoinLayout, error)
Layout computes the join's output from its children's schemas.
It is the ONLY definition of the collision, suffixing and coalescing rules. Join.Schema, resolveJoin and the physical planner all go through it.
Deliberately NOT cached on the node: projection pushdown narrows a child via WithChildren, and a cached layout would go stale exactly then. WithColumns.Schema re-walks on every call for the same reason.
func (*Join) WithChildren ¶
type JoinColumn ¶
type JoinColumn struct {
Side JoinSide
Index int // ordinal in THAT side's input schema
// CoalesceWith is the RIGHT input ordinal whose value merges into this column,
// or -1. Only ever set on a FromLeft column. The operator emits
// coalesce(left, right); Schema records the promoted type.
CoalesceWith int
}
JoinColumn is the provenance of one output column, positionally aligned with JoinLayout.Schema.
type JoinKind ¶
type JoinKind uint8
JoinKind is the shape of a join.
const ( // JoinInner is the zero value and the universal default: only matching pairs. JoinInner JoinKind = iota JoinLeft // every left row; right columns null where unmatched JoinRight // every right row; the mirror JoinFull // every row from both sides JoinSemi // left rows with at least one match — a filter JoinAnti // left rows with no match — the inverse of semi JoinCross // cartesian product, no keys )
type JoinLayout ¶
type JoinLayout struct {
Schema *dtype.Schema
Columns []JoinColumn // len == Schema.Len()
// KeyTypes[i] is the promoted type in which key pair i is COMPARED. Both sides
// cast to it. This is the expr.Binding lesson: the evaluator must use the
// resolved type rather than re-deriving it, or two copies of the promotion
// rules drift.
KeyTypes []dtype.DataType
}
JoinLayout is the single authority on a join's output.
Why this returns more than a schema ¶
Step 2's audit found WithColumns.Schema() and resolveWithColumns() had implemented DIFFERENT semantics, which surfaced as "the optimizer produced a plan whose schema does not resolve". The fix was one function.
A join has a worse version of the same exposure, because the physical operator needs more than the schema: it needs to know, per output column, which side and which ordinal to gather from, and which right column to merge with. Returning only a *dtype.Schema would force the operator to re-derive "which right columns did I drop, which did I rename" — a second implementation of the collision algorithm, which would diverge.
So Layout returns everything, and Schema() is a one-line projection of it.
type JoinValidation ¶
type JoinValidation uint8
JoinValidation asserts a cardinality, erroring when the data violates it.
The first term names the LEFT frame, matching pandas and Polars: ManyToOne on orders.Join(customers) asserts that the customers are unique.
ursus-api.md calls accidental fan-out "the single most common analytics bug", which is the whole reason this exists.
const ( ValidateNone JoinValidation = iota ValidateOneToOne // keys unique on BOTH sides ValidateOneToMany // keys unique on the LEFT ValidateManyToOne // keys unique on the RIGHT ValidateManyToMany // no constraint )
func (JoinValidation) RequiresLeftUnique ¶
func (v JoinValidation) RequiresLeftUnique() bool
RequiresLeftUnique and RequiresRightUnique are what the operator checks.
func (JoinValidation) RequiresRightUnique ¶
func (v JoinValidation) RequiresRightUnique() bool
func (JoinValidation) String ¶
func (v JoinValidation) String() string
type Limit ¶
Limit keeps at most N rows from the start of its input.
It is in the skeleton because head() is the first thing anyone types when exploring, and because it gives slice pushdown somewhere to land later.
func (*Limit) WithChildren ¶
type LocalRule ¶
type LocalRule interface {
Name() string
// Match returns a replacement for n, or (n, false, nil) to leave it alone.
// Children are already rewritten when it is called.
//
// It may return a node with a DIFFERENT schema; Local declines the rewrite in
// that case rather than trusting the rule. That is deliberate: the alternative
// is every rule re-deriving the check, and the one that forgets returns wrong
// columns with no error anywhere.
Match(n Node, f Flags) (Node, bool, error)
}
LocalRule is the easy case: a pure node-to-node replacement, applied bottom-up with children already rewritten.
design/logical.md §8.1 specified this alongside Rule and RunMode; the two pushdowns shipped without it because each needs to carry state DOWN the tree (the required-column set, the predicates being moved) and so had to own its own traversal. A rule that only ever looks at one node and its already-rewritten children needs none of that, and writing the sweep again per rule is how a walk acquires four subtly different copies — see Expressions' own doc for the case where that already happened.
It is thin because TransformUp exists, and that is not what earns it a file ¶
The traversal below is four lines. What earns this adapter its place is the GUARD it wraps every Match in: a local rewrite is accepted only when the replacement resolves to the same schema as what it replaced. Optimizer.Run verifies that at the ROOT of the plan, which is exactly where a rewrite buried under a Filter or a Join is invisible — the root schema of `Filter(bad) -> Scan` is the scan's schema no matter what `bad` became.
type MergeSorted ¶
type MergeSorted struct {
Left, Right Node
// Key names the sorted column. One column, by name, because both schemas must
// already be identical — there is no side to disambiguate.
Key string
}
MergeSorted interleaves two frames that are already sorted on a key, keeping the result sorted.
It is not a join and not a concat ¶
A Union concatenates: every row of the first frame, then every row of the second, and the result is sorted only if the second's first row follows the first's last. A merge interleaves, so the result is sorted whenever both inputs were. Nothing is matched and no row is dropped — the output height is always the sum.
Both inputs must be sorted on Key, and it is CHECKED ¶
Same discipline as GroupByDynamic and the as-of join: a sortedness claim that is trusted but wrong produces a result that is merely in the wrong order, with no error — which is worse here than elsewhere, because the whole point of the operator is the order.
func (*MergeSorted) Children ¶
func (m *MergeSorted) Children() []Node
func (*MergeSorted) Label ¶
func (m *MergeSorted) Label() string
func (*MergeSorted) Schema ¶
func (m *MergeSorted) Schema() (*dtype.Schema, error)
Schema is the shared schema, and the two sides must agree exactly.
Exactly, not compatibly: a merge produces rows from both frames interleaved, so a column that is Int32 on one side and Int64 on the other would need a promotion whose result belongs to neither input — which Union already has a mode for, and which would make this operator a second, quieter Union.
func (*MergeSorted) WithChildren ¶
func (m *MergeSorted) WithChildren(kids []Node) Node
type Node ¶
type Node interface {
// Schema returns the node's output schema. Valid only on a resolved plan;
// it never reads data.
Schema() (*dtype.Schema, error)
// Children returns the input nodes, in order.
Children() []Node
// WithChildren returns a copy of this node with new children. It is how every
// rewrite is expressed: no rule ever mutates a node in place.
WithChildren([]Node) Node
// Label is the single-line description used by Explain, without children.
Label() string
// contains filtered or unexported methods
}
Node is one relational operation.
Sealed by the unexported planNode method, so a type switch over Node is exhaustive by construction. The package is internal, but the identifier is exported because ursus, ursus/sql and the physical planner all consume it.
func Resolve ¶
Resolve prepares a freshly built plan for optimization and execution.
It walks bottom-up and, at each node:
- fetches source schemas (the only step that performs I/O, hence the context),
- EXPANDS multi-column expressions against the now-known input schema,
- TYPE-CHECKS every expression,
- validates node-specific rules (Filter predicates must be Boolean, Project output names must be unique).
The result is a plan on which Schema() is total and cheap, every Project holds single-output expressions, and every expression's types are known.
Why expansion happens here and not earlier or later ¶
Not earlier: `ColDType(Float64)` has no meaning without a schema.
Not later: projection pushdown asks each expression which columns it reads. An unexpanded matcher answers "none", so the optimizer would prune away exactly the columns the expression was about to select. Running expansion before the optimizer, rather than teaching every rule about matchers, is what keeps the rules simple and correct by default.
func TransformUp ¶
TransformUp rewrites the tree bottom-up, applying fn to each node after its children have been rewritten. This is the shape almost every rule wants: a rule that changes a child's schema should see the change before it processes the parent.
type Optimizer ¶
type Optimizer struct {
// Verify runs the schema-preservation check after every rule. On in tests,
// off in production, because it costs a full schema resolution per rule.
Verify bool
// contains filtered or unexported fields
}
Optimizer applies rules to a resolved plan.
func (*Optimizer) Run ¶
Run applies every enabled rule to n.
After each rule it optionally re-derives the root schema and compares it to the original. A rewrite that changes the output schema is not an optimization, it is a bug, and this check catches an entire class of them for the cost of one schema resolution per rule.
func (*Optimizer) SetConstEvaluator ¶
func (o *Optimizer) SetConstEvaluator(e ConstEvaluator)
SetConstEvaluator attaches a constant folder to the simplification rule.
Without one the rule still performs every structural rewrite it knows; only Lit⊕Lit folding is skipped. See ConstEvaluator for why this is injected rather than imported, and TestPlanPackageNeedsNoArrow for the invariant it protects.
type PredicatePushdown ¶
type PredicatePushdown interface {
Source
// ClassifyPredicates reports, for each conjunct in preds and in the same
// order, how completely this source can honour it.
//
// The returned slice MUST have exactly len(preds) entries. A source that
// violates this is treated as supporting nothing — see ClassifyPredicates,
// the package-level helper, which is the only thing the optimizer calls.
//
// It must be a pure function of preds: the optimizer may call it more than
// once, and it runs long before any data is read.
ClassifyPredicates(preds []expr.Node) []Pushdown
}
PredicatePushdown is implemented by a Source that can use predicates itself.
It is optional, in the same way source.Openable is: a Source that does not implement it is fully usable and simply gets its filtering done above the scan. The optional-interface shape is what makes Unsupported the default for every source that has not thought about the question.
type Project ¶
Project computes a new set of columns from its input.
This backs Select. After Resolve, every expression produces exactly one column, so the output schema is one field per element of Exprs, in order.
func (*Project) WithChildren ¶
type Pushdown ¶
type Pushdown uint8
Pushdown says how completely a source can honour ONE predicate conjunct.
Why this is not a bool ¶
The obvious `Caps.Predicate bool` is wrong in two independent ways, and both return wrong ROWS rather than an error.
First, capability is per-conjunct. In `x > 5 AND lower(y) = 'z'` a Parquet source can use the first against row-group statistics and can do nothing with the second. Filter.Preds is already a conjunct slice precisely so that conjuncts move independently; a single bool forces an all-or-nothing decision over a set whose members differ.
Second, and worse: honouring a predicate is not the same as SATISFYING it. Row-group pruning skips groups that provably contain no match, but every surviving group still holds non-matching rows. A source that reports "yes I handle predicates" and is then trusted to have applied them exactly returns those extra rows to the user. Silent, data-dependent, and invisible on any fixture small enough to fit in one row group.
const ( // Unsupported: the source cannot use this conjunct. The Filter stays above // the Scan and the conjunct is not recorded on the Scan at all. // // THIS IS THE ZERO VALUE, deliberately. A short slice, a missing switch arm, // a source that forgets to answer — every accident lands here, and the // consequence of landing here is a slower plan rather than a wrong one. Unsupported Pushdown = iota // Inexact: the source may use this conjunct to skip data, but what it returns // is a SUPERSET of the matching rows. The conjunct goes into the Scan AND // stays in a Filter above it. This is what Parquet row-group pruning is. Inexact // Exact: the source returns exactly the matching rows, with the same null // semantics the engine would apply (a row survives iff the predicate is valid // and true). Only then may the Filter be removed. // // Claim this only if it is true. It is the one value that deletes a correctness // check from the plan. Exact )
func ClassifyPredicates ¶
ClassifyPredicates asks src how it can handle each conjunct, failing closed.
Every path that cannot be trusted — a source that does not implement the interface, a slice of the wrong length, a value outside the enum — yields Unsupported for every conjunct. The rule is that a source must actively and correctly claim a capability to get it; nothing is inferred.
type Reverse ¶
type Reverse struct{ Input Node }
Reverse emits rows in the opposite order.
A full pipeline breaker: the last input row is the first output row, so nothing can be emitted until the input ends. Memory is O(rows), like Sort's — and unlike Sort it does no comparison, so it is the cheapest possible breaker and a useful shape to have written when external buffering arrives.
func (*Reverse) WithChildren ¶
type RowIndex ¶
RowIndex prepends a column numbering the rows from Offset.
It is a node rather than an expression ¶
`CumCount(false).Over()` computes the same numbers, and does it through the window sink — a pipeline breaker holding every row. A row index needs none of that: it is a counter that crosses batch boundaries, which is the third operator shape (stateful and streaming, like limitOp and distinctOp) rather than the second.
It also joins the list of things that depend on the driver delivering batches in input order. Numbering rows is meaningless otherwise.
func (*RowIndex) WithChildren ¶
type Rule ¶
Rule is one optimizer rewrite.
A rule takes a resolved plan and returns a rewritten one plus whether it changed anything. It must be SOUND: the rewritten plan must produce the same rows, in the same order, with the same schema. The optimizer verifies the schema half of that automatically — see Optimizer.Run. Apply receives the Flags so a rule can gate an individual ARM, not just its own existence. Predicate pushdown needs that: moving a filter through a join is far riskier than moving one through a sort, and "turn off all predicate pushdown" is too blunt a tool to bisect with when the two live in one recursive sweep.
type RunMode ¶
type RunMode uint8
RunMode says how often the driver applies a rule.
const ( // Once: the rule reaches its own fixed point internally, so a second pass // would be wasted work. Pushdowns are like this — one top-down sweep places // every projection. Once RunMode = iota // UntilStable: the rule makes local improvements that may enable each other, // so it is re-applied until nothing changes. Constant folding is like this. UntilStable )
type Scan ¶
type Scan struct {
Src Source
Full *dtype.Schema // the source's whole schema; set by Resolve
// Projection is the exact column list to emit, in order. nil = all columns.
Projection []string
// Predicate holds conjuncts the source claimed it could use, and NOTHING
// about what it guarantees. A conjunct here may be Exact (removed from the
// plan) or Inexact (still present in a Filter above this node) — the Scan does
// not record which, because it does not need to: whatever the source fails to
// remove, the surviving Filter removes.
//
// A conjunct reaches this field only via predicatePushdown, which asks the
// source with ClassifyPredicates first. Putting one here by hand asserts that
// the source can honour it.
Predicate []expr.Node
// MaxRows is a hint that no more than this many rows will be consumed. 0 means
// unlimited — chosen so the zero value is "read everything", since the failure
// mode of a mistaken 0 with the opposite convention is an empty result.
//
// It is a HINT. A source may return more, and the Limit node that produced it
// always remains in the plan to enforce the bound.
MaxRows int
}
Scan reads from a Source.
Projection nil means "every column"; a non-nil (possibly empty) slice is the exact set to produce, in that order. Projection pushdown fills it in, which is the entire reason Scan exists as a distinct node from Source: the optimizer needs somewhere to record what it worked out.
Full is the source's complete schema, captured during Resolve so that later phases never need I/O or a context to answer a schema question.
Projection is the EMIT set, not the READ set ¶
Projection is what the scan produces, and it is what Schema reports. The columns the source must decode are a superset: a conjunct in Predicate that was classified Exact is deleted from the plan entirely, so nothing above the scan mentions its columns, yet the source cannot evaluate it without reading them.
Filter(c > 5).Select(a) with c > 5 Exact
=> Scan{Projection: [a], Predicate: [c > 5]}
The source reads {a, c} and emits {a}. That is not a wart to be fixed by widening Projection — widening it would make the scan emit a column the plan above does not expect, changing the output schema. It is a documented part of the contract; see source.ScanSpec, which says the same thing to implementors.
func (*Scan) ReadSet ¶
ReadSet returns the columns the source must decode: the emit set plus every column referenced by a pushed-down predicate.
Nothing in the plan consumes this — the scan's schema is the emit set and that is what the operators above see. It exists so Explain can show the truth: a scan rendered as "projection: [a] (1/3 cols)" while quietly reading a second column for its predicate is a plan that lies to whoever is debugging it.
func (*Scan) WithChildren ¶
func (*Scan) WithMaxRows ¶
WithMaxRows returns a copy of the scan carrying a row-count hint.
func (*Scan) WithProjection ¶
WithProjection returns a copy of the scan projecting the given columns.
type Slice ¶
Slice keeps Len rows starting at Offset.
Streaming, not a breaker: it counts rows past and stops, so it never holds more than one batch. Head is the Offset == 0 special case and stays its own node because limit pushdown recognises it structurally.
A negative Len means "to the end", which is how a caller says "drop the first n rows" without knowing the height.
func (*Slice) WithChildren ¶
type Sort ¶
type Sort struct {
Input Node
Keys []SortKey
// Limit > 0 means only the first Limit rows are needed, which turns a full
// sort into a bounded top-k. Set by the limit-pushdown rule.
Limit int
}
Sort orders rows.
This is a PIPELINE BREAKER: it must see every input row before it can emit the first output row. It is therefore an Operator rather than a BatchOp, and it is where memory pressure first becomes real — which is why the streaming engine's external merge sort has this node as its target.
func (*Sort) WithChildren ¶
func (*Sort) WithLimit ¶
WithLimit returns a copy of the sort carrying a row-count bound.
Mirrors Scan.WithMaxRows, and unlike it this bound is EXACT rather than a hint: sortSink honours it by switching to ArgTopK, whose contract is that it returns the same indices ArgSort would, in the same order, including for ties. So the enclosing Limit becomes redundant rather than load-bearing — but it stays, for the same reason limitPushdown leaves it above a Scan: the rule is a performance rewrite and nothing about correctness should depend on it having fired.
type SortKey ¶
SortKey is one component of an ordering.
An alias rather than a second declaration: a window carries its own ordering and internal/expr may not import internal/plan, so the type has to live at the lower level. Two structurally identical types would be two places for the null-placement rule to drift apart.
type Source ¶
type Source interface {
// Name identifies the source in Explain output, e.g. "parquet" or "memory".
Name() string
// Describe is a short human-readable detail line, e.g. a path or a row count.
Describe() string
// Schema returns the full schema of the source, before any projection.
Schema(ctx context.Context) (*dtype.Schema, error)
// Caps reports which pushdowns the source can honour itself.
Caps() Caps
}
Source is a thing a Scan can read from.
This is ursus's TableProvider equivalent, and it is public from the start because "plug in your own source" is a first-class use case, not an afterthought — an engine whose only inputs are the file formats its authors happened to implement is much less useful than one you can point at your own storage.
Note Schema takes a context: resolving a Parquet footer or inferring a CSV schema is real I/O, and a schema request that cannot be cancelled would be a hole in the cancellation story.
type Tail ¶
Tail keeps the last N rows.
Why it is not a Slice with a negative offset ¶
Slice can stream because it knows where to start. Tail cannot: which rows are the last N is unknown until the input ends. It holds a ring of the most recent N rows — O(N) memory, not O(input), so it is bounded but not free, and it is the only operator here that buffers.
func (*Tail) WithChildren ¶
type TemporalGroup ¶
type TemporalGroup struct {
Input Node
// Index is the temporal column the windows are cut from. It must resolve to a
// Date or Datetime, and the operator verifies at run time that it arrives sorted.
Index expr.Node
// Keys are extra CATEGORICAL partitions — DynamicOptions.GroupBy. Windows are cut
// independently inside each one, so a per-service hourly rollup is one node.
Keys []expr.Node
Aggs []expr.Node
// Rolling picks the window generator: false is the grid, true is one per row.
Rolling bool
Every, Period, Offset dtype.Interval
Closed expr.Closed
}
TemporalGroup is GroupByDynamic and Rolling, which are one node because they are one algorithm with two window generators.
Both reduce to "aggregate a RANGE of rows" ¶
The naive reading is alarming: one row can belong to several windows, and every aggregate operator here indexes group state by row. But both operators require the index column SORTED, and that changes the shape completely —
If rows are ordered by the index, every temporal window covers a CONTIGUOUS RUN of rows. A window is not a set of row ids; it is a pair (lo, hi).
— which is what lets one mechanism serve both, and lets both reuse every existing accumulator unchanged. All nineteen aggregates work in a temporal window on the day this node lands, including the two that refuse to spill, because nothing about kernel.Accumulator moves.
Where they differ ¶
GroupByDynamic windows come from a GRID: offset + k*every, each period long.
They exist even when no row falls in them, which is the whole
difference from GroupBy(truncate(ts, every)) and the reason a
gap in a time series shows up as a zero rather than as nothing.
Rolling one window per ROW, ending at that row's own timestamp and
reaching back by period. Output height equals input height.
It is a pipeline breaker, and the Explain sketch in the API doc is aspirational ¶
ursus-api.md shows GROUP_BY_DYNAMIC as `streaming: yes`, which is achievable on a sorted index — a window can be emitted as soon as the input passes its end. This implementation buffers instead, because the grid's extent is derived from the observed [min, max] and because that is what makes empty windows fall out for free. The cost is O(rows), the same as windowSink's and for the same reason.
func (*TemporalGroup) Children ¶
func (t *TemporalGroup) Children() []Node
func (*TemporalGroup) Label ¶
func (t *TemporalGroup) Label() string
func (*TemporalGroup) Schema ¶
func (t *TemporalGroup) Schema() (*dtype.Schema, error)
Schema is the index column, then the categorical keys, then the aggregates — the same shape as Aggregate's, with the index taking the place of the first key.
The index keeps its own name and type: for a dynamic group it holds the window's start instant, and for a rolling group the row's own instant. Both are values of the column the windows were cut from, so neither changes type.
func (*TemporalGroup) WithChildren ¶
func (t *TemporalGroup) WithChildren(kids []Node) Node
type Union ¶
type Union struct {
Inputs []Node
Mode ConcatMode
}
Union stacks frames vertically — the first n-ary node in the plan.
Arity was the cheap part; reconciliation is the cost ¶
Join's doc records that adding the first TWO-child node cost almost nothing in traversal — "the generic traversals were already written N-ary and need nothing" — and everything in SIDEDNESS. A Union has no sides: every child produces the same output, so none of Join's layout, suffixing or per-side pushdown machinery has an analogue here.
What it has instead is a schema that is a function of ALL its children jointly, where every other node's is a function of one.
Strict is not schema equality ¶
dtype.Field compares by struct equality, and that includes Nullable — which is "a static property of the schema, not a count of nulls actually present". So two frames differing only in a nullability flag are NOT Equal, and refusing to concatenate them would be absurd: it is the ordinary result of filtering one of them. Strict therefore means "the same columns in the same order", with types promoted and nullability unioned.
The schema is never cached ¶
Deliberately, for the reason JoinLayout gives: projection pushdown narrows a child through WithChildren, and a cached reconciliation would go stale exactly then.
func (*Union) WithChildren ¶
WithChildren cannot assert an arity, which every other node does.
Join panics on != 2 and the single-child nodes on != 1, so a rewrite that loses a child is caught immediately. A Union takes any number, so the only check left is that it kept at least one.
type Window ¶
type Window struct {
Input Node
// Exprs are the windows to compute. Every one is an *expr.Window wrapped in an
// *expr.Alias naming its temporary, which is what lets Schema resolve them
// through the ordinary path.
Exprs []expr.Node
}
Window computes windowed expressions and APPENDS them to its input's schema.
Why it is a node at all ¶
A user never writes one. `Select(Col("x").Mean().Over(Col("g")))` mentions no window operator, the way `GroupBy(k).Agg(...)` mentions an aggregation — so the node is synthesised during Resolve, spliced beneath the Project or WithColumns whose expressions contained windows, with each window subtree replaced by a reference to a `__winN` temporary.
The alternative was to extract windows in the PHYSICAL planner, the way extractAggs pulls aggregates out of an Aggregate's expressions. That works and costs two things worth more than it saves: Explain would not show that a window is being computed, and the optimizer could not see it — which matters because a window is a predicate-pushdown barrier and a rule cannot refuse to cross a node it cannot observe.
It appends rather than replaces ¶
Output is the input schema plus one field per expression. That is what makes the Project above it work: it evaluates over the original columns and the temporaries side by side, so `Col("x").Sub(Col("x").Mean().Over(g))` needs no special case — the subtraction is ordinary arithmetic over two ordinary columns.
func (*Window) Label ¶
Label renders the windows themselves, not a count.
Same reason Aggregate does: a golden plan file is how an optimizer regression is caught, and "2 windows" makes two structurally different queries indistinguishable in the snapshot.
func (*Window) WithChildren ¶
type WithColumns ¶
WithColumns adds or replaces columns, keeping everything else.
It is distinct from Project rather than sugar over `Project(All(), newcols...)` because the replace-in-place semantics matter: recomputing an existing column must leave it in its original position, not move it to the end. Users notice column order, and a rewrite that reshuffles it on every recomputation is endlessly annoying.
func (*WithColumns) Children ¶
func (w *WithColumns) Children() []Node
func (*WithColumns) Label ¶
func (w *WithColumns) Label() string
func (*WithColumns) WithChildren ¶
func (w *WithColumns) WithChildren(kids []Node) Node