RFC 008: Logical Plan IR
Mirrored from
docs/rfc/008-logical-plan-ir.mdin the engine repo. Source of truth lives there. Status: draft Author(s): Matías Fonseca info@namidb.com Supersedes: —
Summary
Define la representación intermedia (IR) que el query engine usa entre el
AST de Cypher (RFC-004) y el executor. El IR es un árbol de operadores
relacionales extendidos para grafos — el shape estándar de los DBMS modernos
(DuckDB, DataFusion, Materialize, Kùzu) — adaptado al modelo
property-graph. Esta RFC fija los operadores, su semántica, las reglas de
lowering desde Cypher, el tipo runtime RuntimeValue y la API del executor
naïve inicial (tree-walking, eager Vec<Row>).
Out-of-scope explícito en la versión inicial: streaming/morsel-driven execution, cost-based optimizer, WCOJ planner, parallelism, distribución multi-namespace, query result caching.
Motivation
Sin un IR estable, lowering, optimizer y executor terminan acoplados a la forma del AST. Esto duele en tres dimensiones:
-
Optimizer imposible de injertar. Para rewrite predicate pushdown / join-order / projection-elimination necesitamos un árbol que sepa hablar de
Filter(input, pred)yProject(input, items)como operadores independientes, no como cláusulas anidadas. -
Executor morsel-driven no puede compartir código. El executor vectorizado va a operar sobre el mismo árbol de operadores que el naïve; solo cambia la representación de filas (Arrow
RecordBatchvsVec<Row>) y la estrategia de scheduling. Si el IR es estable, la versión vectorizada reemplaza solo la implementación deOperator::execute. -
EXPLAIN/PROFILE necesitan algo qué imprimir. Sin IR, el
EXPLAINtendría que recorrer el AST y traducirlo on-the-fly cada vez. Con IR imprimimos el árbol directo.
El costo de hacerlo de entrada (vs diferirlo) es ~700–1 000 LoC y una iteración de design. El costo de diferirlo es refactor obligatorio cuando entren optimizer y executor vectorizado — peor.
Design
Tipo runtime: RuntimeValue
namidb-core::Value cubre los escalares (Null/Bool/I64/F64/Str/Bytes/Vec<f32>)
pero le faltan los compuestos que Cypher necesita: LIST, MAP, NODE,
RELATIONSHIP. Definimos RuntimeValue standalone en namidb-query
para mantener core agnóstico del query layer:
pub enum RuntimeValue { Null, Bool(bool), Integer(i64), Float(f64), String(String), List(Vec<RuntimeValue>), Map(BTreeMap<String, RuntimeValue>), Node(Box<NodeValue>), Rel(Box<RelValue>), // Date / DateTime / Duration: stubs iniciales; semantics completas más adelante. Date(i32), // days since 1970-01-01 DateTime(i64), // microseconds since 1970-01-01T00:00:00Z}NodeValue y RelValue envuelven NodeView / EdgeView del storage:
pub struct NodeValue { pub id: NodeId, pub label: String, pub properties: BTreeMap<String, RuntimeValue>,}
pub struct RelValue { pub edge_type: String, pub src: NodeId, pub dst: NodeId, pub properties: BTreeMap<String, RuntimeValue>,}Conversiones From<NodeView> y From<EdgeView> mapean
core::Value → RuntimeValue row-wise; esto introduce una copia pero es
aceptable en la versión inicial (la versión vectorizada futura va a operar
directo sobre Arrow batches sin esta conversión).
Tipo runtime: Row
pub struct Row { pub bindings: BTreeMap<String, RuntimeValue>,}Una Row es el estado completo de un binding scope en el current scope.
MATCH (a)-[r]->(b) RETURN a.name, r.weight, b.id produce rows con tres
bindings vivos (a, r, b) hasta el RETURN, que projecta a una nueva
row con solo a.name, r.weight, b.id.
Decisión BTreeMap (no HashMap): determinismo en orden de iteración
para tests y EXPLAIN output. Lookup O(log k) con k = #bindings —
inmaterial vs el costo de IO.
Operadores del IR
Cada operador es una variante de LogicalPlan. El árbol es child-pointer
single-input excepto Union (dos inputs). Aristas implícitas: cada
operador “produce rows” para su parent.
pub enum LogicalPlan { /// Producer de rows: scan completo de todos los nodes con `label`. /// `alias` es el binding que cada NodeValue ocupa en la row de salida. NodeScan { label: String, alias: String, },
/// Variante O(1): scan de un único node por id. Usado cuando el AST /// llega con `(p:Person {id: $personId})` — lowering detecta el filtro /// trivial y lo convierte en `NodeById` en vez de `Filter(NodeScan, ...)`. NodeById { label: String, alias: String, id: Expression, // typically Parameter("personId") or Literal(NodeId) },
/// Toma rows del `input`, expande la binding `source` por sus edges /// `direction`/`edge_type`, materializa el destino bajo `target_alias` /// y opcionalmente bind la rel en `rel_alias`. Expand { input: Box<LogicalPlan>, source: String, edge_type: Option<String>, direction: RelationshipDirection, rel_alias: Option<String>, target_alias: String, /// Cuando el AST trae variable-length `*min..max`, este campo /// guarda los bounds; lowering decide si genera un único `Expand` /// con length o (a futuro) un sub-plan recursivo. length: Option<RelationshipLength>, },
/// Selecciona rows que satisfacen `predicate`. Filter { input: Box<LogicalPlan>, predicate: Expression, },
/// Reemplaza el row con una nueva proyección. Mantiene scope abierto /// vía la lista de items (cada item es expression + optional alias). /// Si `discard_input_bindings = true`, las bindings no proyectadas /// se borran (RETURN-style). Si `false`, se conservan (WITH-style). Project { input: Box<LogicalPlan>, items: Vec<ProjectionItem>, distinct: bool, discard_input_bindings: bool, },
/// Agrupa por `group_by` y aplica las funciones aggregate. Aggregate { input: Box<LogicalPlan>, group_by: Vec<(Expression, String)>, // (key expression, output alias) aggregations: Vec<(String, AggregateExpr)>, // (output alias, agg) },
/// Sort + skip + limit fundidos. Si solo hay sort, `skip = 0`, /// `limit = u64::MAX`. Si solo hay limit, `keys` es vacío. TopN { input: Box<LogicalPlan>, keys: Vec<OrderKey>, skip: u64, limit: u64, },
/// Distinct sobre el set completo de columnas visibles. Distinct { input: Box<LogicalPlan>, },
/// UNION o UNION ALL. Union { left: Box<LogicalPlan>, right: Box<LogicalPlan>, all: bool, },
/// Expande una expression-list a multiple rows, una por elemento. Unwind { input: Box<LogicalPlan>, list: Expression, alias: String, },
/// Driver inicial sin filas — produce exactamente un row vacío. /// Necesario para queries que abren con UNWIND o WITH literal, ni /// para subqueries que arrancan independientes. Empty,}
pub struct ProjectionItem { pub expression: Expression, pub alias: String,}
pub struct OrderKey { pub expression: Expression, pub direction: OrderDirection,}
pub enum AggregateExpr { Count { arg: Option<Expression>, distinct: bool }, Sum { arg: Expression, distinct: bool }, Avg { arg: Expression, distinct: bool }, Min { arg: Expression }, Max { arg: Expression }, Collect { arg: Expression, distinct: bool },}Semántica NULL (three-valued logic)
Misma que Cypher 25 / GQL:
NULL OP NULL = NULLpara todoOP ∈ {=, <>, <, >, ...}.NULL AND false = false,NULL AND true = NULL,NULL AND NULL = NULL.NULL OR true = true,NULL OR false = NULL,NULL OR NULL = NULL.Filter(predicate)descarta rows cuyo predicate evalúa aNULL(igual quefalse).IS NULL/IS NOT NULLson los únicos operadores que devuelvenBoolpara inputNULL.- Aggregate functions (excepto
count(*)) ignoran NULL en sus inputs. - Comparison entre tipos incompatibles (e.g.
1 = "x") →NULL(no error). - Division by zero entre enteros → error runtime. Entre floats →
NaN(siguiendo IEEE 754; downstream<conNaNretornaNULL).
Semántica de scope
Cada clause MATCH/OPTIONAL MATCH/UNWIND/WITH/CREATE/MERGE
extiende el scope con nuevas bindings.
WITHcierra el scope: bindings no proyectadas se descartan. Es el único punto de re-arranque limpio. Cypher fuerza unWITHentre dosMATCHque comparten bindings — esto se controla en el AST, no en el IR.OPTIONAL MATCHpropagaNULLen todas las bindings cuando el match no tiene resultado. Implementado comoFilter+ outer-join semantics a futuro — inicialmente se baja aExpandcon flagoptionalque produce rows con bindingsNULLcuando no encuentra targets.- Las bindings de una
OrderByclausula siguiente aRETURN(oWITH) son las de la proyección, no las pre-proyección. Eso obliga a lowerRETURN ... ORDER BYcomoProject + TopN, noTopN + Project.
Evaluation order garantizado
El executor ejecuta el árbol bottom-up, depth-first. Si un operador tiene
dos entradas (Union) ejecuta left antes que right. Side-effects en
el executor están prohibidos inicialmente (no hay SET / CREATE /
DELETE todavía); cuando lleguen van a operadores dedicados
(SetProperty, CreateNode, DeleteNode) que ejecutan strictly after
todos los reads de la query (o lazy según RFC futuro).
Lowering rules
Para cada cláusula Cypher del subset RFC-004:
| Cypher | LogicalPlan |
|---|---|
MATCH (a:L) (no patterns más) | NodeScan { label: "L", alias: "a" } |
MATCH (a:L {id: $x}) (igualdad sobre id) | NodeById { label: "L", alias: "a", id: Parameter("x") } |
MATCH (a:L {id: $x}) (igualdad sobre otra prop) | Filter(NodeScan, a.prop = $x) |
MATCH (a)-[r:R]->(b) | Expand { input: <prev>, source: a, edge_type: R, dir: Right, rel_alias: r, target_alias: b } |
MATCH (a) WHERE p | Filter(<scan>, p) |
RETURN x, y AS z | Project { items: [x, z=y], discard_input=true } |
RETURN DISTINCT x | Project { distinct: true, ... } |
WITH x, y AS z | Project { items: [x, z=y], discard_input=true } (mismo que RETURN — diferencia es solo si hay clauses siguientes) |
WITH x WHERE p | Filter(Project(...), p) |
ORDER BY k1, k2 SKIP s LIMIT l (después de Project) | TopN { keys: [k1, k2], skip: s, limit: l } |
UNION ALL | Union { all: true } |
UNION | Distinct(Union { all: false }) |
UNWIND list AS x | Unwind { input: <prev or Empty>, list, alias: x } |
MATCH a, b (multiple patterns, mismo MATCH) | Cross product: lower b con input = lowered(a) y sin shared bindings. |
La regla específica para OPTIONAL MATCH:
OPTIONAL MATCH (a)-[r]->(b)conaya bindeada del scope anterior: lower comoExpand { ..., optional: true }. Si no hay match, produce un row conr = NULLyb = NULL(preserva el row input).- Sin variable-length permitido (parser ya lo rechaza, ver
RFC-004 §Drawbacks 5).
EXPLAIN format
Project [name=a.firstName, age=a.age] TopN keys=[a.age DESC] skip=0 limit=10 Filter (a.age > 18) Expand source=a edge_type=KNOWS dir=-> target=b NodeScan label=Person alias=aCada operador se imprime en una línea con el nombre del operador, sus
parámetros entre [...] o nombre=value, y los hijos indentados con
dos espacios. EXPLAIN produce esto; PROFILE (a futuro) lo decora con
runtime stats (rows_out, time_ms, bytes_read).
API del executor
pub async fn execute( plan: &LogicalPlan, snapshot: &Snapshot<'_>, params: &BTreeMap<String, RuntimeValue>,) -> Result<Vec<Row>, ExecError>;Trae todo a memoria. Eager. Single-thread (tokio current_thread).
ExecError cubre: binding not found, type error, parameter not provided,
storage error.
Alternatives considered
A. AST → directamente executor (no IR)
Pro: menos código, menos boilerplate. Con: acopla executor a AST. Optimizer requeriría refactor masivo. EXPLAIN tendría que reconstruir el plan en string-time. Veredicto: rechazado. La inversión IR-first es ~300 LoC extra que ahorra >1000 LoC más adelante.
B. Push-based dataflow (Materialize-style)
Pro: modelo dataflow nativo, encaja con streaming y continuous queries. Con: mucho más complejo. Cada operador es un actor con state + input/output channels. Overhead alto para queries one-shot. Diferencial solo aparece en multi-query / streaming scenarios. Veredicto: rechazado; potencial RFC futuro si entramos a streaming/CDC.
C. Volcano-style iterator (trait Operator { fn next(); })
Pro: estándar en DBMS clásicos (Postgres, MySQL pre-pipelined).
Lazy, low-memory per operator. Streaming natural.
Con: sin parallelism. Function-call overhead por row. La industria
moderna (DuckDB, Velox) lo abandonó.
Veredicto: rechazado. Inicialmente eager Vec<Row> es más simple y
suficiente; a futuro vamos directo a morsel-driven, no Volcano.
D. DataFusion como IR
Pro: maduro, optimizer “para free”, compatibilidad con Arrow.
Con: DataFusion es relacional, no graph-shaped. Adaptar Expand,
multi-hop, WCOJ a DataFusion es trabajo grande y nunca natural.
Veredicto: rechazado como IR único. A futuro lo cableamos como
bridge para SQL surface paralelo (graph queries en nuestro IR,
SQL surface en DataFusion, mismo executor).
E. Single-input vs multi-input operators
Decisión: single-input excepto Union. Join (Hash, NL, LFTJ) es
explícito multi-input pero no aparece inicialmente (lowering produce
Expand chain, no joins). Joins entran cuando el optimizer re-ordene.
Drawbacks
-
RuntimeValueintroduce conversión row-by-row vs Arrow. Aceptable inicialmente (correctness-first); la versión vectorizada elimina la conversión midiendo sobreRecordBatchdirecto. Mientras tanto, hot loops conviertenBTreeMap<String, core::Value> → BTreeMap<String, RuntimeValue>por cada NodeView accedida. -
Emptyoperator +NodeByIdson corner cases. Podrían vivir como casos especiales delNodeScan, pero declararlos explícitos en el IR los hace inspeccionables enEXPLAINy trivial de optimizar después. -
OPTIONAL MATCH como flag en
Expandmezcla orthogonality (left outer join semantics) con sintaxis (cypher-specific clause). A futuro probablemente lo refactorizamos aLeftOuterExpando un explicitLeftJoinoperator cuando el optimizer lo necesite. -
Distinctsobre el row entero no permite optimizarDISTINCT coldonde solo necesitamos uniqueness de una columna. Optimización diferida. -
Lowering errors no son recuperables — un solo
BindingNotFoundaborta el plan. En contraste, parser tiene multi-error recovery. Aceptable: semantic errors son menos frecuentes que typos sintácticos y queremos fail-fast.
Addendum — SemiApply, Argument, PatternList
Tres operadores adicionales al IR para soportar pattern predicates, pattern comprehensions y back-references a outer scope:
-
Argument { bindings: Vec<String> }— single-row placeholder cuyas bindings se cargan desde el outer scope. Aparece como leaf de subplans dentro deSemiApplyoPatternList. El executor materializavec![row]donderowcopia las bindings nombradas desde el outer. -
SemiApply { input, subplan, negated }— semi-join existencial. Para cada row producida porinput, ejecutasubplanparametrizado por el row (víaouter_row); mantiene la row iff el subplan emitió ≥1 (positivo) ó =0 (negated). Reemplaza la semánticaFilter(Exists(...))con un operador dedicado. Pendiente: convertir nested-loop semi-apply a hash-semijoin cuando hay >N rows. -
PatternList { input, subplan, projection, alias }— materializa unaRuntimeValue::Listpor outer row. Para cada row, ejecutasubplanparametrizado por la row, evalúaprojectionsobre cada inner row, colecta a una lista y bindea aaliasen la row outer. Es el lowering de[(pattern) WHERE p | proj]cuando aparece como top-level projection item.
Lowering rules adicionales
-
WHERE con EXISTS: descompone el AND-tree del predicate. Cada término que es
Exists(pattern)oNOT Exists(pattern)se extrae a unSemiApplychained sobre el input plan; los residuos se reconstruyen comoFilterencima de la chain. Casos no soportados en v0:Existsdentro deOR,CASE, doble negación, etc. →UnsupportedFeature. -
Pattern comprehension top-level: hoist a
PatternListcon alias sintético__pcN, substitute la comprehension expression porVariable(__pcN)en el item de la projection. -
Aggregate nesting (e.g.
head(collect(x))): el lowering walk recursivo cada item expression, hoist cada aggregate function call a un alias sintético__aggNcon laAggregateExprcorrespondiente, substituye la call porVariable(__aggN). Group keys = items que no contienen ningún__aggN. Items con agg-nesting se evalúan sobre la row post-Aggregate. -
RETURN */WITH *: expandeExpressionKind::Stara una projection item por cada binding nombrada visible enLowerCtx(skip__anon*). Cierra RFC-004 Q1. -
Back-reference de head pattern: cuando
(a)reutiliza una binding ya en scope y no hay input plan, emiteArgument { bindings: [a] }en vez deEmpty. Esto permite que un subplan deSemiApply/PatternListreciba la binding outer al ejecutarse.
Out-of-scope todavía (pendiente para versiones futuras)
- Pattern comprehensions nested dentro de scalar functions
(
size([(a)-[]->(b)|b.name])). EXISTSfuera del AND-root del WHERE (dentro de OR/CASE/etc).- Path bindings (
p = (a)-[*]->(b)) + path materialization. - Write clauses (CREATE/MERGE/SET/REMOVE/DELETE).
Open questions
-
Q1:
Pattern predicates como sub-plans.✅ Cerrada víaSemiApply+Argument. La optimización a hash-semijoin queda pendiente. -
Q2: Variable-length patterns sin variable-length operator. Inicialmente podemos pasar
length: Option<RelationshipLength>alExpandy dejar que el executor iterelength.min..=length.maxiterations. Eso funciona pero no escala. ¿Variable-length explícito como operador separado (Traverse) a futuro con WCOJ? Probable sí. -
Q3: Materialización de paths.
MATCH p = (a)-[*]->(b)requiere quepsea materializable como List. Diferido. -
Q4:
✅ Cerrada víaWITH *yRETURN *.expand_star_itemsen el lowering. -
Q5: Hoist de pattern comprehensions nested. Hoy solo top-level en projection items. Hoist nested requiere planning de orden de evaluación y bookkeeping de scopes intermedios. Diferido.
References
- DuckDB logical/physical plans — https://duckdb.org/docs/sql/query_syntax/select.html (architecture notes en el repo de DuckDB).
- Kuzu morsel-driven execution — Boncz et al., CIDR 2024 paper .
- Materialize/Differential Dataflow operators — McSherry et al., 2013.
- Volcano model — Goetz Graefe, “Volcano—An Extensible and Parallel Query Evaluation System”, IEEE TKDE 1994.
- Cypher openCypher 9 §Section 3 (Linear queries semantics).
- GQL ISO/IEC 39075:2024 §17 (Linear queries) y §18 (Composite queries).