Stability: Internal — see Stability Tiers.
This document is the canonical guide for how an optimizer rule or plan builder asks a question about a plan node — "is this a specific class?", "can this node do X?", "what are its physical properties?" — and which mechanism answers each. It also covers the characteristics/physical-property layer that many rules reason over.
A rule almost always needs one of three distinct things from a node. They are not interchangeable, and each has exactly one right mechanism:
| Question the rule is asking | Mechanism | Where it lives |
|---|---|---|
| "Is this this specific class, so I can use its API?" | instanceof SomeNode |
the node classes themselves |
| "Can this node do X, whatever its class?" (any join kind, any aggregate kind) | branded marker interface + CapabilityDetectors guard |
src/planner/framework/characteristics.ts |
| "What are this node's physical properties?" (readonly / ordering / FDs / determinism) | PlanNode.physical via PlanNodeCharacteristics |
src/planner/framework/characteristics.ts |
A fourth mechanism, node.nodeType (a PlanNodeType enum value), exists but is for dispatch and serialization only — rule-manifest routing, the plan formatter, EXPLAIN. It is not a class-narrowing tool: nodeType is not 1:1 with classes and gives the compiler no narrowing.
Need one class's specific API →
instanceofthat class. Need "any node that can do X" → the capability guard.
Both are legitimate. They differ by intent, and that difference is not "drift." A rule that only ever works on AggregateNode should say instanceof AggregateNode; a rule that works on any aggregating node should ask CapabilityDetectors.isAggregating(node). Choosing the narrower instanceof when you mean one class is correct, not a smell.
Use instanceof when a rule needs a specific class's API. It is type-sound, narrows natively, and is the dominant idiom in the planner (hundreds of call sites). The canonical rule shape is:
const ruleAggregateStreaming: RuleFn = (node, optimizer) => {
if (!(node instanceof AggregateNode)) return null;
// node is now AggregateNode — its full API is available with compiler narrowing
...
};Why instanceof is safe here (cross-bundle constraint). Planner node classes are singletons within @quereus/quereus — there is exactly one AggregateNode constructor per process, and plugins never receive plan nodes across a bundle boundary. So the classic instanceof-across-realms hazard (two copies of a class, instanceof silently false) does not arise for plan nodes. instanceof on a planner node is as reliable as any other identity check.
When a rule accepts any implementer of a capability — any join kind, any aggregate kind, anything that exposes a predicate — use the branded marker interfaces in characteristics.ts and their CapabilityDetectors guards.
Each capability interface declares a unique readonly is<X>Capable: true brand. Every implementer sets it, and the matching guard tests exactly that marker:
export interface AggregationCapable extends RelationalPlanNode {
readonly isAggregationCapable: true; // the brand
getGroupingKeys(): readonly ScalarPlanNode[];
...
}
// in CapabilityDetectors:
static isAggregating(node: PlanNode): node is AggregationCapable {
return (node as Partial<Pick<AggregationCapable, 'isAggregationCapable'>>).isAggregationCapable === true;
}To make a node detectable as a capability:
- Declare
implements XCapableon the class. - Set the
is<X>Capablebrand totrue. - Add (or reuse) the guard in
CapabilityDetectors.
The compiler enforces completeness. implements XCapable fails to compile unless the class also sets the brand, so "implements the capability" and "is detected as having it" are the same fact — a new implementer cannot silently be missed by a guard. A unique brand name also cannot misfire on an incidental property.
The thing to not do — and the reason characteristics.ts is lint-guarded against any — is detect a capability by probing for a property or method with a cast to any:
// ❌ Anti-pattern: duck-typed property-presence check
function isAggregating(node: PlanNode): boolean {
return 'getGroupingKeys' in node
&& typeof (node as any).getGroupingKeys === 'function';
}This misfires: it silently matches any unrelated node that happens to grow a getGroupingKeys member, and it silently stops matching if the method is renamed — with no compiler help either way. The brand mechanism above replaced every such detector. characteristics.ts carries a file-scoped @typescript-eslint/no-explicit-any: error override (in packages/quereus/eslint.config.mjs) so a reintroduced as any detector fails lint.
node.nodeType routes the rule manifest, drives the plan formatter, and labels EXPLAIN output. Use it there. Do not use it to narrow to a class in rule logic — it is not 1:1 with classes and the compiler cannot narrow on it. For "is this a specific class?" use instanceof; for "can it do X?" use the capability guard.
Physical properties — readonly, ordering, functional dependencies, determinism, cardinality — are the canonical source for "what this node does at runtime," independent of its class. They live on PlanNode.physical and are read through PlanNodeCharacteristics.
This is a genuinely different question from class identity. Detecting side effects, for example, is a physical-property question, not an instanceof one:
// A node's side-effect status is a physical property, not a class fact —
// UpdateNode, DeleteNode, and any future mutating node all answer through
// the same surface.
if (PlanNodeCharacteristics.hasSideEffects(node)) {
// handle operations with side effects
}Likewise "does this produce ordered output?" is answered by physical properties, not by enumerating the classes (Sort, StreamAggregate, …) that happen to:
if (PlanNodeCharacteristics.hasOrderedOutput(node)) {
// any node that produces ordered output
}The detector surface (PlanNodeCharacteristics) covers side effects, readonly/determinism/idempotence, ordering and monotonicity, cardinality (estimatesRows, guaranteesUniqueRows, hasUniqueKeys), and the relational/scalar/void type class. See src/planner/framework/characteristics.ts for the full list.
Problem: Rules need to reason about "what determines what" — uniqueness, transitive equalities, pinned constants, domain constraints — across many operator shapes without re-implementing the algebra.
Solution: Treat PhysicalProperties.fds / equivClasses / constantBindings / domainConstraints as the single source of truth, and route every query through the helpers in planner/util/fd-utils.ts rather than walking the lists directly.
// ❌ Fragile: re-implementing closure inline
const determined = new Set<number>(seedCols);
for (const fd of node.physical.fds ?? []) {
if (fd.determinants.every(d => determined.has(d))) {
for (const dep of fd.dependents) determined.add(dep);
}
}
// ✅ Robust: use the shared fixed-point helper (which also handles iteration to convergence)
import { computeClosure } from '../util/fd-utils.js';
const determined = computeClosure(seedCols, node.physical.fds ?? []);Key conventions for FD-aware rules. Most of these are normative — the register is where they are stated, and where a reviewer checks them against the code. This guide keeps only what a rule author needs at the keyboard.
Reason through the helpers, never by walking physical.fds yourself — hand-walking misses
transitive closure and forgets the subsumption / cap / guard semantics addFd enforces. And
pick coverage vs uniqueness deliberately: closureCoversAll is a pure value claim (a
determined column is redundant in an ORDER BY / GROUP BY regardless of uniqueness),
whereas isUniqueDeterminant — or, at node level, keysOf / isUnique — is the only sound
way to ask "is this set row-unique?".
If your rule needs a guarded FD's fact, do not discharge the guard yourself. Discharge happens
at the producing Filter's computePhysical, via predicateImpliesGuard + stripGuard.
Invariant: OPT-048
Crossing a Project / Returning / Aggregate / join boundary, translate via projectFds /
shiftFds and their EC / binding / domain / IND mirrors instead of hand-mapping indices.
Accumulate with addFd, not Array.push. Pass { keyHints } listing column-index sets known
to be keys so cap eviction prefers to keep them; truncations log on the quereus:planner:fd
debug channel.
Invariant: OPT-042
Do not invent a propagation policy for a new operator — follow the per-operator table in Functional Dependency Tracking.
Set-ness is not an FD, but the readers consume it: hasAnyKey(fds, columnCount, isSet) and
friends take it as a parameter, while keysOf / isUnique read getType().isSet themselves.
And a source tag is for diagnostics — never branch rule logic on it.
Not in the register (a convention, not an invariant): whenever a rule adds a
ConstantBinding at the same site as ECs (Filter, inner join), close it with
closeConstantBindingsOverEcs so downstream consumers see the binding on every EC peer in one
pass. That is what makes WHERE t.k = u.k AND t.k = 5 land as one binding covering both
columns.
See Functional Dependency Tracking for the producer/consumer catalog and the per-operator propagation table, and Assertions § Binding-aware Delta Planning for the analyzeRowSpecific / extractBindings analysis surface that builds on this layer.
Cache eligibility mixes all three questions — a physical-property check (isRelational, hasSideEffects) and a capability guard (isCached narrows to CacheCapable, so isCached() is callable without a cast):
export class CachingAnalysis {
static isCacheable(node: PlanNode): boolean {
// Physical: must be relational to cache results
if (!PlanNodeCharacteristics.isRelational(node)) return false;
// Capability: already-cached nodes don't need re-caching
if (CapabilityDetectors.isCached(node) && node.isCached()) return false;
// Physical: side effects gate cacheability
if (PlanNodeCharacteristics.hasSideEffects(node)) {
return this.isExpensiveRepeatedOperation(node);
}
return true;
}
}Before writing a rule, name what it needs from the node:
function ruleMyOptimization(node: PlanNode, context: OptContext): PlanNode | null {
// Class identity → instanceof
if (!(node instanceof MyTargetNode)) return null;
// Physical gate → PlanNodeCharacteristics
if (PlanNodeCharacteristics.hasSideEffects(node)) return null;
// Cross-class capability → CapabilityDetectors
if (!CapabilityDetectors.canPushDownPredicate(node)) return null;
return transform(node, context);
}Make a rule's requirements explicit in its doc comment:
/**
* Rule: Predicate Pushdown
*
* Required:
* - Node implements PredicateCapable (CapabilityDetectors.canPushDownPredicate)
* - Node is read-only (PlanNodeCharacteristics.hasSideEffects === false)
* - Predicate is deterministic
*/
export function rulePushDownPredicate(node: PlanNode, context: OptContext): PlanNode | null {
// Implementation follows documented requirements
}When working with the optimizer or plan builders:
- Need a specific class's API? →
instanceof ThatNode. It's type-sound, narrows natively, and is the dominant idiom. Safe here because planner nodes are singletons in@quereus/quereus(no cross-bundle realm hazard). - Need "any node that can do X"? → a
CapabilityDetectorsguard backed by a branded marker interface insrc/planner/framework/characteristics.ts. - Need a physical property (readonly / ordering / FDs / determinism / cardinality)? →
PlanNodeCharacteristicsoverPlanNode.physical. - Routing / serialization / EXPLAIN? →
nodeType. Never for class narrowing in rule logic. - DON'T detect a capability by duck-typing —
'foo' in node && typeof (node as any).foo === 'function'. That's the misfiring pattern the brand mechanism (and theno-explicit-anyguard oncharacteristics.ts) exists to prevent.