Skip to content
Open
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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,14 @@ graph = Rubydex::Graph.new
graph.index_workspace
graph.resolve

# Parse once, render against a graph as a table or JSON string
# Parse once, then run against a graph. `run` executes the query and returns the result set.
query = Rubydex::Query.parse("MATCH (c:Class) RETURN c.name")
puts query.render(graph, "table")
puts query.render(graph, "json")
result = query.run(graph)

# Read the rows as Ruby objects, or render the same result set as a table or JSON string
result.rows.each { |row| puts row["c.name"] }
puts result.render("table")
puts result.render("json")

# Describe the schema
puts Rubydex::Query.schema("table")
Expand Down
282 changes: 236 additions & 46 deletions ext/rubydex/query.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@
#include "rustbindings.h"
#include "utils.h"

/*
* RDoc parser workaround for https://github.com/ruby/rdoc/issues/1744:
* mRubydex = rb_define_module("Rubydex")
*/

static VALUE mRubydex;
static VALUE cQueryResult;

// Raises the Ruby error that matches a Cypher failure reported by Rust and releases `message`.
// Syntax and execution failures get a Rubydex error; everything else is a Ruby argument error.
NORETURN(static void raise_query_error(const char *message, CQueryErrorKind kind));

static void raise_query_error(const char *message, CQueryErrorKind kind) {
VALUE error_message = rb_utf8_str_new_cstr(message);
free_c_string(message);

VALUE error_class;
switch (kind) {
case CQueryErrorKind_Syntax:
error_class = rb_const_get(mRubydex, rb_intern("QuerySyntaxError"));
break;
case CQueryErrorKind_Execution:
error_class = rb_const_get(mRubydex, rb_intern("QueryExecutionError"));
break;
default:
error_class = rb_eArgError;
break;
}

rb_exc_raise(rb_exc_new_str(error_class, error_message));
}

/*
* call-seq:
* Rubydex::Query.schema(format = :table) -> String
Expand Down Expand Up @@ -51,51 +83,98 @@ static const rb_data_type_t query_type = {
* Rubydex::Query.parse(query) -> Rubydex::Query
*
* Parses a Cypher query into an opaque, reusable object without needing a graph. Raises
* ArgumentError on a syntax error, so a query can be validated before building a graph.
* Rubydex::QuerySyntaxError on a syntax error, so a query can be validated before building a graph.
*/
static VALUE rdxr_query_parse(VALUE klass, VALUE query) {
Check_Type(query, T_STRING);

struct CParseResult result = rdx_cypher_parse(StringValueCStr(query));
if (result.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(result.error);
free_c_string(result.error);
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
raise_query_error(result.error, result.error_kind);
}

return TypedData_Wrap_Struct(klass, &query_type, result.query);
}

// Backing data for Rubydex::Query::Result: the executed result set plus the graph it came from.
typedef struct {
void *result_set; // Result set owned by Rust, released with rdx_result_set_free
VALUE graph_obj; // Ruby Graph object to keep it alive, since node cells build handles from it
VALUE rows; // Memoized array of row hashes, nil until `rows` builds it
} QueryResultData;

// Marks the references movable, so that a compaction can relocate them. `query_result_compact`
// then writes their new locations back into the struct.
static void query_result_mark(void *ptr) {
if (ptr) {
QueryResultData *data = (QueryResultData *)ptr;
rb_gc_mark_movable(data->graph_obj);
rb_gc_mark_movable(data->rows);
}
}

static void query_result_compact(void *ptr) {
if (ptr) {
QueryResultData *data = (QueryResultData *)ptr;
data->graph_obj = rb_gc_location(data->graph_obj);
data->rows = rb_gc_location(data->rows);
}
}

static void query_result_free(void *ptr) {
if (ptr) {
QueryResultData *data = (QueryResultData *)ptr;
rdx_result_set_free(data->result_set);
xfree(data);
}
}

static const rb_data_type_t query_result_type = {
.wrap_struct_name = "Rubydex::Query::Result",
.function = {
.dmark = query_result_mark,
.dfree = query_result_free,
.dsize = NULL,
.dcompact = query_result_compact,
},
.parent = NULL,
.data = NULL,
.flags = RUBY_TYPED_FREE_IMMEDIATELY,
};

static inline QueryResultData *query_result_data(VALUE self) {
QueryResultData *data;
TypedData_Get_Struct(self, QueryResultData, &query_result_type, data);
return data;
}

/*
* call-seq:
* render(graph, format = :table) -> String
* run(graph) -> Rubydex::Query::Result
*
* Runs this parsed query against +graph+ and returns the formatted output. +format+ may be
* +:table+ (default) or +:json+. Raises ArgumentError on an execution or format error.
* Runs this parsed query against +graph+ exactly once and returns the result set. Read it as Ruby
* objects with Rubydex::Query::Result#rows, or format it with Rubydex::Query::Result#render. Raises
* Rubydex::QueryExecutionError when the query fails against the graph.
*/
static VALUE rdxr_query_render(int argc, VALUE *argv, VALUE self) {
VALUE graph_obj, format;
rb_scan_args(argc, argv, "11", &graph_obj, &format);

static VALUE rdxr_query_run(VALUE self, VALUE graph_obj) {
void *query;
TypedData_Get_Struct(self, void *, &query_type, query);

void *graph = rdxi_graph_from_object(graph_obj);
// Wrap first, so the result set has an owner that frees it even if a later step raises.
QueryResultData *data;
VALUE result = TypedData_Make_Struct(cQueryResult, QueryResultData, &query_result_type, data);
data->result_set = NULL;
data->graph_obj = graph_obj;
data->rows = Qnil;

struct CQueryResult result = rdx_query_run(query, graph, rdxi_symbol_or_string_cstr(format, "table"));

if (result.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(result.error);
free_c_string(result.error);
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
struct CExecuteResult executed = rdx_query_execute(query, rdxi_graph_from_object(graph_obj));
if (executed.error != NULL) {
raise_query_error(executed.error, executed.error_kind);
}

VALUE output = result.output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(result.output);
if (result.output != NULL) {
free_c_string(result.output);
}
data->result_set = executed.result_set;

return output;
return result;
}

// Converts a structured result cell into a Ruby value. Node cells become real graph handles
Expand Down Expand Up @@ -148,10 +227,10 @@ static VALUE cypher_cell_to_value(VALUE graph_obj, const struct CCell *cell) {
}
}

// Body function for rb_ensure in Query#run — walks the iterator and builds the rows array.
// May raise if cell conversion (e.g. handle construction) fails; the ensure function frees the
// iterator regardless.
static VALUE query_run_yield(VALUE args) {
// Body function for rb_ensure in Rubydex::Query::Result#rows — walks the iterator and builds the
// rows array. May raise if cell conversion (e.g. handle construction) fails; the ensure function
// frees the iterator regardless.
static VALUE query_rows_yield(VALUE args) {
VALUE graph_obj = rb_ary_entry(args, 0);
struct CRowsIter *iter = (struct CRowsIter *)(uintptr_t)NUM2ULL(rb_ary_entry(args, 1));

Expand All @@ -172,45 +251,156 @@ static VALUE query_run_yield(VALUE args) {
return rows;
}

// Ensure function for rb_ensure in Query#run to always free the iterator.
static VALUE query_run_ensure(VALUE args) {
// Ensure function for rb_ensure in Rubydex::Query::Result#rows to always free the iterator.
static VALUE query_rows_ensure(VALUE args) {
struct CRowsIter *iter = (struct CRowsIter *)(uintptr_t)NUM2ULL(rb_ary_entry(args, 1));
rdx_rows_iter_free(iter);
return Qnil;
}

/*
* call-seq:
* run(graph) -> Array[Hash[String, Object]]
* rows -> Array[Hash[String, Object]]
*
* Runs this parsed query against +graph+ and returns the rows as Ruby objects: each row is a Hash
* keyed by RETURN column name. Scalar cells become String/Integer/true/false/nil, lists become
* Arrays, and node cells become Declaration / Definition / Document handles. Raises ArgumentError
* on an execution error.
* Returns the rows as Ruby objects: a frozen Array in which each row is a Hash keyed by RETURN
* column name. Scalar cells become String/Integer/true/false/nil, lists become Arrays, maps become
* Hashes, and node cells become Declaration / Definition / Document handles. The array is built on
* the first call and reused afterwards.
*/
static VALUE rdxr_query_run(VALUE self, VALUE graph_obj) {
void *query;
TypedData_Get_Struct(self, void *, &query_type, query);
static VALUE rdxr_query_result_rows(VALUE self) {
QueryResultData *data = query_result_data(self);

void *graph = rdxi_graph_from_object(graph_obj);
if (!NIL_P(data->rows)) {
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.

if (iter == NULL) {
rb_raise(rb_eRuntimeError, "failed to create iterator");
}

struct CRunRows run = rdx_query_run_rows(query, graph);
VALUE args = rb_ary_new_from_args(2, data->graph_obj, ULL2NUM((uintptr_t)iter));
data->rows = rb_ary_freeze(rb_ensure(query_rows_yield, args, query_rows_ensure, args));

if (run.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(run.error);
free_c_string(run.error);
return data->rows;
}

/*
* call-seq:
* columns -> Array[String]
*
* Returns the RETURN column names, in order. The names are known even when the query matched no
* rows.
*/
static VALUE rdxr_query_result_columns(VALUE self) {
QueryResultData *data = query_result_data(self);

size_t count = rdx_result_set_column_count(data->result_set);
VALUE columns = rb_ary_new_capa((long)count);

for (size_t i = 0; i < count; i++) {
rb_ary_push(columns, rdxi_owned_c_string_to_ruby(rdx_result_set_column(data->result_set, i)));
}

return columns;
}

/*
* call-seq:
* each { |row| ... } -> self
* each -> Enumerator
*
* Yields every row as a Hash keyed by RETURN column name. Rubydex::Query::Result is Enumerable, so
* +map+, +select+, and the rest of Enumerable work on the rows.
*/
static VALUE rdxr_query_result_each(VALUE self) {
RETURN_ENUMERATOR(self, 0, 0);

VALUE rows = rdxr_query_result_rows(self);
long length = RARRAY_LEN(rows);

for (long i = 0; i < length; i++) {
rb_yield(RARRAY_AREF(rows, i));
}

return self;
}

/*
* call-seq:
* size -> Integer
* length -> Integer
*
* Returns the number of rows, without building the row objects.
*/
static VALUE rdxr_query_result_size(VALUE self) {
return SIZET2NUM(rdx_result_set_row_count(query_result_data(self)->result_set));
}

/*
* call-seq:
* empty? -> bool
*
* Returns +true+ when the query matched no rows.
*/
static VALUE rdxr_query_result_empty_p(VALUE self) {
return rdx_result_set_row_count(query_result_data(self)->result_set) == 0 ? Qtrue : Qfalse;
}

/*
* call-seq:
* render(format = :table) -> String
*
* Returns the result set as formatted output. +format+ may be +:table+ (default) or +:json+. The
* query is not run again. Raises ArgumentError on an unknown format.
*/
static VALUE rdxr_query_result_render(int argc, VALUE *argv, VALUE self) {
VALUE format;
rb_scan_args(argc, argv, "01", &format);

QueryResultData *data = query_result_data(self);
struct CQueryResult result = rdx_result_set_format(data->result_set, rdxi_symbol_or_string_cstr(format, "table"));

if (result.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(result.error);
free_c_string(result.error);
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
}

VALUE args = rb_ary_new_from_args(2, graph_obj, ULL2NUM((uintptr_t)run.iter));
return rb_ensure(query_run_yield, args, query_run_ensure, args);
VALUE output = result.output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(result.output);
if (result.output != NULL) {
free_c_string(result.output);
}

return output;
}

void rdxi_initialize_query(VALUE mRubydex) {
void rdxi_initialize_query(VALUE moduleRubydex) {
mRubydex = moduleRubydex;

VALUE cQuery = rb_define_class_under(mRubydex, "Query", rb_cObject);
rb_undef_alloc_func(cQuery);
rb_define_singleton_method(cQuery, "parse", rdxr_query_parse, 1);
rb_define_singleton_method(cQuery, "schema", rdxr_cypher_schema, -1);
rb_define_method(cQuery, "render", rdxr_query_render, -1);
rb_define_method(cQuery, "run", rdxr_query_run, 1);

/*
* The result of running a Rubydex::Query against a graph: the columns and rows produced by one
* execution. Enumerable over its rows.
*/
cQueryResult = rb_define_class_under(cQuery, "Result", rb_cObject);
rb_undef_alloc_func(cQueryResult);

// 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


rb_include_module(cQueryResult, rb_mEnumerable);
rb_define_method(cQueryResult, "columns", rdxr_query_result_columns, 0);
rb_define_method(cQueryResult, "rows", rdxr_query_result_rows, 0);
rb_define_method(cQueryResult, "each", rdxr_query_result_each, 0);
rb_define_method(cQueryResult, "size", rdxr_query_result_size, 0);
rb_define_alias(cQueryResult, "length", "size");
rb_define_method(cQueryResult, "empty?", rdxr_query_result_empty_p, 0);
rb_define_method(cQueryResult, "render", rdxr_query_result_render, -1);
}
Loading