-
Notifications
You must be signed in to change notification settings - Fork 16
Make Cypher query execution return a Result instance
#991
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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)); | ||
|
|
||
|
|
@@ -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)); | ||
| 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"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's hide it in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How? This isn't making the method
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought we can hide it by making
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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: On the other hand, 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: |
||
|
|
||
| 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); | ||
| } | ||
There was a problem hiding this comment.
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:
rowsconverts node cells by looking them up in the current graph state. If the graph changes afterquery.run(graph)but before the firstresult.rows, the executed result can change shape. For example:Can we materialize the row cells at execution time, or have the result set carry the node category/kind/id captured during execution, so
Resultis independent of later graph mutations? I'd add a regression test forrun; mutate graph; rowstoo.There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like
Result#renderandResult#rowsdisagree based on graph mutations after execution. Do we need to rebase on #992?There was a problem hiding this comment.
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.