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
135 changes: 108 additions & 27 deletions ext/rubydex/query.c
Original file line number Diff line number Diff line change
Expand Up @@ -227,37 +227,115 @@ static VALUE cypher_cell_to_value(VALUE graph_obj, const struct CCell *cell) {
}
}

// 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) {
// Builds the Hash keys of one walk: one frozen UTF-8 String per column. The keys are shared by
// every row of the walk, so a wide result does not allocate a key String per cell. `rb_hash_aset`
// stores a frozen String key as it is, instead of duplicating and freezing it.
static VALUE query_row_keys(struct CRowsIter *iter) {
size_t count = rdx_rows_iter_column_count(iter);
const char *const *columns = rdx_rows_iter_columns(iter);
VALUE keys = rb_ary_new_capa((long)count);

for (size_t i = 0; i < count; i++) {
rb_ary_push(keys, rb_str_freeze(rb_utf8_str_new_cstr(columns[i])));
}

return keys;
}

// Converts one row of the cursor into a Hash keyed by the shared Strings in `keys`.
static VALUE query_row_to_hash(VALUE graph_obj, VALUE keys, const struct CResultRow *row) {
long column_count = RARRAY_LEN(keys);
VALUE hash = rb_hash_new_capa(column_count);

for (size_t c = 0; c < row->len && (long)c < column_count; c++) {
rb_hash_aset(hash, RARRAY_AREF(keys, (long)c), cypher_cell_to_value(graph_obj, &row->cells[c]));
}

return hash;
}

// Raises when the graph no longer holds a node that the query returned. Building a string in place
// of the missing handle would silently change the column's type, so the walk stops instead.
NORETURN(static void raise_stale_result(struct CRowsIter *iter));

static void raise_stale_result(struct CRowsIter *iter) {
VALUE error_class = rb_const_get(mRubydex, rb_intern("StaleQueryResultError"));
const char *node = rdx_rows_iter_error(iter);

if (node == NULL) {
rb_raise(error_class, "the graph no longer holds a node that this query returned");
}

rb_raise(error_class, "the graph no longer holds `%s`, a node that this query returned", node);
}

// Body function for rb_ensure in Rubydex::Query::Result#rows — walks the cursor and collects every
// row. May raise if a node is gone, or if cell conversion (e.g. handle construction) fails; the
// ensure function frees the cursor regardless.
static VALUE query_rows_collect(VALUE args) {
VALUE graph_obj = rb_ary_entry(args, 0);
struct CRowsIter *iter = (struct CRowsIter *)(uintptr_t)NUM2ULL(rb_ary_entry(args, 1));

size_t column_count = rdx_rows_iter_column_count(iter);
const char *const *columns = rdx_rows_iter_columns(iter);
VALUE keys = query_row_keys(iter);
VALUE rows = rb_ary_new_capa((long)rdx_rows_iter_len(iter));

struct CResultRow row;
while (rdx_rows_iter_next(iter, &row)) {
VALUE hash = rb_hash_new();
for (size_t c = 0; c < row.len && c < column_count; c++) {
VALUE key = rb_utf8_str_new_cstr(columns[c]);
rb_hash_aset(hash, key, cypher_cell_to_value(graph_obj, &row.cells[c]));
for (;;) {
switch (rdx_rows_iter_next(iter, &row)) {
case CRowsNextStatus_Row:
rb_ary_push(rows, query_row_to_hash(graph_obj, keys, &row));
break;
case CRowsNextStatus_MissingNode:
raise_stale_result(iter);
default:
return rows;
}
rb_ary_push(rows, hash);
}
}

return rows;
// Body function for rb_ensure in Rubydex::Query::Result#each — walks the cursor and yields one row
// at a time, so only one row exists as Ruby objects at any moment. A `break` or an exception in the
// block leaves through rb_ensure, which frees the cursor.
static VALUE query_rows_stream(VALUE args) {
VALUE graph_obj = rb_ary_entry(args, 0);
struct CRowsIter *iter = (struct CRowsIter *)(uintptr_t)NUM2ULL(rb_ary_entry(args, 1));

VALUE keys = query_row_keys(iter);

struct CResultRow row;
for (;;) {
switch (rdx_rows_iter_next(iter, &row)) {
case CRowsNextStatus_Row:
rb_yield(query_row_to_hash(graph_obj, keys, &row));
break;
case CRowsNextStatus_MissingNode:
raise_stale_result(iter);
default:
return Qnil;
}
}
}

// Ensure function for rb_ensure in Rubydex::Query::Result#rows to always free the iterator.
// Ensure function for rb_ensure to always free the cursor.
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;
}

// Opens a cursor over the result set's rows and runs `body` with it. The cursor is always freed.
static VALUE query_with_rows(VALUE self, VALUE (*body)(VALUE)) {
QueryResultData *data = query_result_data(self);

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");
}

VALUE args = rb_ary_new_from_args(2, data->graph_obj, ULL2NUM((uintptr_t)iter));
return rb_ensure(body, args, query_rows_ensure, args);
}

/*
* call-seq:
* rows -> Array[Hash[String, Object]]
Expand All @@ -268,21 +346,14 @@ static VALUE query_rows_ensure(VALUE args) {
* the first call and reused afterwards.
*/
static VALUE rdxr_query_result_rows(VALUE self) {
QueryResultData *data = query_result_data(self);

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");
if (!NIL_P(query_result_data(self)->rows)) {
return query_result_data(self)->rows;
}

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));
VALUE rows = rb_ary_freeze(query_with_rows(self, query_rows_collect));
query_result_data(self)->rows = rows;

return data->rows;
return rows;
}

/*
Expand Down Expand Up @@ -312,11 +383,21 @@ static VALUE rdxr_query_result_columns(VALUE self) {
*
* 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.
*
* Unless #rows already built the whole array, +each+ converts one row at a time and discards it
* after the block returns. A large result therefore needs memory for one row, not for all of them,
* and +first+ or +find+ stops converting as soon as the block breaks.
*/
static VALUE rdxr_query_result_each(VALUE self) {
RETURN_ENUMERATOR(self, 0, 0);

VALUE rows = rdxr_query_result_rows(self);
VALUE rows = query_result_data(self)->rows;

if (NIL_P(rows)) {
query_with_rows(self, query_rows_stream);
return self;
}

long length = RARRAY_LEN(rows);

for (long i = 0; i < length; i++) {
Expand Down
17 changes: 17 additions & 0 deletions lib/rubydex/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,21 @@ class QuerySyntaxError < QueryError; end
# Raised by `Query#run` when a parsed query fails while it runs against a graph, for example
# because it names an unknown property or relationship type.
class QueryExecutionError < QueryError; end

# Raised when a query result names a node that the graph no longer holds, because the graph
# changed after the query ran. Reading the rows would silently turn that column from a
# `Declaration`, `Definition`, or `Document` handle into a plain String, so it raises instead.
# `render`, `columns`, `size`, and `empty?` still work, because they read the executed result set
# and never touch the graph.
#
# The check runs while a row is built, so it covers the rows that a walk has not reached yet. Two
# cases fall outside it:
#
# - A handle that a walk already handed out. Such a handle resolves against the graph on each
# call, so a later change to the graph can make it stale. Handles from `Graph#[]` share that
# property. This error says nothing about them.
# - A re-index that keeps the ids. A declaration id comes from the name, so a file indexed again
# under the same names still resolves, and this error does not fire, even though the
# definitions and ancestors behind that name may differ.
class StaleQueryResultError < QueryError; end
end
1 change: 1 addition & 0 deletions rbi/rubydex.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ class Rubydex::ConfigError < Rubydex::Error; end
class Rubydex::QueryError < Rubydex::Error; end
class Rubydex::QuerySyntaxError < Rubydex::QueryError; end
class Rubydex::QueryExecutionError < Rubydex::QueryError; end
class Rubydex::StaleQueryResultError < Rubydex::QueryError; end

# The configuration of a workspace, parsed from its `rubydex.toml`. It carries both the settings that are global to
# every built-in tool, such as the workspace being analyzed, and the typed settings of each tool's own section (e.g.
Expand Down
Loading