Skip to content

Make Cypher query execution return a Result instance - #991

Open
paracycle wants to merge 1 commit into
mainfrom
uk-cypher-api-refactors
Open

Make Cypher query execution return a Result instance#991
paracycle wants to merge 1 commit into
mainfrom
uk-cypher-api-refactors

Conversation

@paracycle

Copy link
Copy Markdown
Member

This addresses two review comments on the Cypher blog post in rails-at-scale#431:

Execution and rendering are separate

Rubydex::Query#render(graph, format) is gone. The new shape is:

query = Rubydex::Query.parse("MATCH (c:Class) RETURN c.name")
result = query.run(graph)

result.rows.each { |row| puts row["c.name"] }
puts result.render(:json)

Query#run returns a Rubydex::Query::Result. That object owns the executed result set, so render never runs the query again. It is Enumerable over its rows, and it answers columns, rows, each, size, length, empty?, and render.

Specific errors

Class Raised by
Rubydex::QueryError The base class. Rescue it to catch either failure.
Rubydex::QuerySyntaxError Query.parse, on invalid Cypher.
Rubydex::QueryExecutionError Query#run, when the query fails against the graph.

ArgumentError remains for true argument faults, such as an unknown output format.

The Rust FFI reports the failure kind through a new CQueryErrorKind, so the C extension raises the matching class.

Verification

  • bundle exec rake test: 300 Ruby runs and the full Rust suite. 0 failures.
  • cargo fmt --check and rubocop: clean. cargo clippy reports no new warnings.
  • CLI smoke test: the table and JSON output are unchanged. A syntax error still fails before indexing. An execution error aborts with its message.
  • Compaction stress: Rubydex::Query::Result marks graph_obj and rows movable and implements dcompact, so GC.verify_compaction_references followed by GC.compact keeps every reference valid.

Follow-up

The blog post in rails-at-scale#431 still describes the old API. It needs the two snippets updated once this merges.

@paracycle
paracycle requested a review from a team as a code owner August 6, 2026 20:34
@paracycle
paracycle requested a review from Morriar August 6, 2026 20:34
@dersam

dersam commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Should this be two separate PRs (one for errors, one for render)?

@paracycle

Copy link
Copy Markdown
Member Author

Should this be two separate PRs (one for errors, one for render)?

I could split, but it felt natural to add the new exceptions while making the API change.

Comment thread lib/rubydex/cli/command/query.rb Outdated
Comment thread rust/rubydex-sys/src/cypher_api.rs Outdated
Comment thread ext/rubydex/query.c
return data->rows;
}

struct CRowsIter *iter = rdx_result_set_rows(data->result_set, rdxi_graph_from_object(data->graph_obj));

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.

One concern with lazy materialization: rows converts node cells by looking them up in the current graph state. If the graph changes after query.run(graph) but before the first result.rows, the executed result can change shape. For example:

result = Rubydex::Query.parse("MATCH (c:Class {name: 'Dog'}) RETURN c").run(graph)
graph.index_source("zoo.rb", "class Cat; end\n", "ruby")
graph.resolve

result.render(:json) # still has the Dog node from the executed result set
result.rows          # [{ "c" => "Dog" }], no longer a Declaration handle

Can we materialize the row cells at execution time, or have the result set carry the node category/kind/id captured during execution, so Result is independent of later graph mutations? I'd add a regression test for run; mutate graph; rows too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've gone the other direction and implemented proper lazy materialization here which also checks for node invalidation too. Is that enough to address your concern?

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.

Looks like Result#render and Result#rows disagree based on graph mutations after execution. Do we need to rebase on #992?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

On the contrary, #992 is based on this branch, since it need this refactor to operate.

@paracycle
paracycle force-pushed the uk-cypher-api-refactors branch from 0f586bd to 69d6b71 Compare August 6, 2026 21:40
@paracycle
paracycle requested review from Morriar and st0012 August 6, 2026 21:44
`Rubydex::Query#render(graph, format)` ran the query and formatted the
result in one call. This commit removes it. The new shape is:

```ruby
query = Rubydex::Query.parse("MATCH (c:Class) RETURN c.name")
result = query.run(graph)
puts result.render(:json)
```

`Query#run` now returns a `Rubydex::Query::Result` instance. That object
owns the executed result set, so `render` never runs the query again.
The class is `Enumerable` over its rows. It answers `columns`, `rows`,
`each`, `size`, `length`, `empty?`, and `render`.

Moreover, the query API now raises specific errors instead of
`ArgumentError`:

- `Rubydex::QueryError` is the base class.
- `Query.parse` raises `Rubydex::QuerySyntaxError` on invalid Cypher
queries.
- `Query#run` raises `Rubydex::QueryExecutionError` when the query fails
  against the graph.

`ArgumentError` remains for true argument faults, such as an unknown
output format.

The layers change as follows:

- `rubydex`: `run_parsed` becomes `render(&ResultSet, OutputFormat)`, so
  a caller executes once and formats as often as it needs.
- `rubydex-sys`: `rdx_query_run_rows` becomes `rdx_query_execute`. The
  new `rdx_result_set_format`, `rdx_result_set_rows`,
  `rdx_result_set_column_count`, `rdx_result_set_column`,
  `rdx_result_set_row_count`, and `rdx_result_set_free` work on the
  returned result set. `rdx_query_run` is deleted. `CQueryErrorKind`
  carries the failure kind across the FFI boundary.
- `ext/rubydex/query.c`: adds the `Rubydex::Query::Result` typed data.
  It marks `graph_obj` and `rows` movable and implements `dcompact`, so
  a compaction can relocate them.

`Rubydex::Query::Result#rows` builds the row objects on the first call
and freezes the array. `Query#run` wraps the Ruby object before it runs
the query, so the result set always has an owner that frees it, even
when execution raises.
@paracycle
paracycle force-pushed the uk-cypher-api-refactors branch from 69d6b71 to 460fcfa Compare August 6, 2026 21:46
Comment thread ext/rubydex/query.c

// A result can only be obtained from `Query#run`; `new` would create an object with no Rust
// data behind it.
rb_undef_method(rb_singleton_class(cQueryResult), "new");

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.

Let's hide it in rbi too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

How? This isn't making the method private, it is completely removing it. How can we remove methods in RBI files?

@st0012 st0012 Aug 6, 2026

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.

I thought we can hide it by making Result.new private? Hide as using Sorbet to prevent users from calling it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am not sure that I like that as a solution, since it only makes it private and doesn't mean it can't be called from other contexts:

class Bar
  class << self
    private

    def new = super
  end
end

class Baz < Bar
  (Instance = new) rescue puts "Error"
  puts "No error when creating: #{Instance}" if defined?(Instance)
end

Bar.new rescue puts "Error"
Baz.new rescue puts "Error"

gives:

❯ ruby bar.rb
No error when creating: #<Baz:0x0000000121aec870>
Error
Error

On the other hand, undef_method :new completely removes it:

class Bar
  class << self
    undef_method :new
  end
end

class Baz < Bar
  (Instance = new) rescue puts "Error"
  puts "No error when creating: #{Instance}" if defined?(Instance)
end

Bar.new rescue puts "Error"
Baz.new rescue puts "Error"

gives:

❯ ruby bar.rb
Error
Error
Error

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.

4 participants