Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/gf-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ serde = { workspace = true }
[dev-dependencies]
cucumber = { workspace = true }
tokio = { workspace = true }
gf-cypher = { path = "../gf-cypher" }

[[test]]
name = "bdd"
Expand Down
39 changes: 32 additions & 7 deletions crates/gf-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
//! GraphForge core — public error type, span, and shared value model.
//!
//! Every other crate in the workspace depends on this crate. It deliberately
//! has no dependencies on the pipeline crates (gf-ast, gf-ir, etc.) so that
//! downstream crates can import error types without creating cycles.
//! GraphForge core — public error type, span, shared value model, and engine facade.
#![forbid(unsafe_code)]

use std::fmt;
Expand Down Expand Up @@ -206,6 +202,28 @@ pub struct FindOptions {
pub space: Option<String>,
}

// ---------------------------------------------------------------------------
// ExplainStage
// ---------------------------------------------------------------------------

/// Which compiler stage to inspect with [`GraphForge::explain`].
///
/// Only [`ExplainStage::Ast`] is implemented in M9. All other variants return
/// [`GfError::NotImplemented`] until the corresponding pipeline layer ships.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplainStage {
/// Pretty-printed JSON of the parsed [`gf_ast::AstQuery`].
Ast,
/// Bound AST after name resolution (not yet implemented).
BoundAst,
/// Graph IR envelope (not yet implemented).
GraphIr,
/// DataFusion logical plan (not yet implemented).
LogicalPlan,
/// DataFusion physical plan (not yet implemented).
PhysicalPlan,
}

// ---------------------------------------------------------------------------
// GraphForge — public facade
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -356,10 +374,17 @@ impl GraphForge {

/// Return a human-readable compiler pipeline explanation.
///
/// Not yet wired into the engine facade. For M9, AST-stage explain is
/// provided by `gf_cypher::explain`; the full multi-stage API (using
/// [`ExplainStage`]) will be connected here in a later milestone.
///
/// # Errors
/// Returns [`GfError::NotImplemented`] until M9 ships.
/// Returns [`GfError::NotImplemented`] — callers should use
/// `gf_cypher::explain` directly until the engine facade is fully wired.
pub fn explain(&self, _cypher: &str) -> Result<String, GfError> {
Err(GfError::NotImplemented("explain"))
Err(GfError::NotImplemented(
"explain: use gf_cypher::explain directly",
))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Load an ontology from a YAML or JSON file.
Expand Down
67 changes: 54 additions & 13 deletions crates/gf-core/tests/bdd/api_steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,16 +459,14 @@ async fn when_node_count(world: &mut GraphForgeWorld, _label: String) {

#[when(regex = r#"^I call explain on "([^"]+)"$"#)]
async fn when_explain(world: &mut GraphForgeWorld, query: String) {
if let Some(forge) = &world.forge {
match forge.explain(&query) {
Ok(s) => {
world.last_result = Some(gf_core::RecordBatch {
schema: vec!["plan".to_string()],
columns: vec![vec![s]],
});
}
Err(e) => world.last_error = Some(e.to_string()),
match gf_cypher::explain(&query) {
Ok(s) => {
world.last_result = Some(gf_core::RecordBatch {
schema: vec!["plan".to_string()],
columns: vec![vec![s]],
});
}
Err(e) => world.last_error = Some(e.to_string()),
}
}

Expand Down Expand Up @@ -742,13 +740,56 @@ async fn then_result_is_n(_world: &mut GraphForgeWorld, _n: i64) {
}

#[then(regex = r#"^the result is a non-empty string$"#)]
async fn then_nonempty_string(_world: &mut GraphForgeWorld) {
// pending
async fn then_nonempty_string(world: &mut GraphForgeWorld) {
if world
.last_error
.as_deref()
.map_or(false, |e| e.contains("not implemented"))
{
return; // pending skeleton step — skip gracefully
}
if let Some(rb) = &world.last_result {
let val = rb
.columns
.first()
.and_then(|c| c.first())
.map(|s| s.as_str())
.unwrap_or("");
assert!(!val.is_empty(), "expected non-empty string result");
} else {
panic!(
"no result stored — step failed? error: {:?}",
world.last_error
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[then(regex = r#"^the result contains "([^"]+)"$"#)]
async fn then_result_contains_text(_world: &mut GraphForgeWorld, _text: String) {
// pending
async fn then_result_contains_text(world: &mut GraphForgeWorld, text: String) {
if world
.last_error
.as_deref()
.map_or(false, |e| e.contains("not implemented"))
{
return; // pending skeleton step — skip gracefully
}
if let Some(rb) = &world.last_result {
let val = rb
.columns
.first()
.and_then(|c| c.first())
.map(|s| s.as_str())
.unwrap_or("");
assert!(
val.contains(&*text),
"expected result to contain {text:?}\ngot:\n{val}"
);
} else {
panic!(
"no result stored — step failed? error: {:?}",
world.last_error
);
}
}

#[then(regex = r#"^the result is an empty list$"#)]
Expand Down
2 changes: 1 addition & 1 deletion crates/gf-cypher/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ repository.workspace = true
gf-core = { path = "../gf-core" }
gf-ast = { path = "../gf-ast" }
thiserror = { workspace = true }
serde_json = { workspace = true }

[dev-dependencies]
serde_json = { workspace = true }

[lints]
workspace = true
70 changes: 69 additions & 1 deletion crates/gf-cypher/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! GraphForge Cypher parser — recursive descent + Pratt expression parser.
//!
//! Public surface: [`parse`], [`lexer`], [`parser`].
//! Public surface: [`parse`], [`explain`], [`lexer`], [`parser`].
#![forbid(unsafe_code)]
#![allow(missing_docs)]
#![allow(clippy::all)]
Expand All @@ -19,3 +19,71 @@ pub use gf_core::Span;
pub fn parse(input: &str) -> Result<AstQuery, ParseError> {
parser::parse(input)
}

/// Return a human-readable compiler pipeline explanation at the AST stage.
///
/// The output is `"AST\n---\n"` followed by pretty-printed JSON of the
/// [`AstQuery`] produced by [`parse`]. Later milestones will add GraphIR,
/// logical-plan, and physical-plan sections.
///
/// # Errors
/// Returns [`ParseError`] if `cypher` is syntactically invalid.
pub fn explain(cypher: &str) -> Result<String, ParseError> {
let ast = parse(cypher)?;
let json =
serde_json::to_string_pretty(&ast).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
Ok(format!(
"AST\n---\n{json}\n\nGraphIR\n-------\n(not yet implemented)"
))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn explain_ast_returns_nonempty_string() {
let result = explain("MATCH (n:Person) RETURN n.name AS name");
assert!(result.is_ok());
let s = result.unwrap();
assert!(!s.is_empty());
}

#[test]
fn explain_output_starts_with_ast_header() {
let s = explain("RETURN 1").unwrap();
assert!(s.starts_with("AST"), "output was:\n{s}");
}

#[test]
fn explain_output_is_valid_json_after_header() {
let s = explain("MATCH (n) RETURN n").unwrap();
// The AST JSON block ends before the first blank-line separator
let json_part = s
.trim_start_matches("AST\n---\n")
.split("\n\n")
.next()
.unwrap_or("");
let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(json_part);
assert!(parsed.is_ok(), "JSON parse failed: {:?}", parsed.err());
}

#[test]
fn explain_json_contains_clauses_key() {
let s = explain("MATCH (n) RETURN n").unwrap();
assert!(s.contains("clauses"), "output was:\n{s}");
}

#[test]
fn explain_parse_error_propagates() {
let result = explain("NOT VALID @@@@");
assert!(result.is_err(), "expected parse error for invalid input");
}

#[test]
fn explain_write_query_does_not_execute() {
// explain parses only — no graph state changes
let result = explain("CREATE (:Ghost {name: 'nobody'})");
assert!(result.is_ok(), "CREATE should parse without error");
}
}
49 changes: 49 additions & 0 deletions src/graphforge/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from collections import defaultdict
from contextlib import contextmanager
import copy
import dataclasses
import datetime
import json
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -111,6 +113,26 @@ def validate_name(cls, v: str) -> str:
model_config = {"frozen": True}


def _ast_to_dict(node: object) -> object:
"""Recursively convert an AST node to a JSON-serialisable structure.

Handles ``@dataclass`` nodes, Pydantic ``BaseModel`` nodes, lists, dicts,
and primitive values. This is needed because ``CypherQuery`` is a plain
``@dataclass`` while its ``clauses`` list contains Pydantic ``BaseModel``
instances — ``dataclasses.asdict`` alone would deep-copy the Pydantic
objects rather than serialising their fields.
"""
if dataclasses.is_dataclass(node) and not isinstance(node, type):
return {k: _ast_to_dict(v) for k, v in dataclasses.asdict(node).items()}
if isinstance(node, BaseModel):
return {k: _ast_to_dict(v) for k, v in node.model_dump(mode="python").items()}
if isinstance(node, list):
return [_ast_to_dict(item) for item in node]
if isinstance(node, dict):
return {k: _ast_to_dict(v) for k, v in node.items()}
return node


class GraphForge:
"""Main GraphForge interface for graph operations.

Expand Down Expand Up @@ -1554,6 +1576,33 @@ def node_count(self, label: str | None = None) -> int:
return self.graph.node_count()
return len(self.graph._label_index.get(label, set()))

def explain(self, query: str, *, stage: str = "ast") -> str:
"""Return a human-readable compiler-pipeline explanation.

For ``stage="ast"`` (the only implemented stage), returns a header
followed by a JSON dump of the parsed AST.

Args:
query: The Cypher query string to explain.
stage: Which pipeline stage to inspect. Currently only ``"ast"``
is implemented; all other values raise ``NotImplementedError``.

Returns:
A multi-line string with a stage header and formatted AST.

Raises:
NotImplementedError: If ``stage`` is not ``"ast"``.
"""
self._ensure_open()
if stage != "ast":
raise NotImplementedError(f"explain stage {stage!r} not yet implemented")
ast = self.parser.parse(query)
try:
formatted = json.dumps(_ast_to_dict(ast), indent=2, default=str)
except Exception:
formatted = repr(ast)
return "AST\n---\n" + formatted
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def relationship_count(self, rel_type: str | None = None) -> int:
"""Return the number of relationships, optionally filtered by type.

Expand Down
36 changes: 36 additions & 0 deletions tests/unit/api/test_api_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,39 @@ def test_close_saves_graph(self, tmp_path):
results = gf2.execute("MATCH (n:Person) RETURN n.name AS name")
gf2.close()
assert results[0]["name"].value == "Alice"


# =========================================================================
# explain() — AST stage (lines 1576-1584)
# =========================================================================


@pytest.mark.unit
class TestExplainAst:
"""Cover explain() lines: stage guard, parse, asdict, return."""

def test_explain_returns_ast_string(self):
"""explain() returns a non-empty string starting with 'AST'."""
gf = GraphForge()
result = gf.explain("MATCH (n:Person) RETURN n.name AS name")
assert isinstance(result, str)
assert result.startswith("AST")

def test_explain_output_contains_json(self):
"""explain() output contains valid JSON after the header."""
gf = GraphForge()
result = gf.explain("RETURN 1")
assert "{" in result

def test_explain_unknown_stage_raises(self):
"""explain() raises NotImplementedError for unknown stages."""
gf = GraphForge()
with pytest.raises(NotImplementedError):
gf.explain("RETURN 1", stage="graphir")

def test_explain_after_close_raises(self):
"""explain() after close() raises RuntimeError."""
gf = GraphForge()
gf.close()
with pytest.raises(RuntimeError):
gf.explain("MATCH (n) RETURN n")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading