Adding a query language¶
KGLite’s query surface lives under crates/kglite/src/graph/languages/. Today there
is one concrete implementation — Cypher — plus scaffolding for a
second (languages/fluent/). This guide walks through how Cypher is
put together and how a hypothetical peer language (SPARQL, GraphQL,
GQL, a custom DSL) would slot into the same tree.
This document is the companion to adding a storage backend — storage layers handle where bytes live; the languages layer handles how a user asks about them.
TL;DR¶
A new query language is a new directory under crates/kglite/src/graph/languages/
containing its own parser/, planner/ (optional), executor/, and a
public entry function callable from pyapi/. Most of the heavy work
— pattern matching, filtering, traversal, aggregation, value coercion
— already exists in crates/kglite/src/graph/core/ and is designed to be shared
across languages.
The integration checklist:
Pick a name —
languages/<name>/.Parse — take the query string, emit an AST.
Plan (optional) — rewrite the AST for cost or push predicates.
Execute — walk the AST against
&impl GraphRead, calling intocore::pattern_matching,core::filtering_methods,core::traversal_methods, and the other shared primitives.Expose — add a
#[pyo3] fn kg_<name>(...)topyapi/kg_core.rs(or a dedicatedpyapi/kg_<name>.rs) that calls the language’s entry point and returns aResultView.
No storage-layer changes should be needed — that’s the whole point of
the GraphRead / GraphWrite split.
The languages/ umbrella¶
crates/kglite/src/graph/languages/
├── mod.rs
├── cypher/ # The concrete implementation
│ ├── mod.rs # Re-exports + estimate_match_rows + explain helper
│ ├── tokenizer.rs
│ ├── ast.rs
│ ├── parser/ # String → AST
│ ├── planner/ # AST → optimised AST (join order, index choice, fusion)
│ ├── executor/ # AST → rows (the work)
│ ├── window.rs # Window-function evaluator
│ ├── result.rs # CypherResult shape
│ └── py_convert.rs # Value → Python object converters
│
└── fluent/ # Scaffolding only — no Rust-side code today
languages/fluent/ is deliberately empty: the fluent chain (the
kg.select(type).where(...).traverse(...) API) is implemented entirely
in pyapi/kg_fluent.rs on top of CowSelection. The directory exists
to document that fluent is a query-interface language, and to reserve
the slot for a future Rust-side extraction if a non-Python fluent
caller materialises (say, a SPARQL frontend that reuses the chain as
its execution model).
There is no QueryLanguage trait yet — Cypher is the single
concrete example, and a trait invented for one impl is premature
abstraction. The first candidate for a trait would be an AST-level
common shape (pattern, filter, project), with core/ primitives as
the shared execution substrate.
Anatomy of languages/cypher/¶
tokenizer.rs¶
Lexer: string → stream of Tokens. Handles the usual Cypher syntax
— identifiers, keywords, literals, punctuation, comments. No graph
knowledge.
ast.rs¶
AST node definitions: Query, MatchClause, WhereClause,
ReturnClause, and the expression tree (Expr, Value, Pattern,
RelationshipPattern). Pure data. Also hosts small helpers that
operate on the AST shape — is_aggregate_expression,
collect_node_types, etc.
parser/¶
Submodules: mod.rs (entry + token helpers), match_pattern,
predicate, expression, clauses. All five share a single
CypherParser struct via multiple impl CypherParser blocks that
Rust merges at codegen — this keeps per-clause parsing local to its
own file without duplicating state.
The MATCH clause delegates pattern parsing to
core::pattern_matching::parse_pattern() — the same parser the
fluent API uses. Anything that matches a pattern in KGLite goes
through that one parser.
planner/¶
Optional AST → AST rewrite pass. Submodules: join_order,
index_selection, cost_model, fusion, simplification.
fusion.rs is the heaviest — it rewrites chains of MATCH/WHERE/RETURN
into fused evaluators that skip intermediate materialisation.
simplification.rs::rewrite_text_score converts ORDER BY text_score(...) into an index-backed scan.
A new language may skip the planner entirely and go directly from parser to executor. The Cypher planner exists because Cypher queries benefit from join-order + index-choice optimisations; a simpler language may not.
executor/¶
Nine files (mod.rs, match_clause.rs, where_clause.rs,
expression.rs, return_clause.rs, call_clause.rs, write.rs,
helpers.rs, tests.rs) sharing a single CypherExecutor<'a> struct
via merged impl blocks. Each clause file adds its own methods on the
executor. Shared helpers (arithmetic, property resolution, type
coercion, return_item_column_name) live in helpers.rs.
The executor takes &impl GraphRead for reads and &mut impl GraphWrite for writes — it doesn’t match on the backend enum. Every
MATCH/RETURN call goes through the trait surface.
window.rs¶
Window-function evaluator (SUM() OVER (...), LAG(), LEAD(),
ROW_NUMBER(), etc.). Lives outside the main executor because window
evaluation runs over post-projection row buffers, not against the
graph.
result.rs + py_convert.rs¶
CypherResult is the internal row-buffer + metadata carrier.
py_convert.rs converts Values to Python objects at the PyO3
boundary.
How Cypher integrates with core/¶
The core/ module is KGLite’s shared query substrate. Every language
is expected to call into it rather than re-implement primitives.
Core module |
What Cypher uses it for |
|---|---|
|
MATCH-clause pattern parser + |
|
Property predicates, value comparisons (shared with fluent) |
|
Outgoing / incoming / undirected edge walks |
|
Node and edge iteration primitives with per-backend fast paths |
|
Per-node property fetch, typed accessors |
|
Arithmetic, math functions |
|
Type coercion, comparison, null handling |
|
Aggregation primitives (count/sum/avg/min/max) |
A peer language writes its executor against this palette. The shared primitives are the reason Cypher and fluent agree on semantics — they call the same filtering / traversal code.
Entry point — how kg.cypher(...) flows¶
kg.cypher("MATCH (p:Person) RETURN p.name")
│
▼
pyapi/kg_core.rs::cypher (PyO3 method on KnowledgeGraph)
│
▼
languages/cypher::parse_cypher → Ast::Query
│
▼
languages/cypher::planner::optimize → Ast::Query (rewritten)
│
▼
languages/cypher::executor::CypherExecutor::execute
│ ├─ match_clause::execute_match (calls core::pattern_matching)
│ ├─ where_clause::evaluate_where (calls core::filtering_methods)
│ ├─ return_clause::project_return (calls core::value_operations)
│ └─ helpers::return_item_column_name
▼
CypherResult → ResultView (pyapi/result_view.rs)
│
▼
Python: list of dicts, pandas DataFrame, GeoDataFrame
cypher is a single entry point. A peer language adds its own — e.g.
pyapi/kg_core.rs::sparql, calling languages::sparql::parse →
execute → ResultView. The ResultView shape is language-agnostic
(rows + column names), so the Python-facing return type stays uniform.
Adding a peer language (speculative)¶
Suppose you want to add SPARQL. Concrete steps:
crates/kglite/src/graph/languages/sparql/withmod.rs,parser.rs,executor.rs. Optionalplanner.rsif SPARQL-specific optimisations are worth it.In
parser.rs, convert SPARQL into an AST. Wherever possible, translate patterns (?s ?p ?o) into the samePattern/PropertyMatchertypes Cypher uses — thecore::pattern_matchingparser can then be reused for the hot path.In
executor.rs, walk the AST against&impl GraphRead, calling the samecore/primitives the Cypher executor uses.Add
#[pyo3] fn sparql(&self, query: &str, ...) -> PyResult<Py<ResultView>>topyapi/kg_core.rs.Add a parity oracle alongside
tests/test_storage_parity.pythat runs the same graph through Cypher and SPARQL for an equivalent query and asserts row-set equivalence.
What you don’t need: storage-backend changes, new Python ResultView
types, or changes to the cypher parser. The languages/ boundary is
designed so a peer language lands as a new sibling directory with no
churn in its neighbours.
Testing¶
Each language ships its own test module. For Cypher, tests live in
crates/kglite/src/graph/languages/cypher/executor/tests.rs (unit) +
tests/test_cypher.py (Python end-to-end) + the test_phaseN_parity.py
files (cross-storage oracle).
A new language should mirror this layout:
Unit tests alongside the executor (
crates/kglite/src/graph/languages/<name>/tests.rsor inline#[cfg(test)] mod tests).Python end-to-end tests exercising the full parser → executor →
ResultViewpath.A cross-storage parity oracle if the language is read-only — memory / mapped / disk must agree.
Reading more¶
crates/kglite/src/graph/languages/cypher/mod.rs— the Cypher dispatcher + re-exports.crates/kglite/src/graph/languages/cypher/executor/mod.rs— the executor entry point; scroll the file for the clause-submodule glob imports.crates/kglite/src/graph/core/— the shared primitives; readmod.rsfiles for each sub-module’s public API.crates/kglite-py/src/graph/pyapi/kg_core.rs— wherecypheris exposed to Python.dev_workfolder/dev-documentation/todo.mdPhase 8 + Phase 9 Report-outs (gitignored, repo-checkout only) — the decisions behind thelanguages/umbrella and the executor’s clause-per-file split.