fix(cypher): walk operator chains and UNION branches in loops

A WHERE chain such as `a OR b OR c ...` was folded into a left-deep tree,
so every walker over the expression tree recursed once per operand: the
evaluator, the seed-selectivity pass of the planner and, every 128 nodes,
the free routine. UNION branches were parsed by a nested cbm_parse call
per branch, so the parser recursed once per branch as well. Stack use
therefore followed the length of a query, not its nesting, and the
daemon evaluates queries on a worker thread with a 256 KiB stack. A long
but flat query, such as a few thousand `OR name = ...` terms, needs to
work there.

Chains of one operator are now linked along the right child at parse
time, OP(a, OP(b, OP(c, ...))), and eval_expr and cypher_expr_selectivity
follow the links in a loop, recursing only into the operands, whose
nesting the existing parse-depth cap bounds. Operand order and the
left-to-right short-circuit of AND and OR are the same as in the nested
form, XOR keeps its running parity, and a missing value takes part in a
chain exactly as before. expr_free already walks with an explicit stack
and needs no change for a right-linked chain.

UNION branches are read by one parser in a loop in cbm_parse and linked
as they come; parse_post_where no longer parses the branch after the
keyword, and the ALL flag still sits on the branch before it.

Tests: 50,000-operand OR and AND chains and 20,000 UNION ALL branches run
on a thread with the worker stack size inside a forked child, with the
expected rows computed by the test; a chain with operands over a missing
property is compared with its parenthesised forms in both associations;
50 UNION and UNION ALL branches return the right rows.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-12 21:17:13 +02:00
parent 5d4b50fe1c
commit df6f674ecb
3 changed files with 581 additions and 104 deletions
+165 -99
View File
@@ -1260,58 +1260,79 @@ static cbm_expr_t *parse_not_expr(parser_t *p) { // NOLINT(misc-no-recursion)
return parse_atom_expr(p);
}
/* A chain `a OP b OP c ...` of one associative operator is linked along the
* RIGHT child: OP(a, OP(b, OP(c, ...))). The chain's length is bounded only by
* the message size, so a left-deep fold would hand every walker (evaluate,
* seed-selectivity, free) a tree as deep as the chain is long; linked this
* way each walker follows the chain in a loop and only recurses into the
* operands, whose nesting the parse-depth cap bounds. Operand order and the
* left-to-right short-circuit are the same as in the nested form. `next` is
* the operand just parsed; `more` says whether another OP follows it. */
static void expr_chain_link(cbm_expr_t **tail, cbm_expr_type_t type, cbm_expr_t *next, bool more) {
if (more) {
cbm_expr_t *node = expr_binary(type, next, NULL);
(*tail)->right = node;
*tail = node;
} else {
(*tail)->right = next;
}
}
/* AND: not (AND not)* */
static cbm_expr_t *parse_and_expr(parser_t *p) { // NOLINT(misc-no-recursion)
cbm_expr_t *left = parse_not_expr(p);
if (!left) {
return NULL;
cbm_expr_t *first = parse_not_expr(p);
if (!first || !check(p, TOK_AND)) {
return first;
}
while (check(p, TOK_AND)) {
advance(p);
cbm_expr_t *right = parse_not_expr(p);
if (!right) {
expr_free(left);
cbm_expr_t *head = expr_binary(EXPR_AND, first, NULL);
cbm_expr_t *tail = head;
while (match(p, TOK_AND)) {
cbm_expr_t *next = parse_not_expr(p);
if (!next) {
expr_free(head);
return NULL;
}
left = expr_binary(EXPR_AND, left, right);
expr_chain_link(&tail, EXPR_AND, next, check(p, TOK_AND));
}
return left;
return head;
}
/* XOR: and (XOR and)* */
static cbm_expr_t *parse_xor_expr(parser_t *p) { // NOLINT(misc-no-recursion)
cbm_expr_t *left = parse_and_expr(p);
if (!left) {
return NULL;
cbm_expr_t *first = parse_and_expr(p);
if (!first || !check(p, TOK_XOR)) {
return first;
}
while (check(p, TOK_XOR)) {
advance(p);
cbm_expr_t *right = parse_and_expr(p);
if (!right) {
expr_free(left);
cbm_expr_t *head = expr_binary(EXPR_XOR, first, NULL);
cbm_expr_t *tail = head;
while (match(p, TOK_XOR)) {
cbm_expr_t *next = parse_and_expr(p);
if (!next) {
expr_free(head);
return NULL;
}
left = expr_binary(EXPR_XOR, left, right);
expr_chain_link(&tail, EXPR_XOR, next, check(p, TOK_XOR));
}
return left;
return head;
}
/* OR: xor (OR xor)* */
static cbm_expr_t *parse_or_expr(parser_t *p) { // NOLINT(misc-no-recursion)
cbm_expr_t *left = parse_xor_expr(p);
if (!left) {
return NULL;
cbm_expr_t *first = parse_xor_expr(p);
if (!first || !check(p, TOK_OR)) {
return first;
}
while (check(p, TOK_OR)) {
advance(p);
cbm_expr_t *right = parse_xor_expr(p);
if (!right) {
expr_free(left);
cbm_expr_t *head = expr_binary(EXPR_OR, first, NULL);
cbm_expr_t *tail = head;
while (match(p, TOK_OR)) {
cbm_expr_t *next = parse_xor_expr(p);
if (!next) {
expr_free(head);
return NULL;
}
left = expr_binary(EXPR_OR, left, right);
expr_chain_link(&tail, EXPR_OR, next, check(p, TOK_OR));
}
return left;
return head;
}
/* Parse WHERE clause — builds expression tree */
@@ -1988,9 +2009,9 @@ static int parse_match_chain(parser_t *p, cbm_query_t *q, int *pat_cap) {
return 0;
}
/* Parse post-WHERE clauses: additional MATCH, WITH, RETURN, UNION */
static int parse_post_where(parser_t *p, cbm_query_t *q, // NOLINT(misc-no-recursion)
int *pat_cap) {
/* Parse post-WHERE clauses: additional MATCH, WITH, RETURN. A UNION keyword
* is left for cbm_parse, which reads the branches in a loop. */
static int parse_post_where(parser_t *p, cbm_query_t *q, int *pat_cap) {
/* More MATCH / OPTIONAL MATCH after WHERE */
if (parse_match_chain(p, q, pat_cap) < 0) {
return CBM_NOT_FOUND;
@@ -2015,57 +2036,44 @@ static int parse_post_where(parser_t *p, cbm_query_t *q, // NOLINT(misc-no-recur
if (parse_return(p, &q->ret) < 0) {
return CBM_NOT_FOUND;
}
/* UNION [ALL] */
if (check(p, TOK_UNION)) {
advance(p);
q->union_all = match(p, TOK_ALL);
cbm_parse_result_t sub = {0};
if (cbm_parse(&p->tokens[p->pos], p->count - p->pos, &sub) < 0) {
if (sub.error) {
snprintf(p->error, sizeof(p->error), "%s", sub.error);
}
cbm_parse_free(&sub);
return CBM_NOT_FOUND;
}
q->union_next = sub.query;
sub.query = NULL;
cbm_parse_free(&sub);
/* The branch after UNION was parsed by a SEPARATE parser over a slice
* of these tokens, so this parser's cursor never moved past the UNION
* keyword. That sub-parse now refuses to succeed with anything left
* over, so everything from here to the end is accounted for. Move the
* cursor to the end to say so, or cbm_parse's end-of-input check reads
* a fully parsed UNION query as unfinished. */
p->pos = p->count;
}
return 0;
}
int cbm_parse(const cbm_token_t *tokens, int token_count, // NOLINT(misc-no-recursion)
cbm_parse_result_t *out) {
memset(out, 0, sizeof(*out));
parser_t p = {.tokens = tokens, .count = token_count, .pos = 0};
/* A stage that failed without writing a message gets the stage's own. */
static void parse_error_default(parser_t *p, const char *msg) {
if (!p->error[0]) {
snprintf(p->error, sizeof(p->error), "%s", msg);
}
}
/* One MATCH ... RETURN block: the whole query, or one branch of a UNION. On
* failure p->error holds the message and *out stays NULL. */
static int parse_query_block(parser_t *p, cbm_query_t **out) {
*out = NULL;
/* Each block starts with a clean message, as it did when every UNION
* branch had a parser of its own. */
p->error[0] = '\0';
/* Check for unsupported leading keywords */
const char *unsup = unsupported_clause_error(peek(&p)->type);
const char *unsup = unsupported_clause_error(peek(p)->type);
if (unsup) {
out->error = heap_strdup(unsup);
snprintf(p->error, sizeof(p->error), "%s", unsup);
return CBM_NOT_FOUND;
}
cbm_query_t *q = calloc(CBM_ALLOC_ONE, sizeof(cbm_query_t));
if (check(&p, TOK_UNWIND)) {
parse_unwind_clause(&p, q);
if (check(p, TOK_UNWIND)) {
parse_unwind_clause(p, q);
}
bool first_optional = false;
if (check(&p, TOK_OPTIONAL)) {
advance(&p);
if (check(p, TOK_OPTIONAL)) {
advance(p);
first_optional = true;
}
if (!expect(&p, TOK_MATCH)) {
out->error = heap_strdup(p.error[0] ? p.error : "expected MATCH");
if (!expect(p, TOK_MATCH)) {
parse_error_default(p, "expected MATCH");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
@@ -2074,32 +2082,66 @@ int cbm_parse(const cbm_token_t *tokens, int token_count, // NOLINT(misc-no-recu
q->patterns = malloc(pat_cap * sizeof(cbm_pattern_t));
q->pattern_optional = malloc(pat_cap * sizeof(bool));
if (parse_match_pattern(&p, &q->patterns[0]) < 0) {
out->error = heap_strdup(p.error[0] ? p.error : "failed to parse pattern");
if (parse_match_pattern(p, &q->patterns[0]) < 0) {
parse_error_default(p, "failed to parse pattern");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
q->pattern_optional[0] = first_optional;
q->pattern_count = SKIP_ONE;
if (parse_match_chain(&p, q, &pat_cap) < 0) {
out->error = heap_strdup(p.error[0] ? p.error : "failed to parse additional pattern");
if (parse_match_chain(p, q, &pat_cap) < 0) {
parse_error_default(p, "failed to parse additional pattern");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
if (parse_where(&p, &q->where) < 0) {
out->error = heap_strdup(p.error[0] ? p.error : "failed to parse WHERE");
if (parse_where(p, &q->where) < 0) {
parse_error_default(p, "failed to parse WHERE");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
if (parse_post_where(&p, q, &pat_cap) < 0) {
out->error = heap_strdup(p.error[0] ? p.error : "failed to parse query");
if (parse_post_where(p, q, &pat_cap) < 0) {
parse_error_default(p, "failed to parse query");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
*out = q;
return 0;
}
int cbm_parse(const cbm_token_t *tokens, int token_count, cbm_parse_result_t *out) {
memset(out, 0, sizeof(*out));
parser_t p = {.tokens = tokens, .count = token_count, .pos = 0};
/* The branches of a UNION are read one after another by this one parser
* and linked as they come, so the number of branches is a loop count, not
* a recursion depth, and one depth counter covers the whole query. */
cbm_query_t *q = NULL;
cbm_query_t *tail = NULL;
for (;;) {
cbm_query_t *block = NULL;
if (parse_query_block(&p, &block) < 0) {
out->error = heap_strdup(p.error);
cbm_query_free(q);
return CBM_NOT_FOUND;
}
if (tail) {
tail->union_next = block;
} else {
q = block;
}
tail = block;
/* UNION [ALL]: the ALL flag sits on the branch before the keyword. */
if (!check(&p, TOK_UNION)) {
break;
}
advance(&p);
tail->union_all = match(&p, TOK_ALL);
}
/* Every token must be consumed. The grammar accepts at most one WITH and
* treats RETURN as optional, so a query with a second WITH stage — or any
* typo after RETURN — used to stop parsing there and succeed anyway. The
@@ -2748,24 +2790,40 @@ static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
return c->negated ? !result : result;
}
/* Recursive expression tree evaluator */
/* Expression tree evaluator. Recurses into NOT and into the left operand of a
* binary node; a chain of one operator is linked along the right child (see
* expr_chain_link) and is walked here in a loop. */
static bool eval_expr(const cbm_expr_t *e, binding_t *b) { // NOLINT(misc-no-recursion)
if (!e) {
return true;
}
switch (e->type) {
case EXPR_CONDITION:
if (e->type == EXPR_CONDITION) {
return eval_condition(&e->cond, b);
case EXPR_AND:
return (eval_expr(e->left, b) && eval_expr(e->right, b)) != 0;
case EXPR_OR:
return (eval_expr(e->left, b) || eval_expr(e->right, b)) != 0;
case EXPR_NOT:
return (!eval_expr(e->left, b)) != 0;
case EXPR_XOR:
return eval_expr(e->left, b) != eval_expr(e->right, b);
}
return true;
if (e->type == EXPR_NOT) {
return (!eval_expr(e->left, b)) != 0;
}
/* AND / OR / XOR. The nested form `a OP (b OP (c ...))` evaluates a, then
* b, then c, and AND/OR stop at the first operand that decides the result;
* the loop does exactly that, one link per iteration. A right child that
* is not a link of the same operator is the chain's last operand. */
const cbm_expr_type_t type = e->type;
bool acc = eval_expr(e->left, b);
const cbm_expr_t *rest = e->right;
for (;;) {
if ((type == EXPR_AND && !acc) || (type == EXPR_OR && acc)) {
return acc;
}
bool linked = rest && rest->type == type;
bool v = eval_expr(linked ? rest->left : rest, b);
/* AND reaches here only with acc true, OR only with acc false, so the
* combined value is v itself; XOR keeps the running parity. */
acc = (type == EXPR_XOR) ? (acc != v) : v;
if (!linked) {
return acc;
}
rest = rest->right;
}
}
/* Evaluate WHERE clause — uses expression tree if available, falls back to legacy */
@@ -5286,19 +5344,27 @@ static int cypher_cond_selectivity(const cbm_condition_t *c, const char *var) {
return (strcmp(c->property, "name") == 0 || strcmp(c->property, "qualified_name") == 0) ? 3 : 2;
}
static int cypher_expr_selectivity(const cbm_expr_t *e, const char *var) {
if (!e) {
return 0;
}
if (e->type == EXPR_CONDITION) {
return cypher_cond_selectivity(&e->cond, var);
}
if (e->type == EXPR_AND) {
/* Best equality on `var` reachable through AND alone. An AND chain is linked
* along the right child (expr_chain_link), so the links are walked in a loop
* and only each link's left operand recurses. */
static int cypher_expr_selectivity(const cbm_expr_t *e, // NOLINT(misc-no-recursion)
const char *var) {
int best = 0;
while (e) {
if (e->type == EXPR_CONDITION) {
int s = cypher_cond_selectivity(&e->cond, var);
return s > best ? s : best;
}
if (e->type != EXPR_AND) {
return best; /* OR / NOT / XOR: no single equality to seed from */
}
int l = cypher_expr_selectivity(e->left, var);
int r = cypher_expr_selectivity(e->right, var);
return l > r ? l : r;
if (l > best) {
best = l;
}
e = e->right;
}
return 0; /* OR / NOT / XOR: no single equality to seed from */
return best;
}
static int cypher_node_selectivity(const cbm_node_pattern_t *n, const cbm_where_clause_t *w) {
+10 -5
View File
@@ -4,12 +4,17 @@
* These functions use bounded recursion where iterative conversion would
* add complexity with no practical benefit:
*
* Cypher recursive descent parser (bounded by query nesting depth ~5):
* Cypher recursive descent parser (bounded by CYPHER_MAX_PARSE_DEPTH; an
* operator chain is linked, not nested, and UNION branches parse in a loop):
* - parse_or_expr, parse_xor_expr, parse_and_expr, parse_not_expr
* - parse_atom_expr, parse_post_where, cbm_parse
* - parse_atom_expr
*
* Cypher expression evaluators (bounded by WHERE clause depth ~5):
* - eval_expr, eval_expr_partial
* Cypher expression walkers (bounded by the same parse depth; each follows
* an operator chain in a loop and recurses only into the operands):
* - eval_expr, cypher_expr_selectivity
*
* Cypher partial evaluator (bounded by WHERE clause depth ~5):
* - eval_expr_partial
*
* Glob pattern matcher (bounded by pattern nesting ~3):
* - glob_match, glob_match_star, glob_match_doublestar
@@ -27,7 +32,7 @@
*/
#define CBM_RECURSION_WHITELIST \
"parse_or_expr", "parse_xor_expr", "parse_and_expr", "parse_not_expr", "parse_atom_expr", \
"parse_post_where", "cbm_parse", "eval_expr", "eval_expr_partial", "glob_match", \
"eval_expr", "eval_expr_partial", "cypher_expr_selectivity", "glob_match", \
"glob_match_star", "glob_match_doublestar", "glob_match_doublestar_slash", \
"glob_match_doublestar_any", "parse_bool_expr", "parse_bool_atom", "r_collect_imports", \
"find_first_descendant_by_kind", "find_first_descendant_of"
+406
View File
@@ -5100,7 +5100,407 @@ TEST(cypher_exec_max_rows_above_ceiling_is_clamped) {
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* LONG OPERATOR CHAINS AND MANY UNION BRANCHES
*
* A WHERE chain `a OR b OR c ...` and a UNION chain are bounded only by the
* message size, and an agent that lists a few thousand names in one query is
* ordinary input. The parser used to fold a chain into a tree as deep as the
* chain is long and to parse every UNION branch through a nested call, so each
* walker (evaluation, seed selectivity, parsing) recursed once per operand or
* branch. The daemon runs a query on a worker thread with a 256 KiB stack
* (daemon/runtime.c RUNTIME_WORKER_STACK_SIZE), where a few thousand frames
* already overflow. These tests run their query on a thread of that size,
* inside a forked child on POSIX, so an overflow is reported as the signal
* that killed the child instead of ending the test runner.
* ══════════════════════════════════════════════════════════════════ */
enum { CYPHER_WORKER_STACK_BYTES = 256 * 1024 }; /* = RUNTIME_WORKER_STACK_SIZE */
typedef struct {
int (*check)(void *);
void *arg;
int result;
} worker_stack_run_t;
static void *worker_stack_thread(void *opaque) {
worker_stack_run_t *run = opaque;
run->result = run->check(run->arg);
return NULL;
}
/* Runs check(arg) on a thread with the worker stack size; returns its result. */
static int run_on_worker_stack_thread(worker_stack_run_t *run) {
cbm_thread_t t;
if (cbm_thread_create(&t, CYPHER_WORKER_STACK_BYTES, worker_stack_thread, run) != 0) {
FAIL("could not create the worker-stack thread");
}
(void)cbm_thread_join(&t);
return run->result;
}
/* Runs check(arg) the way the daemon runs a query. POSIX: on the worker-sized
* thread of a forked child, whose exit status is the check's result; a killing
* signal is reported as such. Windows has no fork, and CreateThread's size
* argument sets the committed stack while the reserve stays the image default,
* so the thread runs in-process there. */
static int run_on_worker_stack(int (*check)(void *), void *arg) {
worker_stack_run_t run = {.check = check, .arg = arg, .result = 1};
#ifdef _WIN32
return run_on_worker_stack_thread(&run);
#else
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
int code = run_on_worker_stack_thread(&run);
fflush(NULL);
_exit(code == 0 ? 0 : 1);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "query thread killed by signal %d on a %d KiB stack",
WTERMSIG(status), CYPHER_WORKER_STACK_BYTES / 1024);
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
return 0;
#endif
}
/* The fixture graph of setup_cypher_store, restated here so the expected rows
* are computed by the test and not read back from the engine. */
static const char *const cypher_fixture_functions[] = {"HandleOrder", "ValidateOrder",
"SubmitOrder", "LogError"};
enum { CYPHER_FIXTURE_FUNCTION_COUNT = 4 };
static const char *const cypher_fixture_calls[][2] = {{"HandleOrder", "ValidateOrder"},
{"ValidateOrder", "SubmitOrder"},
{"HandleOrder", "LogError"}};
enum { CYPHER_FIXTURE_CALL_COUNT = 3 };
static const char *const cypher_fixture_nodes[] = {"HandleOrder", "ValidateOrder", "SubmitOrder",
"main", "LogError"};
enum { CYPHER_FIXTURE_NODE_COUNT = 5 };
enum { CYPHER_CHAIN_OPERANDS = 50000 };
/* Operand i of a generated chain compares f.name with this value. Two fixture
* names sit deep in the chain (the middle and the very last operand); every
* other operand names a function that does not exist. */
static const char *chain_operand_value(int i, char *buf, size_t n) {
if (i == CYPHER_CHAIN_OPERANDS / 2) {
return "ValidateOrder";
}
if (i == CYPHER_CHAIN_OPERANDS - 1) {
return "LogError";
}
snprintf(buf, n, "no_such_function_%d", i);
return buf;
}
/* The test's own reading of the chain: does any operand name `name`? */
static bool chain_names(const char *name) {
for (int i = 0; i < CYPHER_CHAIN_OPERANDS; i++) {
char buf[48];
if (strcmp(chain_operand_value(i, buf, sizeof(buf)), name) == 0) {
return true;
}
}
return false;
}
/* head + "f.name <op> \"v0\"" + join + "f.name <op> \"v1\"" + ... + tail */
static char *chain_query(const char *head, const char *op, const char *join, const char *tail) {
size_t cap = 256 + (size_t)CYPHER_CHAIN_OPERANDS * 64;
char *q = malloc(cap);
if (!q) {
return NULL;
}
size_t len = (size_t)snprintf(q, cap, "%s", head);
for (int i = 0; i < CYPHER_CHAIN_OPERANDS && len < cap; i++) {
char buf[48];
const char *v = chain_operand_value(i, buf, sizeof(buf));
len += (size_t)snprintf(q + len, cap - len, "%sf.name %s \"%s\"", i ? join : "", op, v);
}
if (len >= cap) {
free(q);
return NULL;
}
snprintf(q + len, cap - len, "%s", tail);
return q;
}
typedef struct {
const char *head;
const char *op;
const char *join;
const char *tail;
bool admits_named; /* true: a row passes when the chain names f; false: when it does not */
int expected_rows;
} chain_case_t;
/* Runs the chain query and checks every row against the test's own reading of
* the chain, then checks that the engine still answers afterwards. */
static int chain_check(void *arg) {
const chain_case_t *c = arg;
char *query = chain_query(c->head, c->op, c->join, c->tail);
ASSERT_NOT_NULL(query);
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
free(query);
if (rc != 0) {
printf(" query error: %s\n", r.error ? r.error : "(none)");
}
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, c->expected_rows);
for (int i = 0; i < r.row_count; i++) {
ASSERT_EQ(chain_names(r.rows[i][0]), c->admits_named);
}
cbm_cypher_result_free(&r);
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, CYPHER_FIXTURE_FUNCTION_COUNT);
cbm_cypher_result_free(&r);
cbm_store_close(s);
return 0;
}
/* 50,000 operands joined by OR over a single node pattern: the rows are the
* Functions the chain names. The expected count comes from the fixture list
* and chain_names, not from the engine. */
TEST(cypher_long_or_chain_evaluates) {
chain_case_t c = {.head = "MATCH (f:Function) WHERE ",
.op = "=",
.join = " OR ",
.tail = " RETURN f.name",
.admits_named = true,
.expected_rows = 0};
for (int i = 0; i < CYPHER_FIXTURE_FUNCTION_COUNT; i++) {
c.expected_rows += chain_names(cypher_fixture_functions[i]) ? 1 : 0;
}
ASSERT_EQ(c.expected_rows, 2); /* the generator plants ValidateOrder and LogError */
int rc = run_on_worker_stack(chain_check, &c);
if (rc != 0) {
return rc;
}
PASS();
}
/* 50,000 operands joined by AND over a single-hop pattern, so the planner's
* seed-selectivity walk sees the chain as well as the evaluator: the rows are
* the CALLS edges whose caller the chain does not name. */
TEST(cypher_long_and_chain_evaluates) {
chain_case_t c = {.head = "MATCH (f:Function)-[:CALLS]->(g:Function) WHERE ",
.op = "<>",
.join = " AND ",
.tail = " RETURN f.name, g.name",
.admits_named = false,
.expected_rows = 0};
for (int i = 0; i < CYPHER_FIXTURE_CALL_COUNT; i++) {
c.expected_rows += chain_names(cypher_fixture_calls[i][0]) ? 0 : 1;
}
ASSERT_EQ(c.expected_rows, 2); /* HandleOrder's two calls; ValidateOrder's is named */
int rc = run_on_worker_stack(chain_check, &c);
if (rc != 0) {
return rc;
}
PASS();
}
static int cmp_cstr(const void *a, const void *b) {
return strcmp(*(const char *const *)a, *(const char *const *)b);
}
/* Runs `query` and checks that its first column holds exactly the names in
* `expected`, given in strcmp order; the rows are sorted the same way so the
* engine's row order does not matter. */
static int assert_name_set(cbm_store_t *s, const char *query, const char *const *expected, int n) {
enum { NAME_SET_MAX = 8 };
ASSERT_TRUE(n <= NAME_SET_MAX);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
if (rc != 0) {
printf(" query error: %s\n", r.error ? r.error : "(none)");
}
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, n);
const char *names[NAME_SET_MAX];
for (int i = 0; i < n; i++) {
names[i] = r.rows[i][0];
}
qsort(names, (size_t)n, sizeof(names[0]), cmp_cstr);
for (int i = 0; i < n; i++) {
ASSERT_STR_EQ(names[i], expected[i]);
}
cbm_cypher_result_free(&r);
return 0;
}
/* Every spelling in `forms` is one WHERE expression; all must yield `expected`. */
static int where_forms_agree(cbm_store_t *s, const char *const *forms, int form_count,
const char *const *expected, int n) {
for (int f = 0; f < form_count; f++) {
char query[512];
snprintf(query, sizeof(query), "MATCH (n) WHERE %s RETURN n.name", forms[f]);
int rc = assert_name_set(s, query, expected, n);
if (rc != 0) {
printf(" form: %s\n", forms[f]);
return rc;
}
}
return 0;
}
/* A chain and the same expression written out with parentheses, in both
* associations, must agree row for row. The operands include a property the
* `main` node does not have: file_path reads as the empty string there, so
* `IS NULL` holds and `<>` holds, and a missing value takes part in AND, OR and
* XOR like any other operand instead of voiding the chain. */
TEST(cypher_mixed_chain_matches_nested_form) {
cbm_store_t *s = setup_cypher_store();
static const char *const or_forms[] = {
"n.file_path = \"handler.go\" OR n.file_path IS NULL OR n.name = \"LogError\"",
"(n.file_path = \"handler.go\" OR n.file_path IS NULL) OR n.name = \"LogError\"",
"n.file_path = \"handler.go\" OR (n.file_path IS NULL OR n.name = \"LogError\")"};
static const char *const or_rows[] = {"HandleOrder", "LogError", "main"};
ASSERT_EQ(where_forms_agree(s, or_forms, 3, or_rows, 3), 0);
static const char *const and_forms[] = {
"n.file_path <> \"handler.go\" AND n.file_path <> \"log.go\" AND n.name <> \"SubmitOrder\"",
"(n.file_path <> \"handler.go\" AND n.file_path <> \"log.go\") AND n.name <> "
"\"SubmitOrder\"",
"n.file_path <> \"handler.go\" AND (n.file_path <> \"log.go\" AND n.name <> "
"\"SubmitOrder\")"};
static const char *const and_rows[] = {"ValidateOrder", "main"};
ASSERT_EQ(where_forms_agree(s, and_forms, 3, and_rows, 2), 0);
/* Parity: main is T^T^F^F, LogError F^F^T^F, HandleOrder F^F^F^T. */
static const char *const xor_forms[] = {
"n.name = \"main\" XOR n.file_path IS NULL XOR n.name = \"LogError\" XOR "
"n.file_path = \"handler.go\"",
"((n.name = \"main\" XOR n.file_path IS NULL) XOR n.name = \"LogError\") XOR "
"n.file_path = \"handler.go\"",
"n.name = \"main\" XOR (n.file_path IS NULL XOR (n.name = \"LogError\" XOR "
"n.file_path = \"handler.go\"))"};
static const char *const xor_rows[] = {"HandleOrder", "LogError"};
ASSERT_EQ(where_forms_agree(s, xor_forms, 3, xor_rows, 2), 0);
/* Mixed precedence (NOT > AND > XOR > OR) with a NOT over a missing value:
* main by name, LogError by (F AND T) XOR T, SubmitOrder by NOT (F). */
static const char *const mixed_forms[] = {
"n.name = \"main\" OR n.file_path IS NULL AND n.name = \"LogError\" XOR "
"n.file_path = \"log.go\" OR NOT n.file_path <> \"submit.go\"",
"n.name = \"main\" OR ((n.file_path IS NULL AND n.name = \"LogError\") XOR "
"n.file_path = \"log.go\") OR (NOT (n.file_path <> \"submit.go\"))",
"(n.name = \"main\" OR ((n.file_path IS NULL AND n.name = \"LogError\") XOR "
"n.file_path = \"log.go\")) OR (NOT (n.file_path <> \"submit.go\"))"};
static const char *const mixed_rows[] = {"LogError", "SubmitOrder", "main"};
ASSERT_EQ(where_forms_agree(s, mixed_forms, 3, mixed_rows, 3), 0);
cbm_store_close(s);
PASS();
}
/* Branch i selects fixture node i % 5 by name; `join` is " UNION " or
* " UNION ALL ". */
static char *union_query(int branches, const char *join) {
size_t cap = 64 + (size_t)branches * 80;
char *q = malloc(cap);
if (!q) {
return NULL;
}
size_t len = 0;
for (int i = 0; i < branches && len < cap; i++) {
len +=
(size_t)snprintf(q + len, cap - len, "%sMATCH (n) WHERE n.name = \"%s\" RETURN n.name",
i ? join : "", cypher_fixture_nodes[i % CYPHER_FIXTURE_NODE_COUNT]);
}
if (len >= cap) {
free(q);
return NULL;
}
return q;
}
enum { CYPHER_UNION_MANY_BRANCHES = 20000 };
/* UNION ALL keeps every branch's row in branch order. */
static int union_many_check(void *arg) {
(void)arg;
char *query = union_query(CYPHER_UNION_MANY_BRANCHES, " UNION ALL ");
ASSERT_NOT_NULL(query);
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
free(query);
if (rc != 0) {
printf(" query error: %s\n", r.error ? r.error : "(none)");
}
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, CYPHER_UNION_MANY_BRANCHES);
for (int i = 0; i < r.row_count; i++) {
ASSERT_STR_EQ(r.rows[i][0], cypher_fixture_nodes[i % CYPHER_FIXTURE_NODE_COUNT]);
}
cbm_cypher_result_free(&r);
cbm_store_close(s);
return 0;
}
/* 20,000 UNION ALL branches parse, run and free on the worker stack, and
* every branch contributes its row. */
TEST(cypher_many_union_branches) {
int rc = run_on_worker_stack(union_many_check, NULL);
if (rc != 0) {
return rc;
}
PASS();
}
/* 50 branches: UNION ALL yields one row per branch in branch order, UNION
* keeps the first row of each name, and a UNION with nothing after it is still
* a clean parse error. */
TEST(cypher_fifty_union_branches) {
enum { BRANCHES = 50 };
cbm_store_t *s = setup_cypher_store();
char *all = union_query(BRANCHES, " UNION ALL ");
char *distinct = union_query(BRANCHES, " UNION ");
ASSERT_NOT_NULL(all);
ASSERT_NOT_NULL(distinct);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, all, "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, BRANCHES);
for (int i = 0; i < r.row_count; i++) {
ASSERT_STR_EQ(r.rows[i][0], cypher_fixture_nodes[i % CYPHER_FIXTURE_NODE_COUNT]);
}
cbm_cypher_result_free(&r);
rc = cbm_cypher_execute(s, distinct, "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, CYPHER_FIXTURE_NODE_COUNT);
for (int i = 0; i < r.row_count; i++) {
ASSERT_STR_EQ(r.rows[i][0], cypher_fixture_nodes[i]);
}
cbm_cypher_result_free(&r);
free(all);
free(distinct);
rc = cbm_cypher_execute(s, "MATCH (n) RETURN n.name UNION", "test", 0, &r);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(r.error);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
@@ -5340,4 +5740,10 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_prop_string_with_escaped_quote);
RUN_TEST(cypher_single_hop_seeds_from_selective_far_node);
RUN_TEST(cypher_exec_max_rows_above_ceiling_is_clamped);
/* Long operator chains and many UNION branches */
RUN_TEST(cypher_long_or_chain_evaluates);
RUN_TEST(cypher_long_and_chain_evaluates);
RUN_TEST(cypher_mixed_chain_matches_nested_form);
RUN_TEST(cypher_many_union_branches);
RUN_TEST(cypher_fifty_union_branches);
}