Skip to content

Query the in-memory graph with Cypher#868

Open
paracycle wants to merge 3 commits into
mainfrom
uk_add_cypher_query_engine
Open

Query the in-memory graph with Cypher#868
paracycle wants to merge 3 commits into
mainfrom
uk_add_cypher_query_engine

Conversation

@paracycle

@paracycle paracycle commented Jun 18, 2026

Copy link
Copy Markdown
Member

Goal

Give clients a flexible, future-proof way to query the in-memory graph using Cypher — the de facto standard graph query language. Instead of adding a bespoke method for every traversal, this exposes the graph through a query language clients already know, so new introspection needs become queries rather than new APIs. Queries are read-only, run directly against the existing in-memory Graph (no duplication, no embedded database).

References

Architecture

The Cypher engine itself — lexer, recursive-descent parser, AST, the tree-walking executor, values, and result formatting — lives in a separate, published crate, cypher-parser. The executor is generic over a GraphProvider trait, so it has no dependency on rubydex.

rubydex depends on cypher-parser and provides only the rubydex-specific pieces:

  • query::cypher::schemaimpl GraphProvider for Graph, the property-graph mapping.
  • query::cypher::schema_info — the static schema description.

How it's exposed

  • rubydex_cli: --query "<CYPHER>", --schema, --format table|json.
  • Ruby API — the whole query API lives on Rubydex::Query:
    • Rubydex::Query.parse(str) → an opaque, reusable parsed query (raises ArgumentError on syntax errors, needs no graph).
    • Rubydex::Query#render(graph, format = :table) → runs a parsed query against a graph and returns the formatted output (table or JSON).
    • Rubydex::Query.schema(format = :table) → describes the queryable schema.
  • rdx command CLI:
    rdx query <CYPHER>  [--format table|json]
    rdx query --schema  [--format table|json]
    rdx console
    

Parse first, then build the graph

Both CLIs parse the query into the opaque parsed object before indexing/resolution, so a malformed query fails fast (~0.1s) instead of after a full workspace index:

parse query  ->  build graph (index + resolve)  ->  run parsed query

A parsed Rubydex::Query is reusable: parse once, run against many graphs.

Graph schema exposed to queries

rdx query --schema / rubydex_cli --schema / Rubydex::Query.schema print this model:

Node labels: Document, Definition, Declaration, the grouping label Namespace, and declaration kind sub-labels (Class, Module, SingletonClass, Method, Constant, ConstantAlias, GlobalVariable, InstanceVariable, ClassVariable).

Relationship types: DEFINES (Document→Definition), DECLARES (Definition→Declaration), CONTAINS (lexical nesting), HAS_PARENT (direct superclass; reverse-traverse for direct subclasses, * for the chain), INCLUDES/PREPENDS/EXTENDS (mixins), OWNS (members), HAS_ANCESTOR (linearized ancestor chain, incl. modules), HAS_DESCENDANT (reverse of HAS_ANCESTOR), REFERENCES (Document→Declaration).

Properties: Declaration: name, unqualified_name, kind, visibility, definition_count; Definition: kind, name, file, line; Document: uri, path (file-system path), name (base file name).

Supported syntax: MATCH (node patterns with label disjunction :A|B and inline properties; relationship patterns with direction, type lists, and variable length *min..max), WHERE (=, <>, <, <=, >, >=, CONTAINS, STARTS WITH, ENDS WITH, AND/OR/NOT), RETURN (DISTINCT, AS, aggregates count/collect/min/max/sum/avg), ORDER BY, SKIP, LIMIT. Read-only; write clauses are intentionally unsupported.

Try it

# Discover the model
rdx query --schema

# All classes or modules
rdx query "MATCH (n:Class|Module) RETURN n.name ORDER BY n.name"

# All (transitive) subclasses of a base class, as JSON
rdx query "MATCH (c:Class)-[:HAS_PARENT*1..]->(p {name: 'ApplicationRecord'}) RETURN DISTINCT c.name" --format json

# Count definitions per file
rdx query "MATCH (d:Document)-[:DEFINES]->(def:Definition) RETURN d.path, count(def) AS defs ORDER BY defs DESC"

From Ruby:

query = Rubydex::Query.parse("MATCH (n:Class|Module) RETURN n.name")  # fails fast on bad syntax
graph = Rubydex::Graph.new
graph.index_workspace
graph.resolve
puts query.render(graph, :json)

What's here

Core work:

  1. A read-only Cypher engine over the in-memory graph, extracted into the standalone cypher-parser crate (generic over GraphProvider).
  2. rubydex's GraphProvider mapping plus a single-source schema catalog (RelType + NODE_LABELS/NODE_PROPERTIES), rendered by schema_info.
  3. --query/--schema on rubydex_cli and a rdx query / console command CLI, parsing the query up front so a syntax error fails fast before indexing.

Review refinements folded in: direction-correct HAS_PARENT/HAS_ANCESTOR/HAS_DESCENDANT edge naming, rdx query --schema as a flag, a single "build graph once, then dispatch" CLI structure, extraction of Rubydex::Query into query.c and a shared utils helper, robust file:// path handling, and clap validation that rejects nonsensical flag combinations.

@paracycle paracycle requested a review from a team as a code owner June 18, 2026 22:03
@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch 5 times, most recently from cb17dab to 2e6a202 Compare June 23, 2026 19:42
@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch from 2e6a202 to bc2a231 Compare June 23, 2026 21:16

@vinistock vinistock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still trying to wrap my head around the entire engine, but left some comments already. Excited to have a unified way of querying the graph.

I wonder if there's some IRB trick we can use to enter a "query" mode that accepts the Cypher queries directly (non-valid Ruby). Something like:

bundle exec rdx -i
Indexing...
Resolving...
> graph["Foo"]
=> <Declaration ...>
>
> query_mode!
> MATCH (n:Class|Module) RETURN n.name ORDER BY n.name
=> [Foo]

Comment thread exe/rdx
Comment thread rust/rubydex/src/query/cypher.rs
Comment thread rust/rubydex/src/query/cypher.rs
Comment thread rust/rubydex/src/query/cypher/schema.rs
Comment thread rust/rubydex/src/query/cypher/schema.rs Outdated
Comment thread rust/rubydex/src/query/cypher/schema.rs
Comment thread rust/rubydex/src/query/cypher/schema.rs
Comment thread rust/rubydex/src/query/cypher/schema.rs Outdated
@paracycle paracycle requested a review from vinistock June 25, 2026 20:09
@paracycle

Copy link
Copy Markdown
Member Author

I wonder if there's some IRB trick we can use to enter a "query" mode that accepts the Cypher queries directly (non-valid Ruby). Something like:

bundle exec rdx -i
Indexing...
Resolving...
> graph["Foo"]
=> <Declaration ...>
>
> query_mode!
> MATCH (n:Class|Module) RETURN n.name ORDER BY n.name
=> [Foo]

Great idea. I am prototyping what's possible here, but obviously it won't be a part of this PR.

@paracycle

Copy link
Copy Markdown
Member Author

Ok, this PR has a prototype for the console mode extension: #883

@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch from f6e2615 to 7e08a7b Compare June 25, 2026 20:31
@paracycle

paracycle commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

@vinistock If it is helpful, I had this diagram generated from the codebase for how the query engine works at a high level:

image

@Morriar Morriar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we need our own implementation of the Cypher parser? None of the existing Rust implementations could be reused?

Could you clean the commit history to make this PR easier to read? First commit seems to introduce a whole parser that gets removed later.

@paracycle

Copy link
Copy Markdown
Member Author

Why did we need our own implementation of the Cypher parser? None of the existing Rust implementations could be reused?

The existing implementations are either unmaintained, or were doing much more than what we needed, or were pulling in a lot of dependencies. I wanted to start with a parser under our control and with no dependencies, that support only the subset of the syntax that we want to support (e.g. we will probably never need CREATE queries for building the schema).

If needed, we can swap what we have with another library down the line, but this seems to work just fine.

Could you clean the commit history to make this PR easier to read? First commit seems to introduce a whole parser that gets removed later.

Sure, I can do that tomorrow.

Comment thread rust/rubydex/src/query/cypher/schema.rs Outdated
Comment thread rust/rubydex/src/query/cypher/schema_info.rs Outdated
Comment thread rust/rubydex/src/query/cypher/schema.rs
Comment thread rust/rubydex/src/query/cypher/schema.rs

@vinistock vinistock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some minor things, but I think we can ship and iterate on it.

Note: it seems that, like query.rs, cypher queries are going to be quite heavy on graph traversal (depending on what the user is searching for of course). If possible, it would be nice to scan the graph in parallel

Comment thread ext/rubydex/graph.c Outdated
Comment thread ext/rubydex/graph.c Outdated
Comment thread ext/rubydex/graph.c Outdated
Comment thread rust/rubydex/src/main.rs Outdated
@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch from ded5eb9 to 4fa3719 Compare July 3, 2026 21:16
paracycle added 2 commits July 4, 2026 00:47
Expose the graph through Cypher — the de facto standard graph query
language — so new introspection needs become queries instead of bespoke
APIs. Queries are read-only and run directly against the in-memory `Graph`.

The engine (lexer, parser, executor, formatting) lives in the standalone
`cypher-parser` crate, generic over a `GraphProvider` trait. This adds the
rubydex-specific pieces:

- `query::cypher::schema` — `impl GraphProvider for Graph`: the property
  graph mapping (node labels, relationship types, properties) with the
  relationship catalog defined once in a `REL_SCHEMAS` table.
- `query::cypher::schema_info` — renders that model for discoverability.
- `Document::file_path`/`file_name` for the Document `path`/`name`
  properties, decoding `file://` URIs via the `url` crate.
Add `--query <CYPHER>`, `--schema`, and `--format table|json` to
`rubydex_cli`. The query is parsed up front — before listing/indexing — so a
malformed query fails fast instead of after a full workspace build, and
`--schema` prints the static catalog without building a graph at all.

clap constraints reject nonsensical combinations: `--query`/`--schema` are
mutually exclusive, `--format` requires one of them, and `--show-builtins`
requires `--dot`.
@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch 2 times, most recently from 9dfbbc1 to 85bbbba Compare July 3, 2026 21:52
Add an opaque `Rubydex::Query` object backed by new FFI exports:

- `Rubydex::Query.parse(str)` parses without a graph, raising ArgumentError
  on a syntax error, so a query can be validated before indexing.
- `Query#render(graph, format)` runs a parsed query and returns the
  formatted output; a parsed query is reusable across graphs.
- `Rubydex::Query.schema(format)` describes the queryable schema.

The Query class lives in its own `ext/rubydex/query.c`, with the
String/Symbol/nil format coercion in a shared `rdxi_symbol_or_string_cstr`
utils helper.

The `rdx` CLI gains `query`/`console` commands (with `query --schema`):
graph-independent switches return early, then the workspace graph is built
once and the chosen command runs against it.
@paracycle paracycle force-pushed the uk_add_cypher_query_engine branch from 85bbbba to cc553f6 Compare July 3, 2026 21:56
@paracycle paracycle requested review from Morriar and vinistock July 3, 2026 22:00
@paracycle

Copy link
Copy Markdown
Member Author

@vinistock and @Morriar: I think I've addressed all the comments and I have a clean commit history now. Can you both do a final review?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants