Optimizer Rules
The optimizer applies an ordered list of rewrite rules to the logical plan. Each rule recognizes a pattern and replaces it with an equivalent, cheaper one. Writing a rule is how you teach the optimizer about a custom plan operator, but a rule can be as small as removing a redundant pair of operators.
The three parts of a rule
A PatternRule
has one field and two methods:
operatoris the plan-operator class the rule starts matching at (or an array of classes, ornullto consider every node). The optimizer only calls you on nodes of that type, so this is a cheap pre-filter.match(node)decides whether the pattern actually applies here. Return{ bindings }capturing whatevertransformwill need, ornull.transform(node, bindings)returns the operator that replacesnode.
A worked rule: merging nested limits
A subquery with its own LIMIT inside an outer LIMIT produces two
Limit operators separated by the
subquery's Projection:
Limit(skip=1, limit=2)
Projection
Limit(skip=0, limit=5)
Projection
TupleSource
A projection is one-to-one on rows, so the two limits can be folded into one.
The rule matches the three-node shape, splices the inner
Limit out, and returns a single merged
one:
import * as plan from '@dortdb/core/plan';
import { PatternRule } from '@dortdb/core/optimizer';
interface LimitBindings {
proj: plan.Projection;
inner: plan.Limit;
}
const mergeLimits: PatternRule<plan.Limit, LimitBindings> = {
operator: plan.Limit,
match: (node) => {
const proj = node.source;
if (!(proj instanceof plan.Projection)) return null;
const inner = proj.source;
if (!(inner instanceof plan.Limit)) return null;
return { bindings: { proj, inner } };
},
transform: (node, { proj, inner }) => {
proj.replaceChild(inner, inner.source);
return new plan.Limit(
node.lang,
inner.skip + node.skip,
Math.min(node.limit, Math.max(0, inner.limit - node.skip)),
proj,
);
},
};
The arithmetic is the whole substance of the rule: the inner limit has already
skipped inner.skip rows and kept at most inner.limit, so after the outer
limit skips node.skip more, at most inner.limit - node.skip remain
available.
Two habits worth copying:
matchdoes the checking,transformdoes the work. By the timetransformruns, everything it needs is inbindingsand no case can fail.- Rewire the tree with
replaceChildrather than assigning tosourcedirectly; it keeps theparentlinks consistent.
Register it
Rules are entries in the optimizer's ordered
rules
array, and order matters. Several built-in rules exist only to create
patterns a later rule can exploit. Place a new rule where it should run relative
to the others:
new DortDB({
mainLang: SQL(),
optimizer: { rules: [...defaultRules, mergeLimits] },
});
// or swap the rule set at runtime
db.optimizer.reconfigure({ rules: [...defaultRules, mergeLimits] });
A rule that needs the database itself can be supplied as a class instead of an
object; the optimizer instantiates it with the database interface, see
PatternRuleConstructor.
The built-in rules
range from small local rewrites to whole algorithms, and are the best source of
further examples.