Files
codebase-memory-mcp/src/cypher/cypher.c
T
Martin Vogel 8ab555ee38 Merge pull request #2253 from DeusData/distill/1245-cypher-partial-where
fix(cypher): stop the early WHERE pruning rows it cannot judge yet
2026-10-04 21:01:04 +02:00

5815 lines
210 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* cypher.c — Cypher query engine: lexer, parser, planner, executor.
*
* Translates a subset of Cypher into SQL queries against cbm_store.
* Supports MATCH patterns with relationships, WHERE filters,
* RETURN with COUNT/ORDER BY/LIMIT/DISTINCT.
*/
#include "cypher/cypher.h"
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "store/store.h"
#include "foundation/platform.h"
#include "foundation/limits.h"
#include "foundation/log.h"
#include "foundation/hash_table.h"
#include "foundation/mem_core.h"
enum {
CYP_BUF_16 = 16,
CYP_BUF_48 = 48, /* ASCII '0' */
CYP_BUF_8 = 8,
CYP_BUF_4 = 4,
CYP_MAX_TOKEN = 10, /* max token lookahead */
CYP_PAIR = 2,
CYP_TRIPLE = 3,
CYP_INIT_CAP4 = 4, /* initial small array capacity */
CYP_INIT_CAP8 = 8, /* initial medium array capacity */
CYP_MAX_VARS = 16, /* max Cypher variables in a query */
CYP_MAX_EDGE_VARS = 8, /* max edge variables */
CYP_GROWTH_10 = 10, /* binding growth factor */
CYP_GROWTH_2 = 2, /* geometric buffer growth (#1196) */
CYP_CHAR_IDX1 = 1, /* second character index (e.g. op[1]) */
CYP_EBUF_MASK = 7,
CYP_NODE_COLS = 4, /* columns per node var: name, qn, label, file */
CYP_EDGE_COLS = 3, /* columns per edge var: name, qn, label */
CYP_COL_BUF = 48, /* max column buffer (16 vars * 3 cols) */
CYP_FOUND_NONE = -1,
/* search miss sentinel */ /* mask for ebuf ring buffer (8 entries) */
};
#define CYP_DBL_MAX 1e308
/* execute_single binds the first pattern's leading node even when the query
* leaves it unnamed, so the engine needs a name for it. The binding slot is
* real, which is why check_pattern_var_capacity counts it. */
#define CYP_ANON_HEAD_VAR "_n0"
#include <ctype.h>
#include <limits.h> // INT_MAX
#include "foundation/compat_regex.h"
#include <stddef.h>
#include <stdint.h> // int64_t
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Helpers ────────────────────────────────────────────────────── */
static char *heap_strdup(const char *s) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *d = malloc(len + SKIP_ONE);
if (d) {
memcpy(d, s, len + 1);
}
return d;
}
static char *heap_strndup(const char *s, size_t n) {
char *d = malloc(n + SKIP_ONE);
if (d) {
memcpy(d, s, n);
d[n] = '\0';
}
return d;
}
/* ══════════════════════════════════════════════════════════════════
* LEXER
* ══════════════════════════════════════════════════════════════════ */
static void lex_push(cbm_lex_result_t *r, cbm_token_type_t type, const char *text, int pos) {
if (r->count >= r->capacity) {
r->capacity = r->capacity ? r->capacity * PAIR_LEN : CBM_SZ_32;
r->tokens = safe_realloc(r->tokens, r->capacity * sizeof(cbm_token_t));
}
r->tokens[r->count++] = (cbm_token_t){.type = type, .text = heap_strdup(text), .pos = pos};
}
static void lex_push_n(cbm_lex_result_t *r, cbm_token_type_t type, const char *start, size_t len,
int pos) {
if (r->count >= r->capacity) {
r->capacity = r->capacity ? r->capacity * PAIR_LEN : CBM_SZ_32;
r->tokens = safe_realloc(r->tokens, r->capacity * sizeof(cbm_token_t));
}
r->tokens[r->count++] =
(cbm_token_t){.type = type, .text = heap_strndup(start, len), .pos = pos};
}
/* Parse a string literal (with escape handling) into the token list.
* *pos points at the character after the opening quote; updated past closing quote. */
static void lex_string_literal(const char *input, int len, int *pos, char quote,
cbm_lex_result_t *out) {
int start = *pos;
char buf[CBM_SZ_4K];
int blen = 0;
const int max_blen = CBM_SZ_4K - 1;
while (*pos < len && input[*pos] != quote) {
if (input[*pos] == '\\' && *pos + SKIP_ONE < len) {
(*pos)++;
if (blen < max_blen) {
switch (input[*pos]) {
case 'n':
buf[blen++] = '\n';
break;
case 't':
buf[blen++] = '\t';
break;
case '\\':
buf[blen++] = '\\';
break;
default:
buf[blen++] = input[*pos];
break;
}
}
} else {
if (blen < max_blen) {
buf[blen++] = input[*pos];
}
}
(*pos)++;
}
buf[blen] = '\0';
if (*pos < len) {
(*pos)++; /* skip closing quote */
}
lex_push(out, TOK_STRING, buf, start - SKIP_ONE);
}
/* Keyword table (case-insensitive lookup) */
typedef struct {
const char *name;
cbm_token_type_t type;
} kw_entry_t;
static const kw_entry_t keywords[] = {
/* Core query */
{"MATCH", TOK_MATCH},
{"WHERE", TOK_WHERE},
{"RETURN", TOK_RETURN},
{"ORDER", TOK_ORDER},
{"BY", TOK_BY},
{"LIMIT", TOK_LIMIT},
{"AND", TOK_AND},
{"OR", TOK_OR},
{"AS", TOK_AS},
{"DISTINCT", TOK_DISTINCT},
{"COUNT", TOK_COUNT},
{"CONTAINS", TOK_CONTAINS},
{"STARTS", TOK_STARTS},
{"WITH", TOK_WITH},
{"NOT", TOK_NOT},
{"ASC", TOK_ASC},
{"DESC", TOK_DESC},
/* Phase 1-2: operators + expression */
{"ENDS", TOK_ENDS},
{"IN", TOK_IN},
{"IS", TOK_IS},
{"NULL", TOK_NULL_KW},
{"XOR", TOK_XOR},
/* Phase 3-4: SKIP, UNION, UNWIND, aggregates */
{"SKIP", TOK_SKIP},
{"UNION", TOK_UNION},
{"UNWIND", TOK_UNWIND},
{"SUM", TOK_SUM},
{"AVG", TOK_AVG},
{"MIN", TOK_MIN_KW},
{"MAX", TOK_MAX_KW},
{"COLLECT", TOK_COLLECT},
/* Phase 5: string functions + CASE */
{"toLower", TOK_TOLOWER},
{"toUpper", TOK_TOUPPER},
{"toString", TOK_TOSTRING},
{"tolower", TOK_TOLOWER},
{"toupper", TOK_TOUPPER},
{"tostring", TOK_TOSTRING},
{"CASE", TOK_CASE},
{"WHEN", TOK_WHEN},
{"THEN", TOK_THEN},
{"ELSE", TOK_ELSE},
{"END", TOK_END},
/* Phase 7: OPTIONAL */
{"OPTIONAL", TOK_OPTIONAL},
/* Recognized-but-unsupported write/admin keywords */
{"CREATE", TOK_CREATE},
{"DELETE", TOK_DELETE},
{"DETACH", TOK_DETACH},
{"SET", TOK_SET},
{"REMOVE", TOK_REMOVE},
{"MERGE", TOK_MERGE},
{"YIELD", TOK_YIELD},
{"CALL", TOK_CALL},
{"ALL", TOK_ALL},
{"TRUE", TOK_TRUE},
{"FALSE", TOK_FALSE},
{"EXISTS", TOK_EXISTS},
{"MANDATORY", TOK_MANDATORY},
{"FOREACH", TOK_FOREACH},
{"ON", TOK_ON},
{"ADD", TOK_ADD},
{"CONSTRAINT", TOK_CONSTRAINT},
{"DO", TOK_DO},
{"DROP", TOK_DROP},
{"FOR", TOK_FOR},
{"FROM", TOK_FROM},
{"GRAPH", TOK_GRAPH},
{"OF", TOK_OF},
{"REQUIRE", TOK_REQUIRE},
{"SCALAR", TOK_SCALAR},
{"UNIQUE", TOK_UNIQUE},
{NULL, 0}};
static cbm_token_type_t keyword_lookup(const char *word) {
/* Case-insensitive compare */
for (const kw_entry_t *kw = keywords; kw->name; kw++) {
if (strcasecmp(word, kw->name) == 0) {
return kw->type;
}
}
return TOK_IDENT;
}
/* Try to match a two-character token at position i. Returns true and advances i if matched. */
static bool lex_try_two_char(const char *input, int len, int *i, cbm_lex_result_t *out) {
static const struct {
char c1, c2;
cbm_token_type_t type;
const char *text;
} pairs[] = {
{'!', '=', TOK_NEQ, "!="}, {'<', '>', TOK_NEQ, "<>"}, {'=', '~', TOK_EQTILDE, "=~"},
{'>', '=', TOK_GTE, ">="}, {'<', '=', TOK_LTE, "<="}, {'.', '.', TOK_DOTDOT, ".."},
};
char c = input[*i];
if (*i + SKIP_ONE >= len) {
return false;
}
char c2 = input[*i + SKIP_ONE];
for (int p = 0; p < (int)(sizeof(pairs) / sizeof(pairs[0])); p++) {
if (c == pairs[p].c1 && c2 == pairs[p].c2) {
lex_push(out, pairs[p].type, pairs[p].text, *i);
*i += PAIR_LEN;
return true;
}
}
return false;
}
/* Try to match a single-character token. Returns TOK_EOF if not matched. */
static cbm_token_type_t lex_single_char(char c) {
switch (c) {
case '(':
return TOK_LPAREN;
case ')':
return TOK_RPAREN;
case '[':
return TOK_LBRACKET;
case ']':
return TOK_RBRACKET;
case '-':
return TOK_DASH;
case '>':
return TOK_GT;
case '<':
return TOK_LT;
case ':':
return TOK_COLON;
case '.':
return TOK_DOT;
case '{':
return TOK_LBRACE;
case '}':
return TOK_RBRACE;
case '*':
return TOK_STAR;
case ',':
return TOK_COMMA;
case '=':
return TOK_EQ;
case '|':
return TOK_PIPE;
default:
return TOK_EOF;
}
}
/* Try to lex an identifier or keyword starting at position i. Returns true if matched. */
static bool lex_try_ident(const char *input, int len, int *i, cbm_lex_result_t *out) {
char c = input[*i];
if (!isalpha((unsigned char)c) && c != '_') {
return false;
}
int start = *i;
while (*i < len && (isalnum((unsigned char)input[*i]) || input[*i] == '_')) {
(*i)++;
}
char word[CBM_SZ_256];
int wlen = *i - start;
if (wlen >= (int)sizeof(word)) {
wlen = (int)sizeof(word) - SKIP_ONE;
}
memcpy(word, input + start, wlen);
word[wlen] = '\0';
cbm_token_type_t type = keyword_lookup(word);
lex_push_n(out, type, input + start, *i - start, start);
return true;
}
/* Try to lex a number starting at position i. Returns true if matched. */
static bool lex_try_number(const char *input, int len, int *i, cbm_lex_result_t *out) {
char c = input[*i];
if (!isdigit((unsigned char)c) &&
!(c == '.' && *i + SKIP_ONE < len && isdigit((unsigned char)input[*i + SKIP_ONE]))) {
return false;
}
int start = *i;
while (*i < len && (isdigit((unsigned char)input[*i]) ||
(input[*i] == '.' && *i + SKIP_ONE < len && input[*i + SKIP_ONE] != '.'))) {
(*i)++;
}
lex_push_n(out, TOK_NUMBER, input + start, *i - start, start);
return true;
}
/* Skip whitespace and comments. Returns true if something was skipped. */
static bool lex_skip_whitespace_comments(const char *input, int len, int *i) {
if (isspace((unsigned char)input[*i])) {
(*i)++;
return true;
}
if (*i + SKIP_ONE < len && input[*i] == '/' && input[*i + SKIP_ONE] == '/') {
while (*i < len && input[*i] != '\n') {
(*i)++;
}
return true;
}
/* SQL-style -- single-line comment */
if (*i + SKIP_ONE < len && input[*i] == '-' && input[*i + SKIP_ONE] == '-') {
while (*i < len && input[*i] != '\n') {
(*i)++;
}
return true;
}
if (*i + SKIP_ONE < len && input[*i] == '/' && input[*i + SKIP_ONE] == '*') {
*i += PAIR_LEN;
while (*i + SKIP_ONE < len && !(input[*i] == '*' && input[*i + SKIP_ONE] == '/')) {
(*i)++;
}
if (*i + SKIP_ONE < len) {
*i += PAIR_LEN;
}
return true;
}
return false;
}
int cbm_lex(const char *input, cbm_lex_result_t *out) {
memset(out, 0, sizeof(*out));
if (!input) {
return CBM_NOT_FOUND;
}
int len = (int)strlen(input);
int i = 0;
while (i < len) {
if (lex_skip_whitespace_comments(input, len, &i)) {
continue;
}
char c = input[i];
/* String literals */
if (c == '"' || c == '\'') {
char quote = c;
i++;
lex_string_literal(input, len, &i, quote, out);
continue;
}
/* Numbers — stop before ".." (DOTDOT operator) */
if (lex_try_number(input, len, &i, out)) {
continue;
}
/* Identifiers / keywords */
if (lex_try_ident(input, len, &i, out)) {
continue;
}
/* Two-character tokens */
{
bool found_two = lex_try_two_char(input, len, &i, out);
if (found_two) {
continue;
}
}
/* Single-character tokens */
cbm_token_type_t stype = lex_single_char(c);
if (stype != TOK_EOF) {
char buf[PAIR_LEN] = {c, '\0'};
lex_push(out, stype, buf, i);
i++;
continue;
}
/* Unknown character — skip */
i++;
}
/* Add EOF */
lex_push(out, TOK_EOF, "", i);
return 0;
}
void cbm_lex_free(cbm_lex_result_t *r) {
if (!r) {
return;
}
for (int i = 0; i < r->count; i++) {
safe_str_free(&r->tokens[i].text);
}
free(r->tokens);
free(r->error);
memset(r, 0, sizeof(*r));
}
/* ══════════════════════════════════════════════════════════════════
* PARSER
* ══════════════════════════════════════════════════════════════════ */
/* The WHERE grammar descends once per nested '(' and once per NOT, so parse
* depth follows the query text rather than anything bounded. A few tens of KB of
* either exhausts the stack before any semantic limit applies. 256 is far past
* any hand-written or generated query while leaving the stack untouched. */
enum { CYPHER_MAX_PARSE_DEPTH = 256 };
typedef struct {
const cbm_token_t *tokens;
int count;
int pos;
int depth; /* current recursive-descent depth; see CYPHER_MAX_PARSE_DEPTH */
char error[CBM_SZ_512];
} parser_t;
/* Enter one level of recursive descent. Returns false when the cap is hit, in
* which case the caller must return NULL without recursing. */
static bool parse_depth_enter(parser_t *p) {
if (p->depth >= CYPHER_MAX_PARSE_DEPTH) {
snprintf(p->error, sizeof(p->error), "expression nested deeper than %d levels",
CYPHER_MAX_PARSE_DEPTH);
return false;
}
p->depth++;
return true;
}
static void parse_depth_leave(parser_t *p) {
p->depth--;
}
static const cbm_token_t *peek(parser_t *p) {
if (p->pos >= p->count) {
return &p->tokens[p->count - SKIP_ONE]; /* EOF */
}
return &p->tokens[p->pos];
}
static const cbm_token_t *advance(parser_t *p) {
if (p->pos >= p->count) {
return &p->tokens[p->count - SKIP_ONE];
}
return &p->tokens[p->pos++];
}
static bool check(parser_t *p, cbm_token_type_t type) {
return peek(p)->type == type;
}
static bool match(parser_t *p, cbm_token_type_t type) {
if (check(p, type)) {
advance(p);
return true;
}
return false;
}
static const cbm_token_t *expect(parser_t *p, cbm_token_type_t type) {
if (check(p, type)) {
return advance(p);
}
snprintf(p->error, sizeof(p->error), "expected token type %d, got %d at pos %d", type,
peek(p)->type, peek(p)->pos);
return NULL;
}
/* Parse inline properties: {key: "value", ...} */
static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
*out = NULL;
*count = 0;
if (!match(p, TOK_LBRACE)) {
return 0;
}
int cap = CYP_INIT_CAP4;
int n = 0;
cbm_prop_filter_t *arr = malloc(cap * sizeof(cbm_prop_filter_t));
if (!arr) {
return CBM_NOT_FOUND;
}
while (!check(p, TOK_RBRACE) && !check(p, TOK_EOF)) {
const cbm_token_t *key = expect(p, TOK_IDENT);
if (!key) {
free(arr);
return CBM_NOT_FOUND;
}
if (!expect(p, TOK_COLON)) {
free(arr);
return CBM_NOT_FOUND;
}
const cbm_token_t *val = expect(p, TOK_STRING);
if (!val) {
free(arr);
return CBM_NOT_FOUND;
}
if (n >= cap) {
int new_cap = cap * PAIR_LEN;
void *tmp = realloc(arr, new_cap * sizeof(cbm_prop_filter_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].key);
safe_str_free(&arr[i].value);
}
free(arr);
return CBM_NOT_FOUND;
}
arr = tmp;
cap = new_cap;
}
const char *new_key = heap_strdup(key->text);
const char *new_val = heap_strdup(val->text);
if (!new_key || !new_val) {
safe_str_free(&new_key);
safe_str_free(&new_val);
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].key);
safe_str_free(&arr[i].value);
}
free(arr);
return CBM_NOT_FOUND;
}
arr[n].key = new_key;
arr[n].value = new_val;
n++;
match(p, TOK_COMMA); /* optional comma */
}
expect(p, TOK_RBRACE);
*out = arr;
*count = n;
return 0;
}
/* Parse node: (variable:Label {props}) */
static int parse_node(parser_t *p, cbm_node_pattern_t *out) {
memset(out, 0, sizeof(*out));
if (!expect(p, TOK_LPAREN)) {
return CBM_NOT_FOUND;
}
/* Optional variable */
if (check(p, TOK_IDENT)) {
/* Lookahead: if next is COLON, this is a variable */
/* Or if next is RPAREN/LBRACE, this is a variable without label */
out->variable = heap_strdup(advance(p)->text);
}
/* Optional :Label, with openCypher label alternation :A|B|C (#242).
* Stored as a single "A|B|C" string; the matcher splits on '|'. */
if (match(p, TOK_COLON)) {
const cbm_token_t *label = expect(p, TOK_IDENT);
if (!label) {
return CBM_NOT_FOUND;
}
char lbuf[CBM_SZ_256];
int ll = snprintf(lbuf, sizeof(lbuf), "%s", label->text);
while (match(p, TOK_PIPE)) {
const cbm_token_t *alt = expect(p, TOK_IDENT);
if (!alt) {
return CBM_NOT_FOUND;
}
int w = snprintf(lbuf + ll, (ll < (int)sizeof(lbuf)) ? sizeof(lbuf) - (size_t)ll : 0,
"|%s", alt->text);
if (w > 0) {
ll += w;
}
if (ll >= (int)sizeof(lbuf)) {
break; /* buffer full */
}
}
out->label = heap_strdup(lbuf);
}
/* Optional {props} */
if (check(p, TOK_LBRACE)) {
if (parse_props(p, &out->props, &out->prop_count) < 0) {
return CBM_NOT_FOUND;
}
}
if (!expect(p, TOK_RPAREN)) {
return CBM_NOT_FOUND;
}
return 0;
}
/* Parse *min..max hop range after the star token has been consumed */
static void parse_hop_range(parser_t *p, int *min_hops, int *max_hops) {
if (check(p, TOK_NUMBER)) {
int val = (int)strtol(peek(p)->text, NULL, CBM_DECIMAL_BASE);
advance(p);
if (match(p, TOK_DOTDOT)) {
*min_hops = val;
*max_hops =
check(p, TOK_NUMBER) ? (int)strtol(advance(p)->text, NULL, CBM_DECIMAL_BASE) : 0;
} else {
/* *N means 1..N */
*min_hops = SKIP_ONE;
*max_hops = val;
}
} else if (match(p, TOK_DOTDOT)) {
*min_hops = SKIP_ONE;
*max_hops =
check(p, TOK_NUMBER) ? (int)strtol(advance(p)->text, NULL, CBM_DECIMAL_BASE) : 0;
} else {
/* * alone = unbounded */
*min_hops = SKIP_ONE;
*max_hops = 0;
}
}
/* Parse relationship type list after ':' inside brackets. Returns -1 on error. */
static int parse_rel_types(parser_t *p, cbm_rel_pattern_t *out) {
int cap = CYP_INIT_CAP4;
int n = 0;
const char **types = malloc(cap * sizeof(const char *));
if (!types) {
return CBM_NOT_FOUND;
}
const cbm_token_t *t = expect(p, TOK_IDENT);
if (!t) {
free(types);
return CBM_NOT_FOUND;
}
const char *first_type = heap_strdup(t->text);
if (!first_type) {
free(types);
return CBM_NOT_FOUND;
}
types[n++] = first_type;
while (match(p, TOK_PIPE)) {
t = expect(p, TOK_IDENT);
if (!t) {
for (int i = 0; i < n; i++) {
safe_str_free(&types[i]);
}
free(types);
return CBM_NOT_FOUND;
}
if (n >= cap) {
int new_cap = cap * PAIR_LEN;
void *tmp = realloc(types, new_cap * sizeof(const char *));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&types[i]);
}
free(types);
return CBM_NOT_FOUND;
}
types = (const char **)tmp;
cap = new_cap;
}
const char *next_type = heap_strdup(t->text);
if (!next_type) {
for (int i = 0; i < n; i++) {
safe_str_free(&types[i]);
}
free(types);
return CBM_NOT_FOUND;
}
types[n++] = next_type;
}
out->types = types;
out->type_count = n;
return 0;
}
/* Parse bracket content of a relationship: [var:TYPE*hops] */
static int parse_rel_bracket(parser_t *p, cbm_rel_pattern_t *out) {
/* Optional variable */
if (check(p, TOK_IDENT) && !check(p, TOK_COLON)) {
out->variable = heap_strdup(advance(p)->text);
}
/* Optional :Types */
if (match(p, TOK_COLON)) {
if (parse_rel_types(p, out) < 0) {
return CBM_NOT_FOUND;
}
}
/* Optional *hop_range */
if (match(p, TOK_STAR)) {
parse_hop_range(p, &out->min_hops, &out->max_hops);
}
if (!expect(p, TOK_RBRACKET)) {
return CBM_NOT_FOUND;
}
return 0;
}
/* Parse relationship: -[:TYPE|TYPE2*min..max]-> or <-[...]- */
static int parse_rel(parser_t *p, cbm_rel_pattern_t *out) {
memset(out, 0, sizeof(*out));
out->min_hops = SKIP_ONE;
out->max_hops = SKIP_ONE;
/* Check for leading < (inbound) */
bool leading_lt = match(p, TOK_LT);
if (!expect(p, TOK_DASH)) {
return CBM_NOT_FOUND;
}
/* Optional bracket content */
if (match(p, TOK_LBRACKET)) {
if (parse_rel_bracket(p, out) < 0) {
return CBM_NOT_FOUND;
}
}
if (!expect(p, TOK_DASH)) {
return CBM_NOT_FOUND;
}
/* Check for trailing > (outbound) */
bool trailing_gt = match(p, TOK_GT);
/* Determine direction */
if (leading_lt && !trailing_gt) {
out->direction = heap_strdup("inbound");
} else if (!leading_lt && trailing_gt) {
out->direction = heap_strdup("outbound");
} else {
out->direction = heap_strdup("any");
}
return 0;
}
/* ── Expression tree helpers ────────────────────────────────────── */
static void expr_free(cbm_expr_t *e) {
enum { EXPR_FREE_STACK = 128 };
cbm_expr_t *stack[EXPR_FREE_STACK];
int top = 0;
if (e) {
stack[top++] = e;
}
while (top > 0) {
cbm_expr_t *cur = stack[--top];
if (cur->type == EXPR_CONDITION) {
safe_str_free(&cur->cond.variable);
safe_str_free(&cur->cond.property);
safe_str_free(&cur->cond.op);
safe_str_free(&cur->cond.value);
safe_str_free(&cur->cond.coalesce_default);
for (int i = 0; i < cur->cond.in_value_count; i++) {
safe_str_free(&cur->cond.in_values[i]);
}
free(cur->cond.in_values);
safe_str_free(&cur->cond.func);
for (int i = 0; i < cur->cond.arg_count; i++) {
safe_str_free(&cur->cond.args[i].variable);
safe_str_free(&cur->cond.args[i].property);
safe_str_free(&cur->cond.args[i].literal);
}
free(cur->cond.args);
}
if (cur->right) {
if (top < EXPR_FREE_STACK) {
stack[top++] = cur->right;
} else {
expr_free(cur->right); /* recurse when stack overflows */
}
}
if (cur->left) {
if (top < EXPR_FREE_STACK) {
stack[top++] = cur->left;
} else {
expr_free(cur->left); /* recurse when stack overflows */
}
}
free(cur);
}
}
static cbm_expr_t *expr_leaf(cbm_condition_t c) {
cbm_expr_t *e = calloc(CBM_ALLOC_ONE, sizeof(cbm_expr_t));
e->type = EXPR_CONDITION;
e->cond = c;
return e;
}
static cbm_expr_t *expr_binary(cbm_expr_type_t type, cbm_expr_t *left, cbm_expr_t *right) {
cbm_expr_t *e = calloc(CBM_ALLOC_ONE, sizeof(cbm_expr_t));
e->type = type;
e->left = left;
e->right = right;
return e;
}
static cbm_expr_t *expr_not(cbm_expr_t *child) {
cbm_expr_t *e = calloc(CBM_ALLOC_ONE, sizeof(cbm_expr_t));
e->type = EXPR_NOT;
e->left = child;
return e;
}
/* ── Unsupported keyword detection ─────────────────────────────── */
static const char *unsupported_clause_error(cbm_token_type_t type) {
switch (type) {
case TOK_CREATE:
return "unsupported Cypher feature: CREATE clause (write operations not supported)";
case TOK_DELETE:
return "unsupported Cypher feature: DELETE clause (write operations not supported)";
case TOK_DETACH:
return "unsupported Cypher feature: DETACH DELETE (write operations not supported)";
case TOK_SET:
return "unsupported Cypher feature: SET clause (write operations not supported)";
case TOK_REMOVE:
return "unsupported Cypher feature: REMOVE clause (write operations not supported)";
case TOK_MERGE:
return "unsupported Cypher feature: MERGE clause (write operations not supported)";
case TOK_YIELD:
return "unsupported Cypher feature: YIELD clause";
case TOK_CALL:
return "unsupported Cypher feature: CALL clause (stored procedures not supported)";
case TOK_FOREACH:
return "unsupported Cypher feature: FOREACH clause";
case TOK_MANDATORY:
return "unsupported Cypher feature: MANDATORY MATCH";
case TOK_DROP:
return "unsupported Cypher feature: DROP (schema operations not supported)";
case TOK_CONSTRAINT:
return "unsupported Cypher feature: CONSTRAINT (schema operations not supported)";
default:
return NULL;
}
}
/* ── Recursive descent WHERE parser (Phase 2) ──────────────────── */
/* Forward declarations for recursive descent */
static cbm_expr_t *parse_or_expr(parser_t *p);
/* Multi-arg scalar function support, shared with the RETURN-item parser (#874) */
static bool is_multiarg_func_call(parser_t *p);
static int parse_multiarg_func_item(parser_t *p, cbm_return_item_t *item);
/* Free a multi-arg function argument array. */
static void func_args_free(cbm_func_arg_t *args, int count) {
for (int i = 0; i < count; i++) {
safe_str_free(&args[i].variable);
safe_str_free(&args[i].property);
safe_str_free(&args[i].literal);
}
free(args);
}
/* Free the fields of a partially-parsed multi-arg function item. */
static void func_item_fields_free(cbm_return_item_t *item) {
safe_str_free(&item->variable);
safe_str_free(&item->property);
safe_str_free(&item->func);
func_args_free(item->args, item->arg_count);
item->args = NULL;
item->arg_count = 0;
}
/* Free the function-call fields of a WHERE condition (#874). */
static void cond_func_fields_free(cbm_condition_t *c) {
safe_str_free(&c->func);
func_args_free(c->args, c->arg_count);
c->args = NULL;
c->arg_count = 0;
}
/* Parse IN [val, val, ...] list. Returns expr_leaf or NULL on error. */
static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) {
advance(p);
c->op = heap_strdup("IN");
if (!c->op) {
safe_str_free(&c->variable);
safe_str_free(&c->property);
return NULL;
}
if (!expect(p, TOK_LBRACKET)) {
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
return NULL;
}
int vcap = CYP_INIT_CAP8;
int vn = 0;
const char **vals = malloc(vcap * sizeof(const char *));
if (!vals) {
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
return NULL;
}
while (!check(p, TOK_RBRACKET) && !check(p, TOK_EOF)) {
if (vn > 0) {
match(p, TOK_COMMA);
}
if (check(p, TOK_STRING) || check(p, TOK_NUMBER)) {
if (vn >= vcap) {
int new_vcap = vcap * PAIR_LEN;
void *tmp = realloc((void *)vals, new_vcap * sizeof(const char *));
if (!tmp) {
for (int i = 0; i < vn; i++) {
safe_str_free(&vals[i]);
}
safe_free(vals);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
return NULL;
}
vals = (const char **)tmp;
vcap = new_vcap;
}
const char *new_val = heap_strdup(advance(p)->text);
if (!new_val) {
for (int i = 0; i < vn; i++) {
safe_str_free(&vals[i]);
}
safe_free(vals);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
return NULL;
}
vals[vn++] = new_val;
} else {
break;
}
}
expect(p, TOK_RBRACKET);
c->in_values = vals;
c->in_value_count = vn;
return expr_leaf(*c);
}
/* Try to parse a comparison operator. Returns heap-allocated op string or NULL. */
static char *parse_comparison_op(parser_t *p) {
if (match(p, TOK_EQ)) {
return heap_strdup("=");
}
if (match(p, TOK_NEQ)) {
return heap_strdup("<>");
}
if (match(p, TOK_EQTILDE)) {
return heap_strdup("=~");
}
if (match(p, TOK_GTE)) {
return heap_strdup(">=");
}
if (match(p, TOK_LTE)) {
return heap_strdup("<=");
}
if (match(p, TOK_GT)) {
return heap_strdup(">");
}
if (match(p, TOK_LT)) {
return heap_strdup("<");
}
if (check(p, TOK_CONTAINS)) {
advance(p);
return heap_strdup("CONTAINS");
}
if (check(p, TOK_STARTS)) {
advance(p);
expect(p, TOK_WITH);
return heap_strdup("STARTS WITH");
}
if (check(p, TOK_ENDS)) {
advance(p);
expect(p, TOK_WITH);
return heap_strdup("ENDS WITH");
}
return NULL;
}
/* Parse a single condition: var.prop OP value | var.prop IS [NOT] NULL | var.prop IN [...] */
/* Free the heap fields of a standalone node pattern (not owned by a pattern). */
static void free_one_node_pattern(cbm_node_pattern_t *n) {
safe_str_free(&n->variable);
safe_str_free(&n->label);
for (int j = 0; j < n->prop_count; j++) {
safe_str_free(&n->props[j].key);
safe_str_free(&n->props[j].value);
}
free(n->props);
memset(n, 0, sizeof(*n));
}
/* Free the heap fields of a standalone relationship pattern. */
static void free_one_rel_pattern(cbm_rel_pattern_t *r) {
safe_str_free(&r->variable);
for (int j = 0; j < r->type_count; j++) {
safe_str_free(&r->types[j]);
}
free(r->types);
safe_str_free(&r->direction);
memset(r, 0, sizeof(*r));
}
/* Parse a bounded EXISTS predicate: EXISTS { (anchor)-[:TYPE]->() } — a
* single-hop, edge-type-specific existence check anchored on a bound variable
* (e.g. WHERE NOT EXISTS { (f)<-[:CALLS]-() } finds functions with no callers).
* Multi-hop / nested-WHERE EXISTS is intentionally unsupported. */
static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) {
advance(p); /* EXISTS */
if (!match(p, TOK_LBRACE)) {
snprintf(p->error, sizeof(p->error), "expected '{' after EXISTS at pos %d", peek(p)->pos);
return NULL;
}
cbm_node_pattern_t anchor = {0};
cbm_rel_pattern_t rel = {0};
cbm_node_pattern_t far_node = {0};
if (parse_node(p, &anchor) < 0 || parse_rel(p, &rel) < 0 || parse_node(p, &far_node) < 0) {
free_one_node_pattern(&anchor);
free_one_rel_pattern(&rel);
free_one_node_pattern(&far_node);
snprintf(p->error, sizeof(p->error),
"unsupported EXISTS pattern — only the single-hop form "
"'(var)-[:TYPE]->()' is supported");
return NULL;
}
expect(p, TOK_RBRACE);
cbm_condition_t c = {0};
c.negated = negated;
c.op = heap_strdup("EXISTS");
c.variable = heap_strdup(anchor.variable ? anchor.variable : "");
c.value = (rel.type_count > 0 && rel.types[0]) ? heap_strdup(rel.types[0]) : NULL;
c.exists_dir = (rel.direction && strcmp(rel.direction, "inbound") == 0) ? 1
: (rel.direction && strcmp(rel.direction, "any") == 0) ? 2
: 0;
free_one_node_pattern(&anchor);
free_one_rel_pattern(&rel);
free_one_node_pattern(&far_node);
return expr_leaf(c);
}
/* Parse the operator + value tail shared by every condition subject
* (var[.prop] and multi-arg functions like coalesce(...)): IS [NOT] NULL,
* IN [...], or a comparison operator with a literal value. */
static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) {
/* IS NULL / IS NOT NULL */
if (check(p, TOK_IS)) {
advance(p);
if (match(p, TOK_NOT)) {
c->op = heap_strdup("IS NOT NULL");
expect(p, TOK_NULL_KW);
} else {
expect(p, TOK_NULL_KW);
c->op = heap_strdup("IS NULL");
}
return expr_leaf(*c);
}
/* IN [...] */
if (check(p, TOK_IN)) {
cbm_expr_t *e = parse_in_list(p, c);
if (!e) {
cond_func_fields_free(c);
}
return e;
}
/* Standard operators */
c->op = parse_comparison_op(p);
if (!c->op) {
snprintf(p->error, sizeof(p->error), "unexpected operator at pos %d", peek(p)->pos);
cond_func_fields_free(c);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->coalesce_default);
return NULL;
}
/* Value */
if (check(p, TOK_STRING) || check(p, TOK_NUMBER)) {
c->value = heap_strdup(advance(p)->text);
} else if (check(p, TOK_TRUE)) {
advance(p);
c->value = heap_strdup("true");
} else if (check(p, TOK_FALSE)) {
advance(p);
c->value = heap_strdup("false");
} else {
snprintf(p->error, sizeof(p->error), "expected value at pos %d", peek(p)->pos);
cond_func_fields_free(c);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
safe_str_free(&c->coalesce_default);
return NULL;
}
return expr_leaf(*c);
}
/* parse_condition_lhs result: the label-test form is a complete condition. */
enum { COND_LHS_COMPLETE = 1 };
/* Parse the left-hand side of a WHERE condition into c.
* Returns CBM_NOT_FOUND on error, 0 when an operator/value should follow, and
* COND_LHS_COMPLETE when the condition is already complete (label test). */
static int parse_condition_lhs(parser_t *p, cbm_condition_t *c) {
if (is_multiarg_func_call(p)) {
/* Multi-arg scalar function LHS: coalesce(f.depth, 0) >= 2 (#874).
* Reuse the RETURN-item parser, then move ownership into the condition. */
cbm_return_item_t fitem;
memset(&fitem, 0, sizeof(fitem));
if (parse_multiarg_func_item(p, &fitem) < 0) {
func_item_fields_free(&fitem);
return CBM_NOT_FOUND;
}
c->variable = fitem.variable;
c->property = fitem.property;
c->func = fitem.func;
c->args = fitem.args;
c->arg_count = fitem.arg_count;
return 0;
}
if (check(p, TOK_IDENT) && p->pos + SKIP_ONE < p->count &&
p->tokens[p->pos + SKIP_ONE].type == TOK_LPAREN) {
/* Unrecognised function call in WHERE — fail loudly with the supported
* set instead of the misleading "unexpected operator" (#874). */
snprintf(p->error, sizeof(p->error),
"unsupported function '%s' in WHERE (supported: coalesce, substring, replace, "
"left, right)",
peek(p)->text);
return CBM_NOT_FOUND;
}
const cbm_token_t *var = expect(p, TOK_IDENT);
if (!var) {
return CBM_NOT_FOUND;
}
/* Label test: WHERE n:Label (openCypher, #241). Modelled as a leaf with
* op="HAS_LABEL" and value=Label, evaluated against the bound node's label. */
if (check(p, TOK_COLON)) {
advance(p);
const cbm_token_t *lbl = expect(p, TOK_IDENT);
if (!lbl) {
return CBM_NOT_FOUND;
}
c->variable = heap_strdup(var->text);
c->op = heap_strdup("HAS_LABEL");
c->value = heap_strdup(lbl->text);
return COND_LHS_COMPLETE;
}
if (match(p, TOK_DOT)) {
const cbm_token_t *prop = expect(p, TOK_IDENT);
if (!prop) {
return CBM_NOT_FOUND;
}
c->variable = heap_strdup(var->text);
c->property = heap_strdup(prop->text);
} else {
/* No dot: bare alias (e.g. post-WITH variable like "cnt") */
c->variable = heap_strdup(var->text);
c->property = NULL;
}
return 0;
}
static cbm_expr_t *parse_condition_expr(parser_t *p) {
/* Check for NOT prefix at condition level (e.g. NOT n.name CONTAINS "x") */
bool negated = match(p, TOK_NOT);
/* EXISTS { pattern } predicate (anchored single-hop existence). */
if (check(p, TOK_EXISTS)) {
return parse_exists_predicate(p, negated);
}
cbm_condition_t c = {0};
c.negated = negated;
int lhs_rc = parse_condition_lhs(p, &c);
if (lhs_rc < 0) {
return NULL;
}
if (lhs_rc > 0) {
/* HAS_LABEL leaf — complete condition, no operator follows */
return expr_leaf(c);
}
return parse_condition_op(p, &c);
}
/* Atom: ( expr ) | condition */
static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
if (match(p, TOK_LPAREN)) {
if (!parse_depth_enter(p)) {
return NULL;
}
cbm_expr_t *e = parse_or_expr(p);
parse_depth_leave(p);
expect(p, TOK_RPAREN);
return e;
}
return parse_condition_expr(p);
}
/* NOT: NOT atom | atom */
static cbm_expr_t *parse_not_expr(parser_t *p) { // NOLINT(misc-no-recursion)
if (match(p, TOK_NOT)) {
if (!parse_depth_enter(p)) {
return NULL;
}
cbm_expr_t *child = parse_not_expr(p);
parse_depth_leave(p);
return child ? expr_not(child) : NULL;
}
return parse_atom_expr(p);
}
/* 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;
}
while (check(p, TOK_AND)) {
advance(p);
cbm_expr_t *right = parse_not_expr(p);
if (!right) {
expr_free(left);
return NULL;
}
left = expr_binary(EXPR_AND, left, right);
}
return left;
}
/* 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;
}
while (check(p, TOK_XOR)) {
advance(p);
cbm_expr_t *right = parse_and_expr(p);
if (!right) {
expr_free(left);
return NULL;
}
left = expr_binary(EXPR_XOR, left, right);
}
return left;
}
/* 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;
}
while (check(p, TOK_OR)) {
advance(p);
cbm_expr_t *right = parse_xor_expr(p);
if (!right) {
expr_free(left);
return NULL;
}
left = expr_binary(EXPR_OR, left, right);
}
return left;
}
/* Parse WHERE clause — builds expression tree */
static int parse_where(parser_t *p, cbm_where_clause_t **out) {
if (!match(p, TOK_WHERE)) {
*out = NULL;
return 0;
}
cbm_where_clause_t *w = calloc(CBM_ALLOC_ONE, sizeof(cbm_where_clause_t));
w->root = parse_or_expr(p);
if (!w->root && p->error[0]) {
free(w);
return CBM_NOT_FOUND;
}
*out = w;
return 0;
}
/* Helper: is token an aggregate function? */
static bool is_aggregate_tok(cbm_token_type_t t) {
return (t == TOK_COUNT || t == TOK_SUM || t == TOK_AVG || t == TOK_MIN_KW || t == TOK_MAX_KW ||
t == TOK_COLLECT) != 0;
}
/* Helper: is token a string function? */
static bool is_string_func_tok(cbm_token_type_t t) {
return (t == TOK_TOLOWER || t == TOK_TOUPPER || t == TOK_TOSTRING) != 0;
}
/* Token type to function name */
static const char *agg_func_name(cbm_token_type_t t) {
switch (t) {
case TOK_COUNT:
return "COUNT";
case TOK_SUM:
return "SUM";
case TOK_AVG:
return "AVG";
case TOK_MIN_KW:
return "MIN";
case TOK_MAX_KW:
return "MAX";
case TOK_COLLECT:
return "COLLECT";
default:
return "COUNT";
}
}
static const char *str_func_name(cbm_token_type_t t) {
switch (t) {
case TOK_TOLOWER:
return "toLower";
case TOK_TOUPPER:
return "toUpper";
case TOK_TOSTRING:
return "toString";
default:
return "";
}
}
/* Parse a value literal: string, number, ident[.prop], true, false. Returns heap-allocated. */
static const char *parse_value_literal(parser_t *p) {
if (check(p, TOK_STRING) || check(p, TOK_NUMBER)) {
return heap_strdup(advance(p)->text);
}
if (check(p, TOK_IDENT)) {
char buf[CBM_SZ_256];
const cbm_token_t *v = advance(p);
if (match(p, TOK_DOT)) {
const cbm_token_t *pr = expect(p, TOK_IDENT);
snprintf(buf, sizeof(buf), "%s.%s", v->text, pr ? pr->text : "");
} else {
snprintf(buf, sizeof(buf), "%s", v->text);
}
return heap_strdup(buf);
}
if (check(p, TOK_TRUE)) {
advance(p);
return heap_strdup("true");
}
if (check(p, TOK_FALSE)) {
advance(p);
return heap_strdup("false");
}
return NULL;
}
/* Parse CASE WHEN ... THEN ... [ELSE ...] END */
static cbm_case_expr_t *parse_case_expr(parser_t *p) {
/* CASE already consumed */
cbm_case_expr_t *kase = calloc(CBM_ALLOC_ONE, sizeof(cbm_case_expr_t));
if (!kase) {
return NULL;
}
int bcap = CYP_INIT_CAP4;
kase->branches = malloc(bcap * sizeof(cbm_case_branch_t));
if (!kase->branches) {
free(kase);
return NULL;
}
while (check(p, TOK_WHEN)) {
advance(p);
cbm_expr_t *when = parse_or_expr(p);
if (!expect(p, TOK_THEN)) {
expr_free(when);
break;
}
const char *then_val = parse_value_literal(p);
if (kase->branch_count >= bcap) {
int new_bcap = bcap * PAIR_LEN;
void *tmp = realloc(kase->branches, new_bcap * sizeof(cbm_case_branch_t));
if (!tmp) {
expr_free(when);
safe_str_free(&then_val);
for (int i = 0; i < kase->branch_count; i++) {
expr_free(kase->branches[i].when_expr);
safe_str_free(&kase->branches[i].then_val);
}
free(kase->branches);
free(kase);
return NULL;
}
kase->branches = tmp;
bcap = new_bcap;
}
kase->branches[kase->branch_count++] =
(cbm_case_branch_t){.when_expr = when, .then_val = then_val};
}
if (match(p, TOK_ELSE)) {
kase->else_val = parse_value_literal(p);
}
expect(p, TOK_END);
return kase;
}
/* Parse a single RETURN/WITH item (aggregate, string func, CASE, or plain var.prop).
* Returns 0 on success, -1 on error. */
/* Parse var[.prop] into item->variable and item->property. Returns -1 on error. */
/* ASCII case-insensitive string equality. */
static bool cyp_ci_eq(const char *a, const char *b) {
if (!a || !b) {
return false;
}
for (; *a && *b; a++, b++) {
if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) {
return false;
}
}
return *a == '\0' && *b == '\0';
}
/* Canonical name for a single-argument scalar / entity-introspection function
* invoked by identifier — labels/type/id/keys/properties and the numeric/bool
* casts toInteger/toFloat/toBoolean — or NULL if unrecognised (case-insensitive).
* toLower/toUpper/toString are separate keyword tokens handled elsewhere. */
static const char *scalar_func_canonical(const char *s) {
static const char *const names[] = {
"labels", "type", "id", "keys", "properties", "toInteger", "toFloat", "toBoolean",
"size", "length", "trim", "ltrim", "rtrim", "reverse", NULL};
for (int i = 0; names[i]; i++) {
if (cyp_ci_eq(s, names[i])) {
return names[i];
}
}
return NULL;
}
/* True for single-argument functions that transform a scalar string value
* (vs. entity-introspection funcs that act on the bound node/edge). */
static bool is_scalar_value_func(const char *f) {
return f && (strcmp(f, "toLower") == 0 || strcmp(f, "toUpper") == 0 ||
strcmp(f, "toString") == 0 || strcmp(f, "toInteger") == 0 ||
strcmp(f, "toFloat") == 0 || strcmp(f, "toBoolean") == 0 ||
strcmp(f, "size") == 0 || strcmp(f, "length") == 0 || strcmp(f, "trim") == 0 ||
strcmp(f, "ltrim") == 0 || strcmp(f, "rtrim") == 0 || strcmp(f, "reverse") == 0);
}
static int parse_var_dot_prop(parser_t *p, cbm_return_item_t *item) {
const cbm_token_t *var = expect(p, TOK_IDENT);
if (!var) {
return CBM_NOT_FOUND;
}
item->variable = heap_strdup(var->text);
if (match(p, TOK_DOT)) {
const cbm_token_t *prop = expect(p, TOK_IDENT);
if (prop) {
item->property = heap_strdup(prop->text);
}
}
return 0;
}
/* True if the cursor is at `IDENT(` where IDENT is a supported scalar function. */
static bool is_named_func_call(parser_t *p) {
if (!check(p, TOK_IDENT) || p->pos + SKIP_ONE >= p->count) {
return false;
}
if (p->tokens[p->pos + SKIP_ONE].type != TOK_LPAREN) {
return false;
}
return scalar_func_canonical(peek(p)->text) != NULL;
}
/* Parse a single-argument scalar / introspection call: labels(n), type(r),
* id(n), keys(n), properties(n), toInteger(n.start_line), ... */
static int parse_named_func_item(parser_t *p, cbm_return_item_t *item) {
const char *canon = scalar_func_canonical(peek(p)->text);
advance(p); /* consume the function name */
expect(p, TOK_LPAREN);
if (parse_var_dot_prop(p, item) < 0) {
return CBM_NOT_FOUND;
}
expect(p, TOK_RPAREN);
item->func = heap_strdup(canon);
return 0;
}
/* Canonical name for a multi-argument scalar function, or NULL. */
static const char *multiarg_func_canonical(const char *s) {
static const char *const names[] = {"coalesce", "substring", "replace", "left", "right", NULL};
for (int i = 0; names[i]; i++) {
if (cyp_ci_eq(s, names[i])) {
return names[i];
}
}
return NULL;
}
static bool is_multiarg_func_call(parser_t *p) {
if (!check(p, TOK_IDENT) || p->pos + SKIP_ONE >= p->count) {
return false;
}
if (p->tokens[p->pos + SKIP_ONE].type != TOK_LPAREN) {
return false;
}
return multiarg_func_canonical(peek(p)->text) != NULL;
}
/* Parse one function argument: a string/number literal or a var[.prop]. */
static int parse_func_arg(parser_t *p, cbm_func_arg_t *arg) {
memset(arg, 0, sizeof(*arg));
if (check(p, TOK_STRING) || check(p, TOK_NUMBER)) {
arg->literal = heap_strdup(peek(p)->text);
advance(p);
return 0;
}
const cbm_token_t *var = expect(p, TOK_IDENT);
if (!var) {
return CBM_NOT_FOUND;
}
arg->variable = heap_strdup(var->text);
if (match(p, TOK_DOT)) {
const cbm_token_t *prop = expect(p, TOK_IDENT);
if (prop) {
arg->property = heap_strdup(prop->text);
}
}
return 0;
}
/* Parse a multi-argument scalar call: coalesce(a, b, ...), substring(s, i[, n]),
* replace(s, from, to), left(s, n), right(s, n). */
static int parse_multiarg_func_item(parser_t *p, cbm_return_item_t *item) {
const char *canon = multiarg_func_canonical(peek(p)->text);
advance(p); /* function name */
expect(p, TOK_LPAREN);
int cap = CYP_INIT_CAP4;
item->args = malloc((size_t)cap * sizeof(cbm_func_arg_t));
item->arg_count = 0;
while (!check(p, TOK_RPAREN) && !check(p, TOK_EOF)) {
if (item->arg_count > 0 && !match(p, TOK_COMMA)) {
break;
}
if (item->arg_count >= cap) {
cap *= PAIR_LEN;
item->args = safe_realloc(item->args, (size_t)cap * sizeof(cbm_func_arg_t));
}
if (parse_func_arg(p, &item->args[item->arg_count]) < 0) {
return CBM_NOT_FOUND;
}
item->arg_count++;
}
expect(p, TOK_RPAREN);
item->func = heap_strdup(canon);
/* Surface the first variable arg as variable/property for column naming. */
if (item->arg_count > 0 && item->args[0].variable) {
item->variable = heap_strdup(item->args[0].variable);
if (item->args[0].property) {
item->property = heap_strdup(item->args[0].property);
}
}
return 0;
}
/* Parse aggregate function call: COUNT(var.prop) */
static int parse_aggregate_item(parser_t *p, cbm_return_item_t *item) {
cbm_token_type_t ft = peek(p)->type;
advance(p);
expect(p, TOK_LPAREN);
/* Optional DISTINCT inside the call: COUNT(DISTINCT x) (#239). */
item->distinct = match(p, TOK_DISTINCT);
if (match(p, TOK_STAR)) {
item->variable = heap_strdup("*");
} else {
if (parse_var_dot_prop(p, item) < 0) {
return CBM_NOT_FOUND;
}
}
expect(p, TOK_RPAREN);
item->func = heap_strdup(agg_func_name(ft));
return 0;
}
/* Parse string function call: toLower(var.prop) */
static int parse_string_func_item(parser_t *p, cbm_return_item_t *item) {
cbm_token_type_t ft = peek(p)->type;
advance(p);
expect(p, TOK_LPAREN);
if (parse_var_dot_prop(p, item) < 0) {
return CBM_NOT_FOUND;
}
expect(p, TOK_RPAREN);
item->func = heap_strdup(str_func_name(ft));
return 0;
}
static int parse_return_item(parser_t *p, cbm_return_item_t *item) {
memset(item, 0, sizeof(*item));
int rc = 0;
if (check(p, TOK_CASE)) {
advance(p);
item->kase = parse_case_expr(p);
item->variable = heap_strdup("CASE");
} else if (is_aggregate_tok(peek(p)->type)) {
rc = parse_aggregate_item(p, item);
} else if (is_string_func_tok(peek(p)->type)) {
rc = parse_string_func_item(p, item);
} else if (is_multiarg_func_call(p)) {
rc = parse_multiarg_func_item(p, item);
} else if (is_named_func_call(p)) {
rc = parse_named_func_item(p, item);
} else {
rc = parse_var_dot_prop(p, item);
}
if (rc < 0) {
return CBM_NOT_FOUND;
}
/* A bare identifier followed by '(' is a function we don't recognise
* (recognised aggregates / string funcs / scalar funcs are handled above),
* and '[' begins list indexing/slicing we don't support. Rather than
* silently projecting an empty column — which looks like a valid but blank
* result and hides the real problem — fail loudly with a clear message so
* the caller knows the query used an unsupported feature (#373). */
if (!item->func && !item->kase && (check(p, TOK_LPAREN) || check(p, TOK_LBRACKET))) {
if (check(p, TOK_LPAREN)) {
snprintf(p->error, sizeof(p->error),
"unsupported function '%s' (supported: count, sum, avg, min, max, collect, "
"toLower, toUpper, toString, toInteger, toFloat, toBoolean, size, length, "
"trim, ltrim, rtrim, reverse, labels, type, id, keys, properties)",
item->variable ? item->variable : "?");
} else {
snprintf(p->error, sizeof(p->error),
"unsupported expression: list indexing/slicing '[...]' is not supported");
}
safe_str_free(&item->variable);
safe_str_free(&item->property);
return CBM_NOT_FOUND;
}
/* Optional AS alias */
if (match(p, TOK_AS)) {
const cbm_token_t *alias = expect(p, TOK_IDENT);
if (alias) {
item->alias = heap_strdup(alias->text);
}
}
return 0;
}
/* Parse aggregate function call for ORDER BY */
static void parse_order_by_agg(parser_t *p, char *buf, size_t buf_sz) {
const char *fn = agg_func_name(peek(p)->type);
advance(p);
expect(p, TOK_LPAREN);
if (match(p, TOK_STAR)) {
snprintf(buf, buf_sz, "%s(*)", fn);
} else {
const cbm_token_t *var = expect(p, TOK_IDENT);
snprintf(buf, buf_sz, "%s(%s)", fn, var ? var->text : "");
}
expect(p, TOK_RPAREN);
}
/* Parse var[.prop] for ORDER BY */
static void parse_order_by_var(parser_t *p, char *buf, size_t buf_sz) {
const cbm_token_t *var = expect(p, TOK_IDENT);
if (!var) {
return;
}
snprintf(buf, buf_sz, "%s", var->text);
if (match(p, TOK_DOT)) {
const cbm_token_t *prop = expect(p, TOK_IDENT);
if (prop) {
snprintf(buf, buf_sz, "%s.%s", var->text, prop->text);
}
}
}
/* Parse ORDER BY expression into buf. Returns buf. */
static char *parse_order_by_expr(parser_t *p, char *buf, size_t buf_sz) {
buf[0] = '\0';
if (is_aggregate_tok(peek(p)->type)) {
parse_order_by_agg(p, buf, buf_sz);
} else {
parse_order_by_var(p, buf, buf_sz);
}
return buf;
}
/* Parse the full comma-separated ORDER BY key list (#1334). Consuming only the
* first key left ", key2 ... LIMIT n" unparsed, which silently dropped the
* LIMIT and flooded the caller with the whole result set. Returns 0 on
* success, CBM_NOT_FOUND when the key list exceeds the modeled maximum. */
static int parse_order_by_clause(parser_t *p, cbm_return_clause_t *r) {
expect(p, TOK_BY);
do {
if (r->order_key_count >= CBM_CYPHER_ORDER_KEYS_MAX) {
return CBM_NOT_FOUND;
}
char order_buf[CBM_SZ_256];
parse_order_by_expr(p, order_buf, sizeof(order_buf));
bool desc = false;
if (match(p, TOK_ASC)) {
desc = false;
} else if (match(p, TOK_DESC)) {
desc = true;
}
r->order_keys[r->order_key_count] = heap_strdup(order_buf);
r->order_descs[r->order_key_count] = desc;
r->order_key_count++;
} while (match(p, TOK_COMMA));
return 0;
}
static void free_return_clause(cbm_return_clause_t *r);
/* Parse RETURN/WITH clause (shared logic) */
static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_with) {
cbm_token_type_t tok = (int)is_with ? TOK_WITH : TOK_RETURN;
/* For WITH, we need to check it's standalone (not preceded by STARTS) */
if (!match(p, tok)) {
*out = NULL;
return 0;
}
cbm_return_clause_t *r = calloc(CBM_ALLOC_ONE, sizeof(cbm_return_clause_t));
/* -1 = no LIMIT clause (return all). An explicit `LIMIT 0` parses to 0 below
* and must return 0 rows — distinguishing the two requires a sentinel, since
* calloc zeroes limit and `limit > 0` would treat LIMIT 0 as "no limit". */
r->limit = -1;
int cap = CYP_INIT_CAP8;
r->items = malloc(cap * sizeof(cbm_return_item_t));
r->distinct = match(p, TOK_DISTINCT);
/* Check for RETURN * */
if (!is_with && match(p, TOK_STAR)) {
r->star = true;
/* Skip to ORDER BY / SKIP / LIMIT */
goto tail;
}
do {
if (r->count > 0 && !match(p, TOK_COMMA)) {
break;
}
cbm_return_item_t item = {0};
if (parse_return_item(p, &item) < 0) {
free_return_clause(r);
return CBM_NOT_FOUND;
}
if (r->count >= cap) {
cap *= PAIR_LEN;
r->items = safe_realloc(r->items, cap * sizeof(cbm_return_item_t));
}
r->items[r->count++] = item;
} while (check(p, TOK_COMMA));
/* Projection is materialized per row into fixed-width stack arrays sized at
* CBM_SZ_32 columns (execute_return_simple and its siblings). Bound the
* parsed item count to that width so an over-wide RETURN is rejected here
* instead of writing past those arrays downstream.
*
* WITH is bounded tighter, by CYP_MAX_VARS. Every item a WITH projects
* becomes one variable of the binding that carries the rest of the query,
* and binding_t holds exactly CYP_MAX_VARS variables. A wider WITH used to
* parse, then lose every alias past the 16th in with_add_vbinding_var and
* answer with silently blank columns. Refuse it here, the same way an
* over-wide RETURN is refused, so the caller sees an error instead of a
* short or empty result. */
if (r->count > (is_with ? CYP_MAX_VARS : CBM_SZ_32)) {
free_return_clause(r);
return CBM_NOT_FOUND;
}
tail:
/* Optional ORDER BY */
if (match(p, TOK_ORDER)) {
if (parse_order_by_clause(p, r) < 0) {
free_return_clause(r);
return CBM_NOT_FOUND;
}
}
/* Optional SKIP. A non-numeric operand is a loud parse error: expect() has
* already filled p->error, and dropping the clause instead would leave
* r->skip at its "none" default while the query still reports success. */
if (match(p, TOK_SKIP)) {
const cbm_token_t *num = expect(p, TOK_NUMBER);
if (!num) {
free_return_clause(r);
return CBM_NOT_FOUND;
}
r->skip = (int)strtol(num->text, NULL, CBM_DECIMAL_BASE);
}
/* Optional LIMIT. Same rule: a dropped LIMIT is the failure mode #1334
* banned - the caller would silently receive the whole result set. */
if (match(p, TOK_LIMIT)) {
const cbm_token_t *num = expect(p, TOK_NUMBER);
if (!num) {
free_return_clause(r);
return CBM_NOT_FOUND;
}
r->limit = (int)strtol(num->text, NULL, CBM_DECIMAL_BASE);
}
*out = r;
return 0;
}
/* Parse RETURN clause */
static int parse_return(parser_t *p, cbm_return_clause_t **out) {
return parse_return_or_with(p, out, false);
}
/* Parse a single MATCH pattern into pat */
static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) {
memset(pat, 0, sizeof(*pat));
int node_cap = CYP_INIT_CAP4;
int rel_cap = CYP_INIT_CAP4;
pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t));
pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t));
if (parse_node(p, &pat->nodes[0]) < 0) {
return CBM_NOT_FOUND;
}
pat->node_count = SKIP_ONE;
while (check(p, TOK_DASH) || check(p, TOK_LT)) {
if (pat->rel_count >= rel_cap) {
rel_cap *= PAIR_LEN;
pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t));
}
if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) {
return CBM_NOT_FOUND;
}
pat->rel_count++;
if (pat->node_count >= node_cap) {
node_cap *= PAIR_LEN;
pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t));
}
if (parse_node(p, &pat->nodes[pat->node_count]) < 0) {
return CBM_NOT_FOUND;
}
pat->node_count++;
}
return 0;
}
/* Parse UNWIND [...] AS var clause into query */
static void parse_unwind_clause(parser_t *p, cbm_query_t *q) {
advance(p);
if (check(p, TOK_LBRACKET)) {
/* Literal list: [1, 2, 3] — collect as JSON array string */
advance(p);
char buf[CBM_SZ_2K] = "[";
int blen = SKIP_ONE;
/* snprintf returns the length it WOULD have written, so a single
* oversized token (string literals lex up to CBM_SZ_4K-1) can push
* blen past sizeof(buf). Clamp after every write and guard the raw
* buf[blen++] stores, mirroring format_collect_list(). */
const int cap = (int)sizeof(buf);
while (!check(p, TOK_RBRACKET) && !check(p, TOK_EOF)) {
if (blen > SKIP_ONE && blen < cap - SKIP_ONE) {
buf[blen++] = ',';
}
if (check(p, TOK_STRING)) {
blen += snprintf(buf + blen, (size_t)(cap - blen), "\"%s\"", peek(p)->text);
advance(p);
} else if (check(p, TOK_NUMBER)) {
blen += snprintf(buf + blen, (size_t)(cap - blen), "%s", peek(p)->text);
advance(p);
} else {
advance(p);
}
if (blen >= cap) {
blen = cap - SKIP_ONE;
}
match(p, TOK_COMMA);
}
expect(p, TOK_RBRACKET);
if (blen < cap - SKIP_ONE) {
buf[blen++] = ']';
}
buf[blen] = '\0';
q->unwind_expr = heap_strdup(buf);
} else if (check(p, TOK_IDENT)) {
q->unwind_expr = heap_strdup(advance(p)->text);
}
expect(p, TOK_AS);
const cbm_token_t *alias = expect(p, TOK_IDENT);
if (alias) {
q->unwind_alias = heap_strdup(alias->text);
}
}
/* Parse a chain of MATCH / OPTIONAL MATCH patterns into query.
* Returns -1 on error (fills p->error). */
static int parse_match_chain(parser_t *p, cbm_query_t *q, int *pat_cap) {
while (check(p, TOK_MATCH) || check(p, TOK_OPTIONAL)) {
bool opt = false;
if (check(p, TOK_OPTIONAL)) {
advance(p);
opt = true;
}
if (!expect(p, TOK_MATCH)) {
break;
}
if (q->pattern_count >= *pat_cap) {
*pat_cap *= PAIR_LEN;
q->patterns = safe_realloc(q->patterns, *pat_cap * sizeof(cbm_pattern_t));
q->pattern_optional = safe_realloc(q->pattern_optional, *pat_cap * sizeof(bool));
}
if (parse_match_pattern(p, &q->patterns[q->pattern_count]) < 0) {
return CBM_NOT_FOUND;
}
q->pattern_optional[q->pattern_count] = opt;
q->pattern_count++;
}
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) {
/* More MATCH / OPTIONAL MATCH after WHERE */
if (parse_match_chain(p, q, pat_cap) < 0) {
return CBM_NOT_FOUND;
}
/* Check for unsupported keywords */
const char *unsup = unsupported_clause_error(peek(p)->type);
if (unsup) {
snprintf(p->error, sizeof(p->error), "%s", unsup);
return CBM_NOT_FOUND;
}
/* Optional WITH clause (standalone, not STARTS WITH) */
if (check(p, TOK_WITH) &&
(p->pos < PAIR_LEN || p->tokens[p->pos - SKIP_ONE].type != TOK_STARTS)) {
if (parse_return_or_with(p, &q->with_clause, true) < 0) {
return CBM_NOT_FOUND;
}
if (parse_where(p, &q->post_with_where) < 0) {
return CBM_NOT_FOUND;
}
}
/* Optional RETURN */
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};
/* Check for unsupported leading keywords */
const char *unsup = unsupported_clause_error(peek(&p)->type);
if (unsup) {
out->error = heap_strdup(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);
}
bool first_optional = false;
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");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
int pat_cap = CYP_INIT_CAP4;
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");
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");
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");
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");
cbm_query_free(q);
return CBM_NOT_FOUND;
}
/* 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
* dropped tail took the filter and the RETURN with it, and the engine
* answered from the fragment it had parsed, using its default projection.
* That reported success and returned wrong rows, which is worse than a
* refusal because nothing tells the caller to look. Refuse instead. */
if (peek(&p)->type != TOK_EOF) {
/* Only mention the one-WITH limit when a standalone WITH really is
* sitting in the part we could not read. Saying it every time points
* a reader at WITH when the problem is a typo. A WITH straight after
* STARTS is the STARTS WITH operator rather than a clause, the same
* guard parse_post_where uses. */
const char *hint = "";
for (int i = p.pos; i < p.count; i++) {
if (p.tokens[i].type == TOK_WITH &&
(i == 0 || p.tokens[i - SKIP_ONE].type != TOK_STARTS)) {
hint = " Note that only one WITH clause is supported.";
break;
}
}
snprintf(p.error, sizeof(p.error),
"unexpected input at pos %d ('%s') — the query was not fully "
"parsed.%s",
peek(&p)->pos, peek(&p)->text ? peek(&p)->text : "", hint);
out->error = heap_strdup(p.error);
cbm_query_free(q);
return CBM_NOT_FOUND;
}
out->query = q;
return 0;
}
void cbm_parse_free(cbm_parse_result_t *r) {
if (!r) {
return;
}
cbm_query_free(r->query);
free(r->error);
memset(r, 0, sizeof(*r));
}
/* ── Query free ─────────────────────────────────────────────────── */
static void free_pattern(cbm_pattern_t *pat) {
for (int i = 0; i < pat->node_count; i++) {
cbm_node_pattern_t *n = &pat->nodes[i];
safe_str_free(&n->variable);
safe_str_free(&n->label);
for (int j = 0; j < n->prop_count; j++) {
safe_str_free(&n->props[j].key);
safe_str_free(&n->props[j].value);
}
free(n->props);
}
free(pat->nodes);
for (int i = 0; i < pat->rel_count; i++) {
cbm_rel_pattern_t *r = &pat->rels[i];
safe_str_free(&r->variable);
for (int j = 0; j < r->type_count; j++) {
safe_str_free(&r->types[j]);
}
free(r->types);
safe_str_free(&r->direction);
}
free(pat->rels);
}
static void free_where(cbm_where_clause_t *w) {
if (!w) {
return;
}
expr_free(w->root);
for (int i = 0; i < w->count; i++) {
safe_str_free(&w->conditions[i].variable);
safe_str_free(&w->conditions[i].property);
safe_str_free(&w->conditions[i].op);
safe_str_free(&w->conditions[i].value);
for (int j = 0; j < w->conditions[i].in_value_count; j++) {
safe_str_free(&w->conditions[i].in_values[j]);
}
free(w->conditions[i].in_values);
safe_str_free(&w->conditions[i].func);
func_args_free(w->conditions[i].args, w->conditions[i].arg_count);
}
free(w->conditions);
safe_str_free(&w->op);
free(w);
}
static void free_case_expr(cbm_case_expr_t *k) {
if (!k) {
return;
}
for (int i = 0; i < k->branch_count; i++) {
expr_free(k->branches[i].when_expr);
safe_str_free(&k->branches[i].then_val);
}
free(k->branches);
safe_str_free(&k->else_val);
free(k);
}
static void free_return_clause(cbm_return_clause_t *r) {
if (!r) {
return;
}
for (int i = 0; i < r->count; i++) {
safe_str_free(&r->items[i].variable);
safe_str_free(&r->items[i].property);
safe_str_free(&r->items[i].alias);
safe_str_free(&r->items[i].func);
free_case_expr(r->items[i].kase);
for (int j = 0; j < r->items[i].arg_count; j++) {
safe_str_free(&r->items[i].args[j].variable);
safe_str_free(&r->items[i].args[j].property);
safe_str_free(&r->items[i].args[j].literal);
}
free(r->items[i].args);
}
free(r->items);
for (int k = 0; k < r->order_key_count; k++) {
safe_str_free(&r->order_keys[k]);
}
free(r);
}
void cbm_query_free(cbm_query_t *q) {
while (q) {
cbm_query_t *next = q->union_next;
for (int i = 0; i < q->pattern_count; i++) {
free_pattern(&q->patterns[i]);
}
free(q->patterns);
free(q->pattern_optional);
free_where(q->where);
free_where(q->post_with_where);
free_return_clause(q->with_clause);
free_return_clause(q->ret);
safe_str_free(&q->unwind_expr);
safe_str_free(&q->unwind_alias);
free(q);
q = next;
}
}
/* ── Convenience: lex + parse ───────────────────────────────────── */
int cbm_cypher_parse(const char *query, cbm_query_t **out, char **error) {
*out = NULL;
*error = NULL;
cbm_lex_result_t lr = {0};
if (cbm_lex(query, &lr) < 0 || lr.error) {
*error = heap_strdup(lr.error ? lr.error : "lex error");
cbm_lex_free(&lr);
return CBM_NOT_FOUND;
}
cbm_parse_result_t pr = {0};
if (cbm_parse(lr.tokens, lr.count, &pr) < 0) {
*error = heap_strdup(pr.error ? pr.error : "parse error");
cbm_parse_free(&pr);
cbm_lex_free(&lr);
return CBM_NOT_FOUND;
}
*out = pr.query;
pr.query = NULL;
cbm_parse_free(&pr);
cbm_lex_free(&lr);
return 0;
}
/* ══════════════════════════════════════════════════════════════════
* EXECUTOR
* ══════════════════════════════════════════════════════════════════ */
/* A binding: maps variable names to nodes and/or edges */
typedef struct {
const char *var_names[CYP_MAX_VARS]; /* variable names (nodes) */
cbm_node_t var_nodes[CYP_MAX_VARS]; /* node data */
int var_count;
const char *edge_var_names[CYP_MAX_EDGE_VARS]; /* variable names (edges) */
cbm_edge_t edge_vars[CYP_MAX_EDGE_VARS]; /* edge data */
int edge_var_count;
cbm_store_t *store; /* for computing in_degree/out_degree on demand */
} binding_t;
/* Return a string field from a node by property name. NULL-safe. */
static const char *node_string_field(const cbm_node_t *n, const char *prop) {
static const struct {
const char *key;
size_t offset;
} fields[] = {
{"name", offsetof(cbm_node_t, name)},
{"qualified_name", offsetof(cbm_node_t, qualified_name)},
/* Aliases: field-eval agents reach for the short names, and a miss
* used to return a silent empty column costing a round-trip. */
{"qn", offsetof(cbm_node_t, qualified_name)},
{"label", offsetof(cbm_node_t, label)},
{"file_path", offsetof(cbm_node_t, file_path)},
{"file", offsetof(cbm_node_t, file_path)},
{"path", offsetof(cbm_node_t, file_path)},
};
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
if (strcmp(prop, fields[i].key) == 0) {
const char *val = *(const char **)((const char *)n + fields[i].offset);
return val ? val : "";
}
}
return NULL;
}
/* Get node property by name.
* store may be NULL; only needed for virtual degree properties. */
static const char *json_extract_prop(const char *json, const char *key, char *buf, size_t buf_sz);
static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t *store) {
if (!n || !prop) {
return "";
}
const char *str = node_string_field(n, prop);
if (str && str[0]) {
return str;
}
/* Computed and JSON-derived values live in rotating thread-local buffers:
* a single row (or an ORDER-BY comparison) reads several of these before any
* of them is copied out, so returning one shared static buffer would alias
* every column to the last value read. Mirrors edge_prop's rotation. */
static _Thread_local char bufs[CYP_BUF_8][CBM_SZ_512];
static _Thread_local int buf_idx = 0;
char *out = bufs[buf_idx];
buf_idx = (buf_idx + SKIP_ONE) % CYP_BUF_8;
if (strcmp(prop, "start_line") == 0) {
snprintf(out, CBM_SZ_512, "%d", n->start_line);
return out;
}
if (strcmp(prop, "end_line") == 0) {
snprintf(out, CBM_SZ_512, "%d", n->end_line);
return out;
}
/* Virtual computed properties: in_degree/out_degree via CALLS edges.
* Enables Cypher dead-code detection: WHERE n.in_degree = '0'. */
if (store && (strcmp(prop, "in_degree") == 0 || strcmp(prop, "out_degree") == 0)) {
int in_deg = 0;
int out_deg = 0;
cbm_store_node_degree(store, n->id, &in_deg, &out_deg);
int val = (strcmp(prop, "in_degree") == 0) ? in_deg : out_deg;
snprintf(out, CBM_SZ_512, "%d", val);
return out;
}
/* Fall back to any value stored in the node's properties JSON — exposes the
* extraction metrics (complexity, cognitive, loop_count, loop_depth,
* transitive_loop_depth, recursive) and any other persisted property to
* WHERE/RETURN, e.g. WHERE n.loop_depth >= 2. */
if (n->properties_json && n->properties_json[0] == '{') {
const char *v = json_extract_prop(n->properties_json, prop, out, CBM_SZ_512);
if (v && v[0]) {
return v;
}
}
return "";
}
/* Extract a string value from JSON properties_json by key.
* Writes result to buf (up to buf_sz). Returns buf if found, "" otherwise.
* Handles both string values ("key":"value") and numeric values ("key":1.5). */
static const char *json_extract_prop(const char *json, const char *key, char *buf, size_t buf_sz) {
if (!json || !key) {
buf[0] = '\0';
return buf;
}
/* Build search pattern: "key": */
char pattern[CBM_SZ_256];
snprintf(pattern, sizeof(pattern), "\"%s\":", key);
const char *p = strstr(json, pattern);
if (!p) {
buf[0] = '\0';
return buf;
}
p += strlen(pattern);
/* Skip whitespace */
while (*p == ' ' || *p == '\t') {
p++;
}
if (*p == '"') {
/* String value — honor backslash escapes: without this, an embedded \"
* cuts the value short at the first escaped quote. */
p++;
size_t i = 0;
while (*p && *p != '"' && i < buf_sz - SKIP_ONE) {
if (*p == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* keep the escape pair intact */
}
buf[i++] = *p++;
}
buf[i] = '\0';
} else if (*p == '[' || *p == '{') {
/* Array/object value — copy the whole balanced construct. A scan-to-comma
* truncates at the first comma INSIDE the value: e.g. a decorators array
* ["@Roles('OWNER', 'ADMIN')","@Get()"] came back as ["@Roles('OWNER'. */
char open = *p;
char close = (open == '[') ? ']' : '}';
int depth = 0;
int in_str = 0;
size_t i = 0;
while (*p && i < buf_sz - SKIP_ONE) {
char c = *p;
if (in_str) {
if (c == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* escape pair stays intact */
} else if (c == '"') {
in_str = 0;
}
} else if (c == '"') {
in_str = 1;
} else if (c == open) {
depth++;
} else if (c == close) {
depth--;
}
buf[i++] = *p++;
if (!in_str && depth == 0) {
break; /* outer bracket closed */
}
}
buf[i] = '\0';
} else {
/* Numeric or other scalar value */
size_t i = 0;
while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) {
buf[i++] = *p++;
}
buf[i] = '\0';
}
return buf;
}
/* Get edge property by name. Uses rotating thread-local buffers to allow
* multiple concurrent calls (e.g. projecting r.url_path, r.confidence
* in the same row). */
static const char *edge_prop(const cbm_edge_t *e, const char *prop) {
if (!e || !prop) {
return "";
}
if (strcmp(prop, "type") == 0) {
return e->type ? e->type : "";
}
/* Rotate through 8 thread-local buffers so multiple props can be accessed per row. */
static CBM_TLS char ebufs[CYP_BUF_8][CBM_SZ_512];
static CBM_TLS int ebuf_idx = 0;
char *buf = ebufs[ebuf_idx++ & CYP_EBUF_MASK];
json_extract_prop(e->properties_json, prop, buf, CBM_SZ_512);
return buf;
}
/* Find an edge variable in a binding */
static cbm_edge_t *binding_get_edge(binding_t *b, const char *var) {
for (int i = 0; i < b->edge_var_count; i++) {
if (strcmp(b->edge_var_names[i], var) == 0) {
return &b->edge_vars[i];
}
}
return NULL;
}
/* Find a variable's node in a binding */
static cbm_node_t *binding_get(binding_t *b, const char *var) {
for (int i = 0; i < b->var_count; i++) {
if (strcmp(b->var_names[i], var) == 0) {
return &b->var_nodes[i];
}
}
return NULL;
}
/* Deep copy a node: heap-dup all string fields so the binding owns them */
static void node_deep_copy(cbm_node_t *dst, const cbm_node_t *src) {
*dst = *src;
dst->project = heap_strdup(src->project);
dst->label = heap_strdup(src->label);
dst->name = heap_strdup(src->name);
dst->qualified_name = heap_strdup(src->qualified_name);
dst->file_path = heap_strdup(src->file_path);
dst->properties_json = heap_strdup(src->properties_json);
}
static void node_fields_free(cbm_node_t *n) {
if (!n) {
return;
}
safe_str_free(&n->project);
safe_str_free(&n->label);
safe_str_free(&n->name);
safe_str_free(&n->qualified_name);
safe_str_free(&n->file_path);
safe_str_free(&n->properties_json);
}
/* Deep copy an edge (binding owns the strings) */
static void edge_deep_copy(cbm_edge_t *dst, const cbm_edge_t *src) {
*dst = *src;
dst->project = heap_strdup(src->project);
dst->type = heap_strdup(src->type);
dst->properties_json = heap_strdup(src->properties_json);
}
static void edge_fields_free(cbm_edge_t *e) {
safe_str_free(&e->project);
safe_str_free(&e->type);
safe_str_free(&e->properties_json);
}
/* Set an edge variable in a binding */
static void binding_set_edge(binding_t *b, const char *var, const cbm_edge_t *edge) {
/* Check existing — free old fields first */
for (int i = 0; i < b->edge_var_count; i++) {
if (strcmp(b->edge_var_names[i], var) == 0) {
edge_fields_free(&b->edge_vars[i]);
edge_deep_copy(&b->edge_vars[i], edge);
return;
}
}
if (b->edge_var_count >= CYP_MAX_EDGE_VARS) {
return;
}
b->edge_var_names[b->edge_var_count] = var; /* not owned — points to AST string */
edge_deep_copy(&b->edge_vars[b->edge_var_count], edge);
b->edge_var_count++;
}
/* Free all deep-copied nodes and edges in a binding */
static void binding_free(binding_t *b) {
for (int i = 0; i < b->var_count; i++) {
node_fields_free(&b->var_nodes[i]);
}
for (int i = 0; i < b->edge_var_count; i++) {
edge_fields_free(&b->edge_vars[i]);
}
}
/* Deep-copy a binding (so source and dest own separate string copies) */
static void binding_copy(binding_t *dst, const binding_t *src) {
dst->var_count = src->var_count;
for (int i = 0; i < src->var_count; i++) {
dst->var_names[i] = src->var_names[i]; /* AST-owned, not freed */
node_deep_copy(&dst->var_nodes[i], &src->var_nodes[i]);
}
dst->edge_var_count = src->edge_var_count;
for (int i = 0; i < src->edge_var_count; i++) {
dst->edge_var_names[i] = src->edge_var_names[i]; /* AST-owned */
edge_deep_copy(&dst->edge_vars[i], &src->edge_vars[i]);
}
dst->store = src->store;
}
/* Deep-copy a node into a binding (binding owns the strings) */
static void binding_set(binding_t *b, const char *var, const cbm_node_t *node) {
/* Check existing — free old fields first */
for (int i = 0; i < b->var_count; i++) {
if (strcmp(b->var_names[i], var) == 0) {
node_fields_free(&b->var_nodes[i]);
node_deep_copy(&b->var_nodes[i], node);
return;
}
}
if (b->var_count >= CYP_MAX_VARS) {
return;
}
b->var_names[b->var_count] = var; /* not owned — points to AST string */
node_deep_copy(&b->var_nodes[b->var_count], node);
b->var_count++;
}
static const char *eval_multiarg_func(binding_t *b, const cbm_return_item_t *item, char *buf,
size_t bufsz);
/* Resolve the actual property value for a condition from a binding */
static const char *resolve_condition_value(const cbm_condition_t *c, binding_t *b) {
/* Multi-arg scalar function LHS: coalesce(f.depth, 0) >= 2 (#874).
* Evaluated through the same code path as RETURN projections. The value is
* consumed by eval_condition before any other condition is resolved, so a
* single thread-local buffer per call is safe. */
if (c->func) {
static _Thread_local char func_buf[CBM_SZ_512];
cbm_return_item_t item = {0};
item.variable = c->variable;
item.property = c->property;
item.func = c->func;
item.args = c->args;
item.arg_count = c->arg_count;
return eval_multiarg_func(b, &item, func_buf, sizeof(func_buf));
}
cbm_edge_t *e = binding_get_edge(b, c->variable);
if (e) {
return edge_prop(e, c->property);
}
cbm_node_t *n = binding_get(b, c->variable);
if (!n) {
return NULL; /* unbound variable */
}
if (c->property) {
return node_prop(n, c->property, b->store);
}
/* Bare alias (e.g. post-WITH virtual var) — use node name directly */
return n->name ? n->name : "";
}
/* Set when an `=~` or inline-property pattern was refused by the regex
* wrapper's compile-size guard during the CURRENT execution; cbm_cypher_execute
* turns it into result->warning so an empty result can be told from "no such
* name". Reset at the start of every execution. */
static _Thread_local bool g_cypher_regex_refused = false;
/* Evaluate a comparison operator between actual and expected strings. */
static bool eval_comparison_op(const char *op, const char *actual, const char *expected) {
if (strcmp(op, "=") == 0) {
return strcmp(actual, expected) == 0;
}
if (strcmp(op, "<>") == 0) {
return strcmp(actual, expected) != 0;
}
if (strcmp(op, "=~") == 0) {
cbm_regex_t re;
int comp_rc = cbm_regcomp(&re, expected, CBM_REG_EXTENDED | CBM_REG_NOSUB);
if (comp_rc != 0) {
if (comp_rc == CBM_REG_ETOOBIG) {
g_cypher_regex_refused = true;
}
return false;
}
int rc = cbm_regexec(&re, actual, 0, NULL, 0);
cbm_regfree(&re);
return rc == 0;
}
if (strcmp(op, "CONTAINS") == 0) {
return strstr(actual, expected) != NULL;
}
if (strcmp(op, "STARTS WITH") == 0) {
return strncmp(actual, expected, strlen(expected)) == 0;
}
if (strcmp(op, "ENDS WITH") == 0) {
size_t alen = strlen(actual);
size_t elen = strlen(expected);
return alen >= elen && strcmp(actual + alen - elen, expected) == 0;
}
if (strcmp(op, ">") == 0 || strcmp(op, "<") == 0 || strcmp(op, ">=") == 0 ||
strcmp(op, "<=") == 0) {
double a = strtod(actual, NULL);
double exp_val = strtod(expected, NULL);
if (op[0] == '>' && op[CYP_CHAR_IDX1] == '=') {
return a >= exp_val;
}
if (op[0] == '<' && op[CYP_CHAR_IDX1] == '=') {
return a <= exp_val;
}
if (op[0] == '>') {
return a > exp_val;
}
return a < exp_val;
}
return false;
}
/* Evaluate a WHERE condition against a binding */
static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
/* Label test: WHERE n:Label (#241) — compare the bound node's label
* directly rather than a property value. */
if (strcmp(c->op, "HAS_LABEL") == 0) {
cbm_node_t *n = binding_get(b, c->variable);
bool result = n && n->label && c->value && strcmp(n->label, c->value) == 0;
return c->negated ? !result : result;
}
/* EXISTS { (var)-[:TYPE]->() }: does the bound node have any edge of the
* given type in the requested direction? (dir 0=out, 1=in, 2=any) */
if (strcmp(c->op, "EXISTS") == 0) {
cbm_node_t *n = binding_get(b, c->variable);
bool result = false;
if (n && b->store) {
cbm_edge_t *edges = NULL;
int cnt = 0;
if (c->exists_dir != 1) { /* outbound or any */
if (c->value) {
cbm_store_find_edges_by_source_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_source(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
if (!result && c->exists_dir != 0) { /* inbound or any */
edges = NULL;
cnt = 0;
if (c->value) {
cbm_store_find_edges_by_target_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_target(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
}
return c->negated ? !result : result;
}
const char *actual = resolve_condition_value(c, b);
/* coalesce(var.prop, literal) (#874): a missing/empty property value
* falls back to the literal default before the operator runs. */
if (c->coalesce_default && (!actual || actual[0] == '\0')) {
actual = c->coalesce_default;
}
if (!actual) {
return true;
}
bool result;
/* IS NULL / IS NOT NULL */
if (strcmp(c->op, "IS NULL") == 0) {
result = (actual[0] == '\0');
return c->negated ? !result : result;
}
if (strcmp(c->op, "IS NOT NULL") == 0) {
result = (actual[0] != '\0');
return c->negated ? !result : result;
}
/* IN [...] */
if (strcmp(c->op, "IN") == 0) {
result = false;
for (int i = 0; i < c->in_value_count; i++) {
if (strcmp(actual, c->in_values[i]) == 0) {
result = true;
break;
}
}
return c->negated ? !result : result;
}
result = eval_comparison_op(c->op, actual, c->value);
return c->negated ? !result : result;
}
/* Recursive expression tree evaluator */
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:
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;
}
/* Evaluate WHERE clause — uses expression tree if available, falls back to legacy */
static bool eval_where(const cbm_where_clause_t *w, binding_t *b) {
if (!w) {
return true;
}
if (w->root) {
return eval_expr(w->root, b);
}
/* Legacy flat evaluation */
if (w->count == 0) {
return true;
}
bool is_and = (w->op && strcmp(w->op, "AND") == 0) != 0;
for (int i = 0; i < w->count; i++) {
bool r = eval_condition(&w->conditions[i], b);
if (is_and && !r) {
return false;
}
if (!is_and && r) {
return true;
}
}
return is_and;
}
/* Three-valued WHERE evaluation for the early (seed-row) pass in
* execute_single: only the first MATCH alias is bound there, so a leaf on
* any other alias is UNKNOWN rather than true. The two-valued evaluator
* returns true for an unbound alias, which NOT / XOR then invert -- pruning
* every seed before relationship expansion (distilled from PR #1245). The
* full evaluation reruns after expansion (Step 3), so the early pass only has
* to prune on a definite false. Allocation-free, O(expression nodes). */
typedef enum { CYP_PARTIAL_FALSE = 0, CYP_PARTIAL_TRUE, CYP_PARTIAL_UNKNOWN } cyp_partial_t;
static cyp_partial_t partial_and(cyp_partial_t l, cyp_partial_t r) {
if (l == CYP_PARTIAL_FALSE || r == CYP_PARTIAL_FALSE) {
return CYP_PARTIAL_FALSE;
}
return (l == CYP_PARTIAL_TRUE && r == CYP_PARTIAL_TRUE) ? CYP_PARTIAL_TRUE
: CYP_PARTIAL_UNKNOWN;
}
static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) {
if (l == CYP_PARTIAL_TRUE || r == CYP_PARTIAL_TRUE) {
return CYP_PARTIAL_TRUE;
}
return (l == CYP_PARTIAL_FALSE && r == CYP_PARTIAL_FALSE) ? CYP_PARTIAL_FALSE
: CYP_PARTIAL_UNKNOWN;
}
/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS
* (func condition with no variable arg) has nothing to wait for. */
static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) {
if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) {
return CYP_PARTIAL_UNKNOWN;
}
return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
}
static cyp_partial_t eval_expr_partial(const cbm_expr_t *e, // NOLINT(misc-no-recursion)
binding_t *b) {
if (!e) {
return CYP_PARTIAL_TRUE;
}
if (e->type == EXPR_CONDITION) {
return eval_condition_partial(&e->cond, b);
}
cyp_partial_t left = eval_expr_partial(e->left, b);
if (e->type == EXPR_NOT) {
if (left == CYP_PARTIAL_UNKNOWN) {
return CYP_PARTIAL_UNKNOWN;
}
return left == CYP_PARTIAL_TRUE ? CYP_PARTIAL_FALSE : CYP_PARTIAL_TRUE;
}
/* Same short-circuits as eval_expr: a definite left decides AND / OR. */
if (e->type == EXPR_AND && left == CYP_PARTIAL_FALSE) {
return CYP_PARTIAL_FALSE;
}
if (e->type == EXPR_OR && left == CYP_PARTIAL_TRUE) {
return CYP_PARTIAL_TRUE;
}
cyp_partial_t right = eval_expr_partial(e->right, b);
if (e->type == EXPR_AND) {
return partial_and(left, right);
}
if (e->type == EXPR_OR) {
return partial_or(left, right);
}
/* EXPR_XOR */
if (left == CYP_PARTIAL_UNKNOWN || right == CYP_PARTIAL_UNKNOWN) {
return CYP_PARTIAL_UNKNOWN;
}
return left != right ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
}
static cyp_partial_t eval_where_partial(const cbm_where_clause_t *w, binding_t *b) {
if (!w) {
return CYP_PARTIAL_TRUE;
}
if (w->root) {
return eval_expr_partial(w->root, b);
}
/* Legacy flat evaluation */
if (w->count == 0) {
return CYP_PARTIAL_TRUE;
}
bool is_and = (w->op && strcmp(w->op, "AND") == 0) != 0;
cyp_partial_t result = is_and ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
for (int i = 0; i < w->count; i++) {
cyp_partial_t r = eval_condition_partial(&w->conditions[i], b);
result = is_and ? partial_and(result, r) : partial_or(result, r);
}
return result;
}
/* Check if a string value looks like a regex pattern. */
static bool looks_like_regex(const char *s) {
if (!s) {
return false;
}
return strstr(s, ".*") || strstr(s, ".+") || strchr(s, '[') || strchr(s, '(') ||
strchr(s, '|') || strchr(s, '^') || strchr(s, '$');
}
/* Check inline property filters.
* Values that look like regex patterns are matched with POSIX ERE;
* plain values use exact strcmp. */
static bool check_inline_props(const cbm_node_t *n, const cbm_prop_filter_t *props, int count,
cbm_store_t *store) {
for (int i = 0; i < count; i++) {
const char *actual = node_prop(n, props[i].key, store);
if (looks_like_regex(props[i].value)) {
cbm_regex_t re;
int comp_rc = cbm_regcomp(&re, props[i].value, CBM_REG_EXTENDED | CBM_REG_NOSUB);
if (comp_rc == 0) {
bool matched = cbm_regexec(&re, actual, 0, NULL, 0) == 0;
cbm_regfree(&re);
if (!matched) {
return false;
}
} else if (strcmp(actual, props[i].value) != 0) {
if (comp_rc == CBM_REG_ETOOBIG) {
g_cypher_regex_refused = true;
}
return false;
}
} else if (strcmp(actual, props[i].value) != 0) {
return false;
}
}
return true;
}
/* ── Result building helpers ────────────────────────────────────── */
typedef struct {
const char ***rows;
int row_count;
int row_cap;
const char **columns;
int col_count;
} result_builder_t;
static void rb_init(result_builder_t *rb) {
memset(rb, 0, sizeof(*rb));
rb->row_cap = CBM_SZ_32;
rb->rows = malloc(rb->row_cap * sizeof(const char **));
}
static void rb_set_columns(result_builder_t *rb, const char **cols, int count) {
rb->columns = malloc((count > 0 ? (size_t)count : SKIP_ONE) * sizeof(const char *));
for (int i = 0; i < count; i++) {
rb->columns[i] = heap_strdup(cols[i]);
}
rb->col_count = count;
}
static void rb_add_row(result_builder_t *rb, const char **values) {
if (rb->row_count >= rb->row_cap) {
rb->row_cap *= PAIR_LEN;
rb->rows = safe_realloc(rb->rows, rb->row_cap * sizeof(const char **));
}
const char **row =
malloc((rb->col_count > 0 ? (size_t)rb->col_count : SKIP_ONE) * sizeof(const char *));
for (int i = 0; i < rb->col_count; i++) {
row[i] = values[i] ? heap_strdup(values[i]) : heap_strdup("");
}
rb->rows[rb->row_count++] = row;
}
/* ── DISTINCT while projecting (#1364) ─────────────────────────────
*
* RETURN DISTINCT used to project up to the engine row cap and only then drop
* duplicates, so a value first seen past the cap never reached the result: on
* a 150k-edge index `RETURN DISTINCT type(r)` lost its one HAS_BRANCH edge.
* The projection loops now de-duplicate as they go, so the cap counts DISTINCT
* rows. Memory stays bounded by the cap: only admitted rows are remembered;
* rows past the cap are probed (no insert) solely to decide `truncated`. */
typedef struct {
CBMHashTable *seen; /* row key -> the same heap key; NULL = not DISTINCT */
} rb_distinct_t;
static void rb_distinct_init(rb_distinct_t *d, bool distinct) {
d->seen = distinct ? cbm_ht_create(CBM_SZ_64) : NULL;
}
static void rb_distinct_free_key(const char *key, void *value, void *userdata) {
(void)key;
(void)userdata;
cbm_free(CBM_MEM_CLASS_OTHER, value);
}
static void rb_distinct_free(rb_distinct_t *d) {
if (d->seen) {
cbm_ht_foreach(d->seen, rb_distinct_free_key, NULL);
cbm_ht_free(d->seen);
d->seen = NULL;
}
}
/* Length-prefixed row key ("<len>:<value>" per column), so no value content
* can make two different rows collide. NULL projects as "" (as rb_add_row). */
static char *rb_distinct_row_key(const char **vals, int n) {
size_t total = SKIP_ONE;
for (int i = 0; i < n; i++) {
total += (vals[i] ? strlen(vals[i]) : 0) + CBM_SZ_32;
}
char *key = cbm_alloc(CBM_MEM_CLASS_OTHER, total);
if (!key) {
return NULL;
}
size_t pos = 0;
key[0] = '\0';
for (int i = 0; i < n; i++) {
const char *v = vals[i] ? vals[i] : "";
int w = snprintf(key + pos, total - pos, "%zu:%s", strlen(v), v);
if (w > 0) {
pos += (size_t)w;
}
}
return key;
}
/* Admission for one projected row. Returns false for a row DISTINCT already
* holds. On true, *key_out is the key to hand to rb_distinct_keep (NULL when
* not DISTINCT, or when the key allocation failed — the row is then admitted
* without de-duplication rather than dropped). */
static bool rb_distinct_is_new(const rb_distinct_t *d, const char **vals, int n, char **key_out) {
*key_out = NULL;
if (!d->seen) {
return true;
}
char *key = rb_distinct_row_key(vals, n);
if (!key) {
return true;
}
if (cbm_ht_has(d->seen, key)) {
cbm_free(CBM_MEM_CLASS_OTHER, key);
return false;
}
*key_out = key;
return true;
}
static void rb_distinct_keep(rb_distinct_t *d, char *key) {
if (key) {
cbm_ht_set(d->seen, key, key);
}
}
/* Add one projected row under a row cap, de-duplicating when DISTINCT.
* Returns false once a NEW row arrives with the cap already full: the caller
* stops there, and flags truncation when the cap is the engine budget. A
* duplicate past the cap is neither kept nor evidence of truncation. */
static bool rb_add_row_capped(result_builder_t *rb, rb_distinct_t *d, const char **vals, int cap) {
char *key = NULL;
if (!rb_distinct_is_new(d, vals, rb->col_count, &key)) {
return true;
}
if (rb->row_count >= cap) {
cbm_free(CBM_MEM_CLASS_OTHER, key);
return false;
}
rb_distinct_keep(d, key);
rb_add_row(rb, vals);
return true;
}
/* ── Main execution ─────────────────────────────────────────────── */
/* Hard ceiling: queries returning more than this trigger an error instead of data.
* Prevents accidental multi-GB JSON payloads from unbounded MATCH (n) RETURN n. */
#define CYPHER_RESULT_CEILING 100000
/* Wall-clock execution deadline (#601). The row ceiling above only fires once
* rows exist, but an unbounded `OPTIONAL MATCH` over the full node set (or a
* GROUP BY with ~one group per node) does O(bindings x groups) work and can run
* for minutes — exhausting RAM/CPU — before a single row is produced, so the
* ceiling never trips and the caller just hangs. A monotonic deadline aborts
* such runaway queries with a clear, actionable error. Checked (throttled) in
* the scan, expansion and aggregation hot loops. */
#define CYPHER_DEADLINE_BUDGET_MS 30000 /* 30s: generous for legit heavy queries */
#define CYPHER_DEADLINE_CHECK_MASK 0x3FF /* sample the clock every 1024 iterations */
static _Thread_local uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */
static _Thread_local bool g_cypher_timed_out = false;
/* Sticky for one cbm_cypher_execute call. Unlike row_count, this survives caps
* that happen before DISTINCT, aggregation, ORDER BY, or the final projection. */
static _Thread_local bool g_cypher_truncated = false;
static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */
static void cypher_deadline_arm(void) {
g_cypher_timed_out = false;
int64_t budget = g_cypher_deadline_override_ms >= 0 ? g_cypher_deadline_override_ms
: CYPHER_DEADLINE_BUDGET_MS;
g_cypher_deadline_ms = cbm_now_ms() + (uint64_t)budget;
}
/* True once the query has run past its wall-clock budget. Sticky: after the
* first trip every subsequent call returns true, so later loops short-circuit. */
static bool cypher_deadline_exceeded(void) {
if (g_cypher_timed_out) {
return true;
}
if (g_cypher_deadline_ms == 0) {
return false;
}
if (cbm_now_ms() >= g_cypher_deadline_ms) {
g_cypher_timed_out = true;
return true;
}
return false;
}
/* Test-only: force the execution budget (ms) for subsequent queries on this
* thread. 0 = trip on the first hot-loop check; <0 restores the default. */
void cbm_cypher_test_set_deadline_ms(int64_t budget_ms) {
g_cypher_deadline_override_ms = budget_ms;
}
/* ── Binding virtual variables (for WITH clause) ──────────────── */
static const char *binding_get_virtual(binding_t *b, const char *var, const char *prop) {
if (!var) {
return "";
}
/* Check virtual vars first (from WITH projection) */
char full[CBM_SZ_256];
if (prop) {
snprintf(full, sizeof(full), "%s.%s", var, prop);
} else {
snprintf(full, sizeof(full), "%s", var);
}
for (int i = 0; i < b->var_count; i++) {
if (strcmp(b->var_names[i], full) == 0) {
return b->var_nodes[i].name ? b->var_nodes[i].name : "";
}
}
/* Fall through to normal lookup */
cbm_edge_t *e = binding_get_edge(b, var);
if (e) {
/* Bare `RETURN r` on an edge: surface the full properties JSON
* (or "{}" if none) so callers can inspect timestamps, weights,
* etc. without naming each property. */
return prop ? edge_prop(e, prop) : (e->properties_json ? e->properties_json : "{}");
}
cbm_node_t *n = binding_get(b, var);
if (n) {
if (prop) {
return node_prop(n, prop, b->store);
}
return n->name ? n->name : "";
}
return "";
}
/* ── String function application ──────────────────────────────── */
static const char *apply_string_func(const char *func, const char *val, char *buf, size_t buf_sz) {
if (!func || !val) {
return val ? val : "";
}
if (strcmp(func, "toLower") == 0) {
size_t i = 0;
for (; i < buf_sz - SKIP_ONE && val[i]; i++) {
buf[i] = (char)tolower((unsigned char)val[i]);
}
buf[i] = '\0';
return buf;
}
if (strcmp(func, "toUpper") == 0) {
size_t i = 0;
for (; i < buf_sz - SKIP_ONE && val[i]; i++) {
buf[i] = (char)toupper((unsigned char)val[i]);
}
buf[i] = '\0';
return buf;
}
if (strcmp(func, "toString") == 0) {
return val; /* already strings */
}
if (strcmp(func, "toInteger") == 0) {
char *end = NULL;
long long v = strtoll(val, &end, CBM_DECIMAL_BASE);
if (end == val) {
/* Not an integer literal — accept a float string and truncate. */
char *fend = NULL;
double d = strtod(val, &fend);
if (fend == val) {
return ""; /* non-numeric → null */
}
v = (long long)d;
}
snprintf(buf, buf_sz, "%lld", v);
return buf;
}
if (strcmp(func, "toFloat") == 0) {
char *end = NULL;
double d = strtod(val, &end);
if (end == val) {
return ""; /* non-numeric → null */
}
snprintf(buf, buf_sz, "%g", d);
return buf;
}
if (strcmp(func, "toBoolean") == 0) {
if (cyp_ci_eq(val, "true")) {
return "true";
}
if (cyp_ci_eq(val, "false")) {
return "false";
}
return ""; /* not a boolean → null */
}
if (strcmp(func, "size") == 0 || strcmp(func, "length") == 0) {
snprintf(buf, buf_sz, "%zu", strlen(val));
return buf;
}
if (strcmp(func, "trim") == 0 || strcmp(func, "ltrim") == 0 || strcmp(func, "rtrim") == 0) {
bool do_left = (strcmp(func, "trim") == 0 || strcmp(func, "ltrim") == 0);
bool do_right = (strcmp(func, "trim") == 0 || strcmp(func, "rtrim") == 0);
const char *start = val;
const char *end = val + strlen(val);
while (do_left && (*start == ' ' || *start == '\t' || *start == '\n' || *start == '\r')) {
start++;
}
while (do_right && end > start &&
(end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\n' || end[-1] == '\r')) {
end--;
}
size_t n = (size_t)(end - start);
if (n >= buf_sz) {
n = buf_sz - SKIP_ONE;
}
memcpy(buf, start, n);
buf[n] = '\0';
return buf;
}
if (strcmp(func, "reverse") == 0) {
size_t len = strlen(val);
if (len >= buf_sz) {
len = buf_sz - SKIP_ONE;
}
for (size_t i = 0; i < len; i++) {
buf[i] = val[len - SKIP_ONE - i];
}
buf[len] = '\0';
return buf;
}
return val;
}
/* ── CASE expression evaluation ───────────────────────────────── */
static const char *eval_case_expr(const cbm_case_expr_t *k, binding_t *b) {
if (!k) {
return "";
}
for (int i = 0; i < k->branch_count; i++) {
if (eval_expr(k->branches[i].when_expr, b)) {
return k->branches[i].then_val ? k->branches[i].then_val : "";
}
}
return k->else_val ? k->else_val : "";
}
/* ── Scan nodes for a pattern ─────────────────────────────────── */
/* True if `actual` matches `pat`, where `pat` may be a '|'-alternation of
* labels ("A|B|C") — openCypher label alternation (#242). */
static bool label_alt_matches(const char *actual, const char *pat) {
if (!pat) {
return true;
}
if (!actual) {
return false;
}
if (!strchr(pat, '|')) {
return strcmp(actual, pat) == 0;
}
size_t al = strlen(actual);
const char *seg = pat;
while (*seg) {
const char *bar = strchr(seg, '|');
size_t seglen = bar ? (size_t)(bar - seg) : strlen(seg);
if (seglen == al && strncmp(seg, actual, seglen) == 0) {
return true;
}
if (!bar) {
break;
}
seg = bar + SKIP_ONE;
}
return false;
}
/* Seed nodes for a label alternation "A|B|C": union the per-label results.
* Node-struct fields are moved (shallow) into out_nodes; each per-label array
* container is freed. */
static void scan_alternation_labels(cbm_store_t *store, const char *project, const char *labels,
cbm_node_t **out_nodes, int *out_count) {
*out_nodes = NULL;
*out_count = 0;
int cap = 0;
char *copy = heap_strdup(labels);
if (!copy) {
return;
}
char *save = NULL;
for (char *tok = strtok_r(copy, "|", &save); tok; tok = strtok_r(NULL, "|", &save)) {
cbm_node_t *part = NULL;
int pc = 0;
cbm_store_find_nodes_by_label(store, project, tok, &part, &pc);
if (pc > 0 && part) {
if (*out_count + pc > cap) {
cap = (*out_count + pc) * PAIR_LEN;
*out_nodes = safe_realloc(*out_nodes, (size_t)cap * sizeof(cbm_node_t));
}
memcpy(*out_nodes + *out_count, part, (size_t)pc * sizeof(cbm_node_t));
*out_count += pc;
}
free(part); /* container only — node fields moved to out_nodes */
}
free(copy);
}
static void scan_pattern_nodes(cbm_store_t *store, const char *project, cbm_node_pattern_t *first,
cbm_node_t **out_nodes, int *out_count) {
if (first->label && strchr(first->label, '|')) {
scan_alternation_labels(store, project, first->label, out_nodes, out_count);
} else if (first->label) {
cbm_store_find_nodes_by_label(store, project, first->label, out_nodes, out_count);
} else {
cbm_store_find_nodes(store, project, out_nodes, out_count);
}
/* Apply inline property filters — free rejected nodes' strings */
if (first->prop_count > 0) {
int kept = 0;
for (int i = 0; i < *out_count; i++) {
if (check_inline_props(&(*out_nodes)[i], first->props, first->prop_count, store)) {
if (kept != i) {
(*out_nodes)[kept] = (*out_nodes)[i];
}
kept++;
} else {
node_fields_free(&(*out_nodes)[i]);
}
}
*out_count = kept;
}
}
/* ── Expand one pattern's relationships on a set of bindings ──── */
/* Process edges: look up target node, filter by label/props, add binding.
* `inbound` controls which end of the edge is the target id. */
/* #1196 (second mechanism): a hop's output buffer must hold EVERY matched
* row. max_rows is an OUTPUT-row limit — projection already enforces it — and
* WHERE/aggregation run after expansion, so any cap here silently falsifies
* results: field-measured, count() reported 9,360 of 13,691 DEFINES because
* the old bind_cap*10 ceiling dropped edges before aggregation ever saw them,
* and a labeled source did not save you. The buffer now grows geometrically;
* only allocation failure stops materialisation (the #601 deadline still
* bounds time), and match_count stays truthful either way (#627 contract). */
static bool binding_out_append(binding_t **rows, int *count, int *cap, binding_t *nb) {
if (*count == *cap) {
int next = *cap > 0 ? *cap * CYP_GROWTH_2 : CYP_GROWTH_10;
binding_t *grown = realloc(*rows, (size_t)next * sizeof(binding_t));
if (!grown) {
binding_free(nb);
return false;
}
*rows = grown;
*cap = next;
}
(*rows)[(*count)++] = *nb;
return true;
}
static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count, bool inbound,
const cbm_node_pattern_t *target_node, binding_t *b, const char *to_var,
const char *rel_var, binding_t **new_bindings, int *new_count,
int *new_cap, int *match_count) {
/* When the terminal node variable is ALREADY bound (e.g. the second pattern
* `(c)-[:CALLS]->(f)` where `f` came from an earlier MATCH), we must FILTER
* to edges that actually reach the bound node — not overwrite the caller's
* `f` binding with whatever node the edge leads to. Overwriting corrupted
* the result of dead-code queries and produced wrong rows (#627). */
cbm_node_t *bound_to = binding_get(b, to_var);
int64_t bound_to_id = bound_to ? bound_to->id : 0;
/* The budget caps MATERIALISATION, not detection: `match_count` must stay
* truthful even after `new_count` hits `max_new`. Gating the loop itself
* (`ei < edge_count && *new_count < max_new`) stopped fetching entirely, so
* a saturated source reported `match_count == 0` despite having neighbours —
* and the OPTIONAL fallback in expand_pattern_rels then emitted an unbound
* "no match" row for it. `WHERE x IS NULL` reads those rows as "nothing
* points here", i.e. it reported live code as dead. Losing rows is
* recoverable; asserting a match does not exist when it does is not. */
for (int ei = 0; ei < edge_count; ei++) {
int64_t tid = inbound ? edges[ei].source_id : edges[ei].target_id;
if (bound_to && tid != bound_to_id) {
continue; /* edge does not reach the already-bound terminal node */
}
cbm_node_t found = {0};
if (cbm_store_find_node_by_id(store, tid, &found) != CBM_STORE_OK) {
continue;
}
if (target_node->label && !label_alt_matches(found.label, target_node->label)) {
node_fields_free(&found);
continue;
}
if (!check_inline_props(&found, target_node->props, target_node->prop_count, store)) {
node_fields_free(&found);
continue;
}
(*match_count)++; /* a real neighbour exists, OOM or not */
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &found);
if (rel_var) {
binding_set_edge(&nb, rel_var, &edges[ei]);
}
(void)binding_out_append(new_bindings, new_count, new_cap, &nb);
node_fields_free(&found);
}
}
/* Expand variable-length relationship via BFS */
/* Set when a variable-length hop range is clamped to the engine ceiling
* during the CURRENT execution; cbm_cypher_execute turns it into
* result->warning so callers can tell "clamped" from "no such path" (#797). */
/* C11 _Thread_local directly: cypher.c stays windows.h-free (compat.h pulls
* in windows.h, whose legacy `far` macro breaks this file's identifiers). */
static _Thread_local int g_cypher_depth_clamped = 0;
static _Thread_local int g_cypher_trail_truncated = 0;
static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
cbm_node_pattern_t *target_node, binding_t *b, cbm_node_t *src,
const char *to_var, binding_t **new_bindings, int *new_count,
int *new_cap, int *match_count) {
/* Clamp BOTH the explicit (`*1..N`) and unbounded (`*`, `*..m`) forms to the
* engine ceiling: an explicit N above the cap was previously honoured
* verbatim, driving cbm_store_bfs to an unbounded hop count (#887). WARN on
* clamp — never a silent truncation. */
int depth_cap = cbm_cypher_max_depth();
int max_depth = rel->max_hops > 0 ? rel->max_hops : depth_cap;
/* A range clamped to the engine ceiling (or an unbounded one) is probed one
* hop beyond it: a candidate out there means the clamp hid real rows, so the
* result is reported truncated; a clamp on a shallow graph stays a warning. */
bool probe_beyond_depth_cap = rel->max_hops <= 0 || max_depth > depth_cap;
if (max_depth > depth_cap) {
char req_buf[16];
char cap_buf[16];
snprintf(req_buf, sizeof(req_buf), "%d", max_depth);
snprintf(cap_buf, sizeof(cap_buf), "%d", depth_cap);
cbm_log_warn("cypher.depth_capped", "requested", req_buf, "cap", cap_buf);
g_cypher_depth_clamped = depth_cap; /* surfaced as result->warning (#797) */
max_depth = depth_cap;
}
cbm_traverse_result_t tr = {0};
const char *dir = rel->direction ? rel->direction : "outbound";
int traversal_depth = probe_beyond_depth_cap ? depth_cap + SKIP_ONE : max_depth;
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, traversal_depth,
CBM_PERCENT, &tr);
if (tr.truncated) {
g_cypher_trail_truncated = 1;
}
cbm_node_t *bound_to = binding_get(b, to_var);
int64_t bound_to_id = bound_to ? bound_to->id : 0;
/* Same contract as process_edges: the budget caps materialisation, never
* detection, so a saturated source cannot report match_count == 0 and get a
* fabricated OPTIONAL "no match" row. */
for (int v = 0; v < tr.visited_count; v++) {
cbm_node_hop_t *hop = &tr.visited[v];
if (hop->hop > max_depth) {
g_cypher_truncated = true; /* the probe hop found a candidate past the cap */
continue;
}
if (hop->hop < rel->min_hops) {
continue;
}
if (bound_to && hop->node.id != bound_to_id) {
continue;
}
if (target_node->label && !label_alt_matches(hop->node.label, target_node->label)) {
continue;
}
if (!check_inline_props(&hop->node, target_node->props, target_node->prop_count, store)) {
continue;
}
(*match_count)++;
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &hop->node);
(void)binding_out_append(new_bindings, new_count, new_cap, &nb);
}
cbm_store_traverse_free(&tr);
}
/* Expand fixed-length (1-hop) relationship edges */
static void expand_fixed_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
cbm_node_pattern_t *target_node, binding_t *b, cbm_node_t *src,
const char *to_var, binding_t **new_bindings, int *new_count,
int *new_cap, int *match_count) {
bool is_inbound = rel->direction && strcmp(rel->direction, "inbound") == 0;
bool is_any = rel->direction && strcmp(rel->direction, "any") == 0;
const char *rel_var = rel->variable;
if (rel->type_count > 0) {
for (int ti = 0; ti < rel->type_count; ti++) {
cbm_edge_t *edges = NULL;
int edge_count = 0;
if (is_inbound) {
cbm_store_find_edges_by_target_type(store, src->id, rel->types[ti], &edges,
&edge_count);
} else {
cbm_store_find_edges_by_source_type(store, src->id, rel->types[ti], &edges,
&edge_count);
}
process_edges(store, edges, edge_count, is_inbound, target_node, b, to_var, rel_var,
new_bindings, new_count, new_cap, match_count);
cbm_store_free_edges(edges, edge_count);
}
if (is_any) {
for (int ti = 0; ti < rel->type_count; ti++) {
cbm_edge_t *edges = NULL;
int edge_count = 0;
cbm_store_find_edges_by_target_type(store, src->id, rel->types[ti], &edges,
&edge_count);
process_edges(store, edges, edge_count, true, target_node, b, to_var, rel_var,
new_bindings, new_count, new_cap, match_count);
cbm_store_free_edges(edges, edge_count);
}
}
} else {
cbm_edge_t *edges = NULL;
int edge_count = 0;
if (is_inbound) {
cbm_store_find_edges_by_target(store, src->id, &edges, &edge_count);
} else {
cbm_store_find_edges_by_source(store, src->id, &edges, &edge_count);
}
process_edges(store, edges, edge_count, is_inbound, target_node, b, to_var, rel_var,
new_bindings, new_count, new_cap, match_count);
cbm_store_free_edges(edges, edge_count);
if (is_any) {
edges = NULL;
edge_count = 0;
cbm_store_find_edges_by_target(store, src->id, &edges, &edge_count);
process_edges(store, edges, edge_count, true, target_node, b, to_var, rel_var,
new_bindings, new_count, new_cap, match_count);
cbm_store_free_edges(edges, edge_count);
}
}
}
static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_t **bindings,
int *bind_count, int *bind_cap, const char **var_name,
bool is_optional) {
for (int ri = 0; ri < pat->rel_count; ri++) {
/* #601: stop expanding further hops once the wall-clock budget is spent
* (an unbounded expansion is exactly what blows up here). */
if (cypher_deadline_exceeded()) {
return;
}
cbm_rel_pattern_t *rel = &pat->rels[ri];
cbm_node_pattern_t *target_node = &pat->nodes[ri + SKIP_ONE];
const char *to_var = target_node->variable ? target_node->variable : "_n_t";
bool is_variable_length = (rel->min_hops != SKIP_ONE || rel->max_hops != SKIP_ONE);
/* #1196: the hop's output buffer GROWS to hold every matched row —
* the old bind_cap*10 ceiling silently dropped edges before WHERE and
* aggregation, falsifying counts. binding_out_append handles growth
* and the OPTIONAL fallback shares it, so no writer can run off the
* end and no row class is dropped (the old fixed sizing had exactly
* those two failure modes, CWE-787 and the OPTIONAL data loss). */
int new_cap = *bind_count > 0 ? *bind_count : CYP_GROWTH_10;
binding_t *new_bindings = malloc((size_t)new_cap * sizeof(binding_t));
if (!new_bindings) {
return; /* OOM: leave existing bindings untouched rather than corrupt */
}
int new_count = 0;
for (int bi = 0; bi < *bind_count; bi++) {
if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
break;
}
binding_t *b = &(*bindings)[bi];
cbm_node_t *src = binding_get(b, *var_name);
if (!src) {
continue;
}
int match_count = 0;
if (is_variable_length) {
expand_var_length(store, rel, target_node, b, src, to_var, &new_bindings,
&new_count, &new_cap, &match_count);
} else {
expand_fixed_length(store, rel, target_node, b, src, to_var, &new_bindings,
&new_count, &new_cap, &match_count);
}
/* OPTIONAL MATCH: no expansion for this source, so keep the binding
* with the target unbound (projection renders it ""). The shared
* growable append gives every fallback row a slot. */
if (is_optional && match_count == 0) {
binding_t nb = {0};
binding_copy(&nb, b);
/* Don't set to_var — it remains unbound; projection returns "" */
(void)binding_out_append(&new_bindings, &new_count, &new_cap, &nb);
}
}
for (int bi = 0; bi < *bind_count; bi++) {
binding_free(&(*bindings)[bi]);
}
free(*bindings);
*bindings = new_bindings;
*bind_count = new_count;
*bind_cap = new_cap;
*var_name = to_var;
}
}
/* ── Result postprocessing helpers ─────────────────────────────── */
/* Find the column index for one ORDER BY key, checking both column names and
* aliases. Returns -1 if not found. */
static int rb_find_order_column(const result_builder_t *rb, const cbm_return_clause_t *ret,
const char *key) {
for (int ci = 0; ci < rb->col_count; ci++) {
if (strcmp(rb->columns[ci], key) == 0) {
return ci;
}
}
for (int ci = 0; ci < ret->count; ci++) {
if (ret->items[ci].alias && strcmp(ret->items[ci].alias, key) == 0) {
return ci;
}
}
return CBM_NOT_FOUND;
}
/* Check whether a column contains numeric data by examining the first non-empty value */
static bool rb_is_numeric_column(const result_builder_t *rb, int col) {
for (int i = 0; i < rb->row_count; i++) {
const char *v = rb->rows[i][col];
if (v && *v) {
const char *p2 = (*v == '-') ? v + SKIP_ONE : v;
if (*p2 == '\0') {
return false;
}
for (; *p2; p2++) {
if (*p2 < '0' || *p2 > '9') {
return false;
}
}
return true;
}
}
return false;
}
static void rb_apply_order_by(result_builder_t *rb, const cbm_return_clause_t *ret) {
if (ret->order_key_count == 0) {
return;
}
/* Resolve every key up front; an unresolvable key is skipped, matching the
* single-key forgiveness (sorting on what can be resolved beats dropping
* the whole ORDER BY). */
int cols[CBM_CYPHER_ORDER_KEYS_MAX];
bool numeric[CBM_CYPHER_ORDER_KEYS_MAX];
bool descs[CBM_CYPHER_ORDER_KEYS_MAX];
int keys = 0;
for (int k = 0; k < ret->order_key_count; k++) {
int order_col = rb_find_order_column(rb, ret, ret->order_keys[k]);
if (order_col < 0) {
continue;
}
cols[keys] = order_col;
numeric[keys] = rb_is_numeric_column(rb, order_col);
descs[keys] = ret->order_descs[k];
keys++;
}
if (keys == 0) {
return;
}
for (int i = 0; i < rb->row_count - SKIP_ONE; i++) {
for (int j = 0; j < rb->row_count - i - SKIP_ONE; j++) {
int cmp = 0;
for (int k = 0; k < keys && cmp == 0; k++) {
if (numeric[k]) {
cmp = (int)strtol(rb->rows[j][cols[k]], NULL, CBM_DECIMAL_BASE) -
(int)strtol(rb->rows[j + SKIP_ONE][cols[k]], NULL, CBM_DECIMAL_BASE);
} else {
cmp = strcmp(rb->rows[j][cols[k]], rb->rows[j + SKIP_ONE][cols[k]]);
}
if (descs[k]) {
cmp = -cmp;
}
}
if (cmp > 0) {
const char **tmp = rb->rows[j];
rb->rows[j] = rb->rows[j + SKIP_ONE];
rb->rows[j + SKIP_ONE] = tmp;
}
}
}
}
static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit,
bool limit_is_engine_budget) {
/* Skip */
if (skip_n > 0 && skip_n < rb->row_count) {
for (int i = 0; i < skip_n; i++) {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
}
free(rb->rows[i]);
}
memmove(rb->rows, rb->rows + skip_n, (rb->row_count - skip_n) * sizeof(const char **));
rb->row_count -= skip_n;
} else if (skip_n >= rb->row_count) {
for (int i = 0; i < rb->row_count; i++) {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
}
free(rb->rows[i]);
}
rb->row_count = 0;
}
/* Limit */
if (limit >= 0 && rb->row_count > limit) {
if (limit_is_engine_budget) {
g_cypher_truncated = true;
}
for (int i = limit; i < rb->row_count; i++) {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
}
free(rb->rows[i]);
}
rb->row_count = limit;
}
}
static void rb_apply_distinct(result_builder_t *rb) {
if (rb->row_count <= SKIP_ONE) {
return;
}
int kept = SKIP_ONE;
for (int i = SKIP_ONE; i < rb->row_count; i++) {
bool dup = false;
for (int j = 0; j < kept && !dup; j++) {
bool same = true;
for (int c = 0; c < rb->col_count && same; c++) {
if (strcmp(rb->rows[i][c], rb->rows[j][c]) != 0) {
same = false;
}
}
if (same) {
dup = true;
}
}
if (!dup) {
if (kept != i) {
rb->rows[kept] = rb->rows[i];
}
kept++;
} else {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
}
free(rb->rows[i]);
}
}
rb->row_count = kept;
}
static void rb_free(result_builder_t *rb) {
for (int i = 0; i < rb->row_count; i++) {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
}
free(rb->rows[i]);
}
free(rb->rows);
for (int i = 0; i < rb->col_count; i++) {
safe_str_free(&rb->columns[i]);
}
free(rb->columns);
}
/* ── Get projection value for a binding + return item ─────────── */
/* Build a JSON list of a node's non-null property keys: keys(n). */
static const char *node_keys_list(const cbm_node_t *n, char *buf, size_t buf_sz) {
const struct {
const char *k;
bool present;
} ks[] = {
{"name", n->name && n->name[0]},
{"qualified_name", n->qualified_name && n->qualified_name[0]},
{"label", n->label && n->label[0]},
{"file_path", n->file_path && n->file_path[0]},
{"start_line", n->start_line > 0},
{"end_line", n->end_line > 0},
};
size_t pos = 0;
bool first = true;
if (pos < buf_sz - SKIP_ONE) {
buf[pos++] = '[';
}
for (size_t i = 0; i < sizeof(ks) / sizeof(ks[0]) && pos < buf_sz - SKIP_ONE; i++) {
if (!ks[i].present) {
continue;
}
int w = snprintf(buf + pos, buf_sz - pos, "%s\"%s\"", first ? "" : ",", ks[i].k);
if (w < 0 || (size_t)w >= buf_sz - pos) {
break;
}
pos += (size_t)w;
first = false;
}
if (pos < buf_sz - SKIP_ONE) {
buf[pos++] = ']';
}
buf[pos] = '\0';
return buf;
}
/* Resolve a function argument to its string value (literal or var.prop). */
static const char *eval_func_arg(binding_t *b, const cbm_func_arg_t *a) {
if (a->literal) {
return a->literal;
}
return binding_get_virtual(b, a->variable, a->property);
}
/* Evaluate a multi-argument scalar function into func_buf (or a direct value). */
static const char *eval_multiarg_func(binding_t *b, const cbm_return_item_t *item, char *buf,
size_t bufsz) {
const char *f = item->func;
int n = item->arg_count;
if (strcmp(f, "coalesce") == 0) {
for (int i = 0; i < n; i++) {
const char *v = eval_func_arg(b, &item->args[i]);
if (v && v[0]) {
return v;
}
}
return "";
}
if (strcmp(f, "substring") == 0 && n >= 2) {
const char *s = eval_func_arg(b, &item->args[0]);
long start = strtol(eval_func_arg(b, &item->args[1]), NULL, CBM_DECIMAL_BASE);
size_t slen = strlen(s);
if (start < 0 || (size_t)start >= slen) {
return "";
}
size_t take = slen - (size_t)start;
if (n >= 3) {
long len = strtol(eval_func_arg(b, &item->args[2]), NULL, CBM_DECIMAL_BASE);
if (len < 0) {
len = 0;
}
if ((size_t)len < take) {
take = (size_t)len;
}
}
if (take >= bufsz) {
take = bufsz - SKIP_ONE;
}
memcpy(buf, s + start, take);
buf[take] = '\0';
return buf;
}
if ((strcmp(f, "left") == 0 || strcmp(f, "right") == 0) && n >= 2) {
const char *s = eval_func_arg(b, &item->args[0]);
long k = strtol(eval_func_arg(b, &item->args[1]), NULL, CBM_DECIMAL_BASE);
if (k < 0) {
k = 0;
}
size_t slen = strlen(s);
size_t take = (size_t)k < slen ? (size_t)k : slen;
if (take >= bufsz) {
take = bufsz - SKIP_ONE;
}
memcpy(buf, (strcmp(f, "left") == 0) ? s : s + (slen - take), take);
buf[take] = '\0';
return buf;
}
if (strcmp(f, "replace") == 0 && n >= 3) {
const char *s = eval_func_arg(b, &item->args[0]);
const char *from = eval_func_arg(b, &item->args[1]);
const char *to = eval_func_arg(b, &item->args[2]);
size_t fromlen = strlen(from);
size_t tolen = strlen(to);
size_t pos = 0;
const char *pp = s;
if (fromlen == 0) {
snprintf(buf, bufsz, "%s", s);
return buf;
}
while (*pp && pos < bufsz - SKIP_ONE) {
if (strncmp(pp, from, fromlen) == 0) {
size_t cpy = tolen;
if (pos + cpy >= bufsz) {
cpy = bufsz - SKIP_ONE - pos;
}
memcpy(buf + pos, to, cpy);
pos += cpy;
pp += fromlen;
} else {
buf[pos++] = *pp++;
}
}
buf[pos] = '\0';
return buf;
}
return ""; /* wrong arity → null */
}
static const char *project_item(binding_t *b, cbm_return_item_t *item, char *func_buf,
size_t buf_sz) {
if (item->kase) {
return eval_case_expr(item->kase, b);
}
if (item->args) {
return eval_multiarg_func(b, item, func_buf, buf_sz);
}
/* Entity-introspection functions operate on the bound node/edge itself,
* not on a scalar property value. */
if (item->func) {
if (strcmp(item->func, "labels") == 0) {
cbm_node_t *n = binding_get(b, item->variable);
if (n && n->label) {
snprintf(func_buf, buf_sz, "[\"%s\"]", n->label);
return func_buf;
}
return "[]";
}
if (strcmp(item->func, "type") == 0) {
cbm_edge_t *e = binding_get_edge(b, item->variable);
return (e && e->type) ? e->type : "";
}
if (strcmp(item->func, "id") == 0) {
cbm_node_t *n = binding_get(b, item->variable);
if (n) {
snprintf(func_buf, buf_sz, "%lld", (long long)n->id);
return func_buf;
}
cbm_edge_t *e = binding_get_edge(b, item->variable);
if (e) {
snprintf(func_buf, buf_sz, "%lld", (long long)e->id);
return func_buf;
}
return "";
}
if (strcmp(item->func, "keys") == 0) {
cbm_node_t *n = binding_get(b, item->variable);
return n ? node_keys_list(n, func_buf, buf_sz) : "[]";
}
if (strcmp(item->func, "properties") == 0) {
cbm_node_t *n = binding_get(b, item->variable);
if (n) {
return n->properties_json ? n->properties_json : "{}";
}
cbm_edge_t *e = binding_get_edge(b, item->variable);
if (e) {
return e->properties_json ? e->properties_json : "{}";
}
return "{}";
}
}
const char *raw = binding_get_virtual(b, item->variable, item->property);
if (is_scalar_value_func(item->func)) {
return apply_string_func(item->func, raw, func_buf, buf_sz);
}
/* Direct node/edge fields live in the binding until this row has been
* copied by rb_add_row. Keep those allocation-backed values intact: the
* fixed per-column scratch buffer exists only to stabilize computed and
* JSON-derived rotating-buffer values. Copying every value through it
* silently clipped long qualified names and paths to 511 bytes before the
* MCP response budget or prefix directory ever saw them. */
for (int i = 0; raw && raw[0] && i < b->var_count; i++) {
const cbm_node_t *node = &b->var_nodes[i];
if (raw == node->project || raw == node->label || raw == node->name ||
raw == node->qualified_name || raw == node->file_path || raw == node->properties_json) {
return raw;
}
}
for (int i = 0; raw && raw[0] && i < b->edge_var_count; i++) {
const cbm_edge_t *edge = &b->edge_vars[i];
if (raw == edge->project || raw == edge->type || raw == edge->properties_json) {
return raw;
}
}
/* Copy into the caller's per-column buffer. `raw` may point to node_prop's
* rotating scratch buffer, which the next column's projection would overwrite
* before rb_add_row copies the assembled row — aliasing every such column to
* the last value read. The per-column func_buf gives each column stable storage. */
if (raw && raw != func_buf && raw[0]) {
size_t len = strlen(raw);
if (len >= buf_sz) {
len = buf_sz - SKIP_ONE;
}
memcpy(func_buf, raw, len);
func_buf[len] = '\0';
return func_buf;
}
return raw ? raw : "";
}
/* Check if a function name is an aggregate */
static bool is_aggregate_func(const char *func) {
return func &&
(strcmp(func, "COUNT") == 0 || strcmp(func, "SUM") == 0 || strcmp(func, "AVG") == 0 ||
strcmp(func, "MIN") == 0 || strcmp(func, "MAX") == 0 || strcmp(func, "COLLECT") == 0);
}
/* Append `val` to a string list only if not already present — i.e. maintain a
* set of distinct values. Used by COUNT(DISTINCT x) (#239). */
static void distinct_list_add(char ***list, int *count, const char *val) {
for (int i = 0; i < *count; i++) {
if (strcmp((*list)[i], val) == 0) {
return;
}
}
int idx = (*count)++;
*list = safe_realloc(*list, (size_t)(idx + SKIP_ONE) * sizeof(char *));
(*list)[idx] = heap_strdup(val);
}
/* Resolve one WITH ORDER BY key on a projected binding. The key is either a
* projected name (`c`, or an unaliased `f.name`) or a property of a carried
* node (`g.start_line` after `WITH f AS g`, #2208): an exact projected name
* wins, otherwise `var.prop` is split and resolved on the carried node. */
static const char *order_key_value(binding_t *b, const char *key) {
const char *dot = strchr(key, '.');
if (!dot) {
return binding_get_virtual(b, key, NULL);
}
for (int i = 0; i < b->var_count; i++) {
if (strcmp(b->var_names[i], key) == 0) {
return binding_get_virtual(b, key, NULL);
}
}
char var[CBM_SZ_256];
size_t vlen = (size_t)(dot - key);
if (vlen >= sizeof(var)) {
return "";
}
memcpy(var, key, vlen);
var[vlen] = '\0';
return binding_get_virtual(b, var, dot + SKIP_ONE);
}
/* Sort bindings by the ORDER BY key list (virtual variables) using bubble
* sort; later keys break ties, direction is per key (#1334). */
static void sort_bindings(binding_t *vbindings, int count, const cbm_return_clause_t *wc) {
for (int i = 0; i < count - SKIP_ONE; i++) {
for (int j = 0; j < count - i - SKIP_ONE; j++) {
int cmp = 0;
for (int k = 0; k < wc->order_key_count && cmp == 0; k++) {
const char *va = order_key_value(&vbindings[j], wc->order_keys[k]);
const char *vb2 = order_key_value(&vbindings[j + SKIP_ONE], wc->order_keys[k]);
char *ea = NULL;
char *eb = NULL;
double da = strtod(va, &ea);
double db = strtod(vb2, &eb);
cmp = (ea != va && eb != vb2) ? ((da > db) - (da < db)) : strcmp(va, vb2);
if (wc->order_descs[k]) {
cmp = -cmp;
}
}
if (cmp > 0) {
binding_t tmp = vbindings[j];
vbindings[j] = vbindings[j + SKIP_ONE];
vbindings[j + SKIP_ONE] = tmp;
}
}
}
}
/* Apply skip and limit to a binding array, freeing discarded entries */
static void bindings_skip_limit(binding_t *vbindings, int *count, int skip, int limit) {
if (skip > 0 && skip < *count) {
for (int i = 0; i < skip; i++) {
binding_free(&vbindings[i]);
}
memmove(vbindings, vbindings + skip, (*count - skip) * sizeof(binding_t));
*count -= skip;
} else if (skip >= *count) {
for (int i = 0; i < *count; i++) {
binding_free(&vbindings[i]);
}
*count = 0;
}
if (limit >= 0 && *count > limit) {
for (int i = limit; i < *count; i++) {
binding_free(&vbindings[i]);
}
*count = limit;
}
}
/* Sort, skip, and limit binding array in-place */
static void with_sort_skip_limit(const cbm_return_clause_t *wc, binding_t *vbindings, int *vcount) {
if (wc->order_key_count > 0) {
sort_bindings(vbindings, *vcount, wc);
}
bindings_skip_limit(vbindings, vcount, wc->skip, wc->limit);
}
/* Resolve the alias or compute a default name for a WITH/RETURN item */
static const char *resolve_item_alias(const cbm_return_item_t *item, char *name_buf,
size_t buf_sz) {
if (item->alias) {
return item->alias;
}
if (item->property) {
snprintf(name_buf, buf_sz, "%s.%s", item->variable, item->property);
} else {
snprintf(name_buf, buf_sz, "%s", item->variable);
}
return name_buf;
}
/* ── WITH clause: project bindings through aggregation or rename ── */
/* WITH aggregation group entry */
typedef struct {
char group_key[CBM_SZ_1K];
const char **group_vals;
double *sums;
int *counts;
double *mins, *maxs;
char ***distinct_lists; /* per-item set of seen values for COUNT(DISTINCT) */
int *distinct_n; /* per-item distinct count (#239) */
cbm_node_t *group_nodes; /* per-item carried node (deep copy; id 0 = not a node) */
} with_agg_t;
/* A WITH item that is a bare, bound node variable (`WITH f`, `WITH f AS g`)
* carries the node itself, not a scalar: return that node, else NULL. Property
* items, functions (labels/id/keys/...) and unbound OPTIONAL vars (id 0) stay
* scalar. The carried node keeps every field, so a later g.<prop> resolves
* exactly as it would without the WITH (#2208). */
static const cbm_node_t *with_item_carried_node(const cbm_return_item_t *item, binding_t *b) {
if (item->func || item->property || !item->variable) {
return NULL;
}
const cbm_node_t *n = binding_get(b, item->variable);
return (n && n->id > 0) ? n : NULL;
}
/* Name a carried node is bound under: the AST-owned alias or variable, which
* outlives every binding of the query (binding_set does not copy the name). */
static const char *with_item_node_alias(const cbm_return_item_t *item) {
return item->alias ? item->alias : item->variable;
}
/* Build a group key from non-aggregate WITH items */
static int with_agg_build_key(cbm_return_clause_t *wc, binding_t *b, char *key, size_t key_sz) {
int kl = 0;
for (int ci = 0; ci < wc->count; ci++) {
if (is_aggregate_func(wc->items[ci].func)) {
continue;
}
char vbuf[CBM_SZ_512];
const cbm_node_t *gn = with_item_carried_node(&wc->items[ci], b);
if (gn) {
/* Group a node by identity, never by its (non-unique) name. */
snprintf(vbuf, sizeof(vbuf), "#%lld", (long long)gn->id);
}
const char *v = gn ? vbuf : project_item(b, &wc->items[ci], vbuf, sizeof(vbuf));
kl += snprintf(key + kl, key_sz - (size_t)kl, "%s|", v);
if (kl >= (int)key_sz) {
kl = (int)key_sz - SKIP_ONE;
}
}
return kl;
}
/* Find or create an aggregation group. Returns index. */
static int with_agg_find_or_create(with_agg_t **aggs, int *agg_cnt, int *agg_cap,
cbm_return_clause_t *wc, binding_t *b, const char *key) {
for (int a = 0; a < *agg_cnt; a++) {
if (strcmp((*aggs)[a].group_key, key) == 0) {
return a;
}
}
if (*agg_cnt >= *agg_cap) {
*agg_cap *= PAIR_LEN;
*aggs = safe_realloc(*aggs, *agg_cap * sizeof(with_agg_t));
}
int found = (*agg_cnt)++;
snprintf((*aggs)[found].group_key, sizeof((*aggs)[found].group_key), "%s", key);
(*aggs)[found].group_vals = calloc(wc->count, sizeof(const char *));
(*aggs)[found].sums = calloc(wc->count, sizeof(double));
(*aggs)[found].counts = calloc(wc->count, sizeof(int));
(*aggs)[found].mins = calloc(wc->count, sizeof(double));
(*aggs)[found].maxs = calloc(wc->count, sizeof(double));
(*aggs)[found].distinct_lists = calloc(wc->count, sizeof(char **));
(*aggs)[found].distinct_n = calloc(wc->count, sizeof(int));
(*aggs)[found].group_nodes = calloc(wc->count, sizeof(cbm_node_t));
for (int ci = 0; ci < wc->count; ci++) {
(*aggs)[found].mins[ci] = CYP_DBL_MAX;
(*aggs)[found].maxs[ci] = -CYP_DBL_MAX;
}
for (int ci = 0; ci < wc->count; ci++) {
if (is_aggregate_func(wc->items[ci].func)) {
(*aggs)[found].group_vals[ci] = heap_strdup("0");
continue;
}
char vbuf[CBM_SZ_512];
const char *v = project_item(b, &wc->items[ci], vbuf, sizeof(vbuf));
(*aggs)[found].group_vals[ci] = heap_strdup(v);
const cbm_node_t *gn = with_item_carried_node(&wc->items[ci], b);
if (gn) {
node_deep_copy(&(*aggs)[found].group_nodes[ci], gn);
}
}
return found;
}
/* Accumulate aggregation values for a binding */
static void with_agg_accumulate(with_agg_t *agg, cbm_return_clause_t *wc, binding_t *b) {
for (int ci = 0; ci < wc->count; ci++) {
if (!is_aggregate_func(wc->items[ci].func)) {
continue;
}
agg->counts[ci]++;
const char *raw = binding_get_virtual(b, wc->items[ci].variable, wc->items[ci].property);
if (wc->items[ci].distinct && strcmp(wc->items[ci].func, "COUNT") == 0) {
distinct_list_add(&agg->distinct_lists[ci], &agg->distinct_n[ci], raw);
}
double dv = strtod(raw, NULL);
agg->sums[ci] += dv;
if (dv < agg->mins[ci]) {
agg->mins[ci] = dv;
}
if (dv > agg->maxs[ci]) {
agg->maxs[ci] = dv;
}
}
}
/* Format a WITH aggregation value into buf */
static void with_agg_format(const char *func, with_agg_t *agg, int ci, char *buf, size_t buf_sz) {
if (strcmp(func, "SUM") == 0) {
snprintf(buf, buf_sz, "%.10g", agg->sums[ci]);
} else if (strcmp(func, "AVG") == 0) {
snprintf(buf, buf_sz, "%.10g", agg->counts[ci] > 0 ? agg->sums[ci] / agg->counts[ci] : 0.0);
} else if (strcmp(func, "MIN") == 0) {
snprintf(buf, buf_sz, "%.10g", agg->mins[ci]);
} else if (strcmp(func, "MAX") == 0) {
snprintf(buf, buf_sz, "%.10g", agg->maxs[ci]);
} else {
snprintf(buf, buf_sz, "%d", agg->counts[ci]);
}
}
/* Add a scalar virtual variable binding for one WITH item. The value lives in
* .name; the owned alias string (var_names points at it) lives in .project,
* which no property accessor exposes. It used to live in .qualified_name, so
* alias.qualified_name / keys(alias) returned the variable name as data (#2208). */
static void with_add_vbinding_var(binding_t *vb, const char *alias, const char *val) {
cbm_node_t vn = {.name = heap_strdup(val), .project = heap_strdup(alias)};
if (vb->var_count < CYP_BUF_16) {
vb->var_names[vb->var_count] = vn.project;
vb->var_nodes[vb->var_count] = vn;
vb->var_count++;
}
}
/* Free with_agg_t array */
static void with_agg_free(with_agg_t *aggs, int agg_cnt, int item_count) {
for (int a = 0; a < agg_cnt; a++) {
for (int ci = 0; ci < item_count; ci++) {
safe_str_free(&aggs[a].group_vals[ci]);
if (aggs[a].distinct_lists && aggs[a].distinct_lists[ci]) {
for (int j = 0; j < aggs[a].distinct_n[ci]; j++) {
free(aggs[a].distinct_lists[ci][j]);
}
free(aggs[a].distinct_lists[ci]);
}
}
free(aggs[a].group_vals);
free(aggs[a].sums);
free(aggs[a].counts);
free(aggs[a].mins);
free(aggs[a].maxs);
free(aggs[a].distinct_lists);
free(aggs[a].distinct_n);
if (aggs[a].group_nodes) {
for (int ci = 0; ci < item_count; ci++) {
node_fields_free(&aggs[a].group_nodes[ci]);
}
}
free(aggs[a].group_nodes);
}
free(aggs);
}
/* Execute WITH aggregation path */
static void execute_with_aggregate(cbm_return_clause_t *wc, binding_t *bindings, int bind_count,
binding_t **vbindings, int *vcount) {
int agg_cap = CBM_SZ_256;
with_agg_t *aggs = calloc(agg_cap, sizeof(with_agg_t));
int agg_cnt = 0;
for (int bi = 0; bi < bind_count; bi++) {
char key[CBM_SZ_1K] = "";
with_agg_build_key(wc, &bindings[bi], key, sizeof(key));
int found = with_agg_find_or_create(&aggs, &agg_cnt, &agg_cap, wc, &bindings[bi], key);
with_agg_accumulate(&aggs[found], wc, &bindings[bi]);
}
*vbindings = safe_realloc(*vbindings, (agg_cnt + SKIP_ONE) * sizeof(binding_t));
if (!*vbindings) {
with_agg_free(aggs, agg_cnt, wc->count);
return;
}
for (int a = 0; a < agg_cnt; a++) {
binding_t vb = {0};
/* Carry the store so node_prop can re-fetch a carried node's properties
* (and compute in_degree/out_degree) on the projected virtual binding. */
vb.store = (bind_count > 0) ? bindings[0].store : NULL;
for (int ci = 0; ci < wc->count; ci++) {
char name_buf[CBM_SZ_256];
const char *alias = resolve_item_alias(&wc->items[ci], name_buf, sizeof(name_buf));
if (is_aggregate_func(wc->items[ci].func)) {
char vbuf[CBM_SZ_64];
if (wc->items[ci].distinct && strcmp(wc->items[ci].func, "COUNT") == 0) {
snprintf(vbuf, sizeof(vbuf), "%d", aggs[a].distinct_n[ci]); /* #239 */
} else {
with_agg_format(wc->items[ci].func, &aggs[a], ci, vbuf, sizeof(vbuf));
}
with_add_vbinding_var(&vb, alias, vbuf);
} else if (aggs[a].group_nodes[ci].id > 0) {
binding_set(&vb, with_item_node_alias(&wc->items[ci]), &aggs[a].group_nodes[ci]);
} else {
with_add_vbinding_var(&vb, alias, aggs[a].group_vals[ci]);
}
}
(*vbindings)[(*vcount)++] = vb;
}
with_agg_free(aggs, agg_cnt, wc->count);
}
/* Execute WITH simple (non-aggregate) projection */
static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, int bind_count,
binding_t *vbindings, int *vcount) {
for (int bi = 0; bi < bind_count; bi++) {
binding_t vb = {0};
vb.store = bindings[bi].store; /* so node_prop can re-fetch / compute on the projection */
for (int ci = 0; ci < wc->count; ci++) {
char name_buf[CBM_SZ_256];
const char *alias = resolve_item_alias(&wc->items[ci], name_buf, sizeof(name_buf));
const cbm_node_t *carried = with_item_carried_node(&wc->items[ci], &bindings[bi]);
if (carried) {
binding_set(&vb, with_item_node_alias(&wc->items[ci]), carried);
continue;
}
char func_buf[CBM_SZ_512];
const char *val =
project_item(&bindings[bi], &wc->items[ci], func_buf, sizeof(func_buf));
with_add_vbinding_var(&vb, alias, val);
}
vbindings[(*vcount)++] = vb;
}
}
/* Apply post-WITH WHERE filter */
static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings,
int *vcount) {
int kept = 0;
for (int i = 0; i < *vcount; i++) {
if (eval_where(where, &vbindings[i])) {
if (kept != i) {
vbindings[kept] = vbindings[i];
}
kept++;
} else {
binding_free(&vbindings[i]);
}
}
*vcount = kept;
}
/* Build a key from a projected vbinding's value tuple (all WITH output items),
* used to detect duplicate rows for WITH DISTINCT (#238). */
static void with_proj_key(cbm_return_clause_t *wc, binding_t *b, char *key, size_t key_sz) {
int kl = 0;
key[0] = '\0';
char name_buf[CBM_SZ_256];
for (int ci = 0; ci < wc->count; ci++) {
const char *alias = resolve_item_alias(&wc->items[ci], name_buf, sizeof(name_buf));
const char *v = binding_get_virtual(b, alias, NULL);
int w = snprintf(key + kl, (kl < (int)key_sz) ? key_sz - (size_t)kl : 0, "%s|", v ? v : "");
if (w > 0) {
kl += w;
}
if (kl >= (int)key_sz) {
break; /* buffer full */
}
}
}
/* Apply WITH DISTINCT: drop projected rows whose value tuple duplicates an
* earlier one, keeping first occurrence (#238 — previously silently ignored). */
static void with_apply_distinct(cbm_return_clause_t *wc, binding_t *vbindings, int *vcount) {
int kept = 0;
for (int i = 0; i < *vcount; i++) {
char key[CBM_SZ_1K];
with_proj_key(wc, &vbindings[i], key, sizeof(key));
bool dup = false;
for (int j = 0; j < kept; j++) {
char pkey[CBM_SZ_1K];
with_proj_key(wc, &vbindings[j], pkey, sizeof(pkey));
if (strcmp(key, pkey) == 0) {
dup = true;
break;
}
}
if (dup) {
binding_free(&vbindings[i]);
} else {
if (kept != i) {
vbindings[kept] = vbindings[i];
}
kept++;
}
}
*vcount = kept;
}
static void execute_with_clause(cbm_query_t *q, binding_t **bindings_ptr, int *bind_count_ptr) {
cbm_return_clause_t *wc = q->with_clause;
if (!wc) {
return;
}
binding_t *bindings = *bindings_ptr;
int bind_count = *bind_count_ptr;
binding_t *vbindings = malloc((bind_count + SKIP_ONE) * sizeof(binding_t));
int vcount = 0;
bool has_agg = false;
for (int i = 0; i < wc->count; i++) {
if (is_aggregate_func(wc->items[i].func)) {
has_agg = true;
break;
}
}
if (has_agg) {
execute_with_aggregate(wc, bindings, bind_count, &vbindings, &vcount);
} else {
execute_with_simple(wc, bindings, bind_count, vbindings, &vcount);
}
/* WITH DISTINCT: dedup projected rows (no-op for aggregation, which already
* collapses to one row per group). */
if (wc->distinct) {
with_apply_distinct(wc, vbindings, &vcount);
}
with_sort_skip_limit(wc, vbindings, &vcount);
for (int bi = 0; bi < bind_count; bi++) {
binding_free(&bindings[bi]);
}
free(bindings);
if (q->post_with_where) {
filter_bindings_where(q->post_with_where, vbindings, &vcount);
}
*bindings_ptr = vbindings;
*bind_count_ptr = vcount;
}
/* ── Execute a single query (no UNION recursion) ──────────────── */
/* Project RETURN * — all bound variable properties */
/* Collect all variable names from query patterns */
/* Has this variable already been collected? A query may name the same variable
* in more than one pattern, and RETURN * must give it one set of columns. */
static bool star_var_seen(const char **vars, int vc, const char *name) {
for (int i = 0; i < vc; i++) {
if (strcmp(vars[i], name) == 0) {
return true;
}
}
return false;
}
/* Collect the variables a RETURN * projects, in the order the query names them
* and with no repeats. Without the repeat check, `MATCH (f) OPTIONAL MATCH
* (f)-[:CALLS]->(g)` names f in two patterns and f gets its four columns
* twice. */
static int collect_pattern_vars(cbm_query_t *q, const char **vars, int max_vars) {
int vc = 0;
for (int pi = 0; pi < q->pattern_count; pi++) {
for (int ni = 0; ni < q->patterns[pi].node_count && vc < max_vars; ni++) {
const char *var = q->patterns[pi].nodes[ni].variable;
if (var && !star_var_seen(vars, vc, var)) {
vars[vc++] = var;
}
}
for (int ri = 0; ri < q->patterns[pi].rel_count && vc < max_vars; ri++) {
const char *var = q->patterns[pi].rels[ri].variable;
if (var && !star_var_seen(vars, vc, var)) {
vars[vc++] = var;
}
}
}
return vc;
}
/* Build star-projection columns: var.name, var.qualified_name, var.label, var.file_path */
static void build_star_columns(result_builder_t *rb, const char **vars, int vc) {
int col_n = vc * CYP_NODE_COLS;
const char *col_names[CBM_SZ_128];
for (int v = 0; v < vc; v++) {
char buf[CBM_SZ_128];
snprintf(buf, sizeof(buf), "%s.name", vars[v]);
col_names[(size_t)v * CYP_NODE_COLS] = heap_strdup(buf);
snprintf(buf, sizeof(buf), "%s.qualified_name", vars[v]);
col_names[((size_t)v * CYP_NODE_COLS) + SKIP_ONE] = heap_strdup(buf);
snprintf(buf, sizeof(buf), "%s.label", vars[v]);
col_names[((size_t)v * CYP_NODE_COLS) + PAIR_LEN] = heap_strdup(buf);
snprintf(buf, sizeof(buf), "%s.file_path", vars[v]);
col_names[((size_t)v * CYP_NODE_COLS) + CYP_TRIPLE] = heap_strdup(buf);
}
rb_set_columns(rb, col_names, col_n);
for (int i = 0; i < col_n; i++) {
safe_str_free(&col_names[i]);
}
}
/* Project one variable's 4 columns for RETURN * */
static void project_star_var(binding_t *b, const char *var, const char **vals) {
cbm_edge_t *edge = binding_get_edge(b, var);
if (edge) {
vals[0] = edge_prop(edge, "type");
vals[SKIP_ONE] = "";
vals[PAIR_LEN] = "";
vals[CYP_TRIPLE] = "";
return;
}
cbm_node_t *n = binding_get(b, var);
vals[0] = n && n->name ? n->name : "";
vals[SKIP_ONE] = n && n->qualified_name ? n->qualified_name : "";
vals[PAIR_LEN] = n && n->label ? n->label : "";
vals[CYP_TRIPLE] = n && n->file_path ? n->file_path : "";
}
/* Project one binding row for RETURN * */
static void project_star_row(binding_t *b, const char **vars, int vc, const char **vals) {
for (int v = 0; v < vc; v++) {
project_star_var(b, vars[v], vals + ((size_t)v * CYP_NODE_COLS));
}
}
/* RETURN * after a WITH.
*
* The pattern's variables are out of scope by this point — the WITH replaced
* them with the names it made. Each of those names holds one value, not a
* node, so each is ONE column rather than the four a node variable gets.
*
* Reading the pattern here instead is the fault this function exists to avoid:
* it named variables the bindings no longer hold, found nothing for every one
* of them, and answered a full result of empty strings with no error. */
static void execute_return_star_after_with(cbm_query_t *q, binding_t *bindings, int bind_count,
int max_rows, result_builder_t *rb) {
cbm_return_clause_t *wc = q->with_clause;
char name_bufs[CYP_MAX_VARS][CBM_SZ_128];
const char *cols[CYP_MAX_VARS];
/* parse_return_or_with refuses a WITH wider than CYP_MAX_VARS, so this
* clamp cannot fire. It stays as the bound this function relies on. */
int col_n = wc->count < CYP_MAX_VARS ? wc->count : CYP_MAX_VARS;
for (int i = 0; i < col_n; i++) {
cols[i] = resolve_item_alias(&wc->items[i], name_bufs[i], sizeof(name_bufs[i]));
}
rb_set_columns(rb, cols, col_n);
rb_distinct_t seen;
rb_distinct_init(&seen, q->ret && q->ret->distinct);
for (int bi = 0; bi < bind_count; bi++) {
const char *vals[CYP_MAX_VARS];
for (int i = 0; i < col_n; i++) {
cbm_node_t *vn = binding_get(&bindings[bi], cols[i]);
vals[i] = vn && vn->name ? vn->name : "";
}
if (!rb_add_row_capped(rb, &seen, vals, max_rows)) {
g_cypher_truncated = true;
break;
}
}
rb_distinct_free(&seen);
}
static void execute_return_star(cbm_query_t *q, binding_t *bindings, int bind_count, int max_rows,
result_builder_t *rb) {
if (q->with_clause) {
execute_return_star_after_with(q, bindings, bind_count, max_rows, rb);
return;
}
const char *vars[CBM_SZ_32];
int vc = collect_pattern_vars(q, vars, CBM_SZ_32);
build_star_columns(rb, vars, vc);
int projection_cap = max_rows;
bool cap_is_engine_budget = true;
cbm_return_clause_t *ret = q->ret;
if (ret && ret->limit >= 0 && ret->limit <= max_rows && ret->order_key_count == 0 &&
ret->skip <= 0) {
projection_cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */
cap_is_engine_budget = false;
}
rb_distinct_t seen;
rb_distinct_init(&seen, ret && ret->distinct);
for (int bi = 0; bi < bind_count; bi++) {
const char *vals[CBM_SZ_128];
project_star_row(&bindings[bi], vars, vc, vals);
if (!rb_add_row_capped(rb, &seen, vals, projection_cap)) {
g_cypher_truncated = g_cypher_truncated || cap_is_engine_budget;
break;
}
}
rb_distinct_free(&seen);
}
/* Format an aggregate value into buf based on function name */
/* Format a COLLECT list as JSON array string */
static void format_collect_list(char **items, int item_count, char *buf, size_t buf_sz) {
char cbuf[CBM_SZ_2K] = "[";
int bl = SKIP_ONE;
for (int i = 0; i < item_count; i++) {
if (i > 0) {
cbuf[bl++] = ',';
}
bl += snprintf(cbuf + bl, sizeof(cbuf) - (size_t)bl, "\"%s\"", items[i]);
if (bl >= (int)sizeof(cbuf)) {
bl = (int)sizeof(cbuf) - SKIP_ONE;
}
}
if (bl < (int)sizeof(cbuf) - SKIP_ONE) {
cbuf[bl++] = ']';
}
cbuf[bl] = '\0';
snprintf(buf, buf_sz, "%s", cbuf);
}
static void format_agg_value(const char *func, int count, double sum, double min_val,
double max_val, char ***collect_lists, int *collect_counts, int ci,
char *buf, size_t buf_sz) {
if (strcmp(func, "SUM") == 0) {
snprintf(buf, buf_sz, "%.10g", sum);
} else if (strcmp(func, "AVG") == 0) {
snprintf(buf, buf_sz, "%.10g", count > 0 ? sum / count : 0.0);
} else if (strcmp(func, "MIN") == 0) {
snprintf(buf, buf_sz, "%.10g", min_val);
} else if (strcmp(func, "MAX") == 0) {
snprintf(buf, buf_sz, "%.10g", max_val);
} else if (strcmp(func, "COLLECT") == 0) {
format_collect_list(collect_lists[ci], collect_counts[ci], buf, buf_sz);
} else {
snprintf(buf, buf_sz, "%d", count);
}
}
/* RETURN aggregation entry */
typedef struct {
char group_key[CBM_SZ_1K];
const char **group_vals;
double *sums;
int *counts;
double *mins, *maxs;
char ***collect_lists;
int *collect_counts;
} ret_agg_entry_t;
/* Initialize a new RETURN aggregation group */
static void ret_agg_init_group(ret_agg_entry_t *entry, const char *key, int item_count,
const char **vals) {
snprintf(entry->group_key, sizeof(entry->group_key), "%s", key);
entry->group_vals = calloc(item_count, sizeof(const char *));
entry->sums = calloc(item_count, sizeof(double));
entry->counts = calloc(item_count, sizeof(int));
/* calloc: the sentinel loop below overwrites every slot, but the bound
* equality with the accumulate loop is invisible to path-sensitive
* analysis; zeroed backing keeps any modeled mismatch defined. */
entry->mins = calloc(item_count, sizeof(double));
entry->maxs = calloc(item_count, sizeof(double));
entry->collect_lists = calloc(item_count, sizeof(char **));
entry->collect_counts = calloc(item_count, sizeof(int));
for (int ci = 0; ci < item_count; ci++) {
entry->mins[ci] = CYP_DBL_MAX;
entry->maxs[ci] = -CYP_DBL_MAX;
entry->group_vals[ci] = heap_strdup(vals[ci]);
}
}
/* Accumulate a binding into RETURN aggregation */
static bool binding_has_value(binding_t *b, const char *var, const char *prop) {
if (!var || strcmp(var, "*") == 0) {
return true;
}
char full[CBM_SZ_256];
if (prop) {
snprintf(full, sizeof(full), "%s.%s", var, prop);
} else {
snprintf(full, sizeof(full), "%s", var);
}
for (int i = 0; i < b->var_count; i++) {
if (strcmp(b->var_names[i], full) == 0) {
return true;
}
}
return binding_get_edge(b, var) != NULL || binding_get(b, var) != NULL;
}
static void ret_agg_accumulate(ret_agg_entry_t *entry, cbm_return_clause_t *ret, binding_t *b) {
for (int ci = 0; ci < ret->count; ci++) {
if (!is_aggregate_func(ret->items[ci].func)) {
continue;
}
if (strcmp(ret->items[ci].func, "COUNT") == 0 &&
!binding_has_value(b, ret->items[ci].variable, ret->items[ci].property)) {
continue;
}
entry->counts[ci]++;
const char *raw = binding_get_virtual(b, ret->items[ci].variable, ret->items[ci].property);
double dv = strtod(raw, NULL);
entry->sums[ci] += dv;
if (dv < entry->mins[ci]) {
entry->mins[ci] = dv;
}
if (dv > entry->maxs[ci]) {
entry->maxs[ci] = dv;
}
if (strcmp(ret->items[ci].func, "COLLECT") == 0) {
int idx = entry->collect_counts[ci]++;
entry->collect_lists[ci] =
safe_realloc(entry->collect_lists[ci], (idx + SKIP_ONE) * sizeof(char *));
entry->collect_lists[ci][idx] = heap_strdup(raw);
} else if (ret->items[ci].distinct && strcmp(ret->items[ci].func, "COUNT") == 0) {
/* COUNT(DISTINCT x): track unique values; emit the set size (#239). */
distinct_list_add(&entry->collect_lists[ci], &entry->collect_counts[ci], raw);
}
}
}
/* Free RETURN aggregation entries */
static void ret_agg_free(ret_agg_entry_t *aggs, int agg_count, int item_count) {
for (int a = 0; a < agg_count; a++) {
for (int ci = 0; ci < item_count; ci++) {
safe_str_free(&aggs[a].group_vals[ci]);
for (int j = 0; j < aggs[a].collect_counts[ci]; j++) {
free(aggs[a].collect_lists[ci][j]);
}
free(aggs[a].collect_lists[ci]);
}
free(aggs[a].group_vals);
free(aggs[a].sums);
free(aggs[a].counts);
free(aggs[a].mins);
free(aggs[a].maxs);
free(aggs[a].collect_lists);
free(aggs[a].collect_counts);
}
free(aggs);
}
/* Execute RETURN with aggregation */
/* Build group key and projected values for one binding */
static void ret_agg_build_key(cbm_return_clause_t *ret, binding_t *b, char *key, size_t key_sz,
const char **vals, char valbufs[][CBM_SZ_512]) {
int klen = 0;
for (int ci = 0; ci < ret->count; ci++) {
if (is_aggregate_func(ret->items[ci].func)) {
vals[ci] = "0";
continue;
}
/* project_item may return its own scratch (stable static or a per-column
* buffer it copied into); persist the value in the caller-owned valbufs
* so vals[] survives until ret_agg_init_group strdup's it. */
const char *v = project_item(b, &ret->items[ci], valbufs[ci], CBM_SZ_512);
if (v != valbufs[ci]) {
snprintf(valbufs[ci], CBM_SZ_512, "%s", v ? v : "");
}
vals[ci] = valbufs[ci];
klen += snprintf(key + klen, key_sz - (size_t)klen, "%s|", vals[ci]);
if (klen >= (int)key_sz) {
klen = (int)key_sz - SKIP_ONE;
}
}
}
/* Emit one aggregated row into the result builder */
static void ret_agg_emit_row(cbm_return_clause_t *ret, ret_agg_entry_t *agg, result_builder_t *rb) {
const char *row[CBM_SZ_32];
char bufs[CBM_SZ_32][CBM_SZ_64];
for (int ci = 0; ci < ret->count; ci++) {
if (!is_aggregate_func(ret->items[ci].func)) {
row[ci] = agg->group_vals[ci];
continue;
}
if (ret->items[ci].distinct && strcmp(ret->items[ci].func, "COUNT") == 0) {
/* COUNT(DISTINCT x) — number of unique values accumulated (#239). */
snprintf(bufs[ci], sizeof(bufs[ci]), "%d", agg->collect_counts[ci]);
row[ci] = bufs[ci];
continue;
}
format_agg_value(ret->items[ci].func, agg->counts[ci], agg->sums[ci], agg->mins[ci],
agg->maxs[ci], agg->collect_lists, agg->collect_counts, ci, bufs[ci],
sizeof(bufs[ci]));
row[ci] = bufs[ci];
}
rb_add_row(rb, row);
}
static void execute_return_agg(cbm_return_clause_t *ret, binding_t *bindings, int bind_count,
result_builder_t *rb) {
int agg_cap = CBM_SZ_256;
ret_agg_entry_t *aggs = calloc(agg_cap, sizeof(ret_agg_entry_t));
int agg_count = 0;
for (int bi = 0; bi < bind_count; bi++) {
/* #601: grouping is O(bindings x groups) — the dominant cost on a
* whole-graph GROUP BY. Abort if we blow the wall-clock budget. */
if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
break;
}
char key[CBM_SZ_1K] = "";
const char *vals[CBM_SZ_32];
char valbufs[CBM_SZ_32][CBM_SZ_512];
ret_agg_build_key(ret, &bindings[bi], key, sizeof(key), vals, valbufs);
int found = CYP_FOUND_NONE;
for (int a = 0; a < agg_count; a++) {
if (strcmp(aggs[a].group_key, key) == 0) {
found = a;
break;
}
}
if (found < 0) {
if (agg_count >= agg_cap) {
agg_cap *= PAIR_LEN;
aggs = safe_realloc(aggs, agg_cap * sizeof(ret_agg_entry_t));
}
found = agg_count++;
ret_agg_init_group(&aggs[found], key, ret->count, vals);
}
ret_agg_accumulate(&aggs[found], ret, &bindings[bi]);
}
for (int a = 0; a < agg_count; a++) {
ret_agg_emit_row(ret, &aggs[a], rb);
}
ret_agg_free(aggs, agg_count, ret->count);
}
/* Build RETURN column names from items */
static void build_return_columns(result_builder_t *rb, cbm_return_clause_t *ret) {
const char *col_names[CBM_SZ_32];
for (int i = 0; i < ret->count && i < CBM_SZ_32; i++) {
cbm_return_item_t *item = &ret->items[i];
if (item->alias) {
col_names[i] = item->alias;
} else if (item->func) {
char buf[CBM_SZ_128];
snprintf(buf, sizeof(buf), "%s(%s)", item->func, item->variable);
col_names[i] = heap_strdup(buf);
} else if (item->kase) {
col_names[i] = "CASE";
} else if (item->property) {
char buf[CBM_SZ_128];
snprintf(buf, sizeof(buf), "%s.%s", item->variable, item->property);
col_names[i] = heap_strdup(buf);
} else {
col_names[i] = item->variable;
}
}
rb_set_columns(rb, col_names, ret->count);
for (int i = 0; i < ret->count && i < CBM_SZ_32; i++) {
cbm_return_item_t *item = &ret->items[i];
if (!item->alias && (item->func || (!item->kase && item->property))) {
safe_str_free(&col_names[i]);
}
}
}
/* Execute simple (non-aggregate) RETURN projection */
static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings, int bind_count,
int max_rows, result_builder_t *rb) {
int proj_cap = max_rows;
bool cap_is_engine_budget = true;
if (ret->limit >= 0 && ret->order_key_count == 0 && ret->skip <= 0) {
proj_cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */
cap_is_engine_budget = false;
}
rb_distinct_t seen;
rb_distinct_init(&seen, ret->distinct);
for (int bi = 0; bi < bind_count; bi++) {
const char *vals[CBM_SZ_32];
char func_bufs[CBM_SZ_32][CBM_SZ_512];
for (int ci = 0; ci < ret->count; ci++) {
vals[ci] =
project_item(&bindings[bi], &ret->items[ci], func_bufs[ci], sizeof(func_bufs[ci]));
}
if (!rb_add_row_capped(rb, &seen, vals, proj_cap)) {
g_cypher_truncated = g_cypher_truncated || cap_is_engine_budget;
break;
}
}
rb_distinct_free(&seen);
}
/* Build default 3-column headers (name, qualified_name, label) per variable */
static void build_default_columns(result_builder_t *rb, const char **vars, int vc) {
int col_n = vc * CYP_EDGE_COLS;
const char *col_names[CYP_COL_BUF];
for (int v = 0; v < vc; v++) {
char buf[CBM_SZ_128];
snprintf(buf, sizeof(buf), "%s.name", vars[v]);
col_names[(size_t)v * CYP_EDGE_COLS] = heap_strdup(buf);
snprintf(buf, sizeof(buf), "%s.qualified_name", vars[v]);
col_names[((size_t)v * CYP_EDGE_COLS) + SKIP_ONE] = heap_strdup(buf);
snprintf(buf, sizeof(buf), "%s.label", vars[v]);
col_names[((size_t)v * CYP_EDGE_COLS) + PAIR_LEN] = heap_strdup(buf);
}
rb_set_columns(rb, col_names, col_n);
for (int i = 0; i < col_n; i++) {
safe_str_free(&col_names[i]);
}
}
/* Default projection when no RETURN clause */
static void execute_default_projection(cbm_pattern_t *pat0, binding_t *bindings, int bind_count,
int max_rows, result_builder_t *rb) {
const char *vars[CYP_MAX_VARS];
int vc = 0;
for (int ni = 0; ni < pat0->node_count; ni++) {
if (pat0->nodes[ni].variable) {
if (vc < CYP_MAX_VARS) {
vars[vc++] = pat0->nodes[ni].variable;
} else {
g_cypher_truncated = true;
}
}
}
build_default_columns(rb, vars, vc);
for (int bi = 0; bi < bind_count && rb->row_count < max_rows; bi++) {
const char *vals[CYP_COL_BUF];
for (int v = 0; v < vc; v++) {
cbm_node_t *n = binding_get(&bindings[bi], vars[v]);
vals[(size_t)v * CYP_EDGE_COLS] = n && n->name ? n->name : "";
vals[((size_t)v * CYP_EDGE_COLS) + SKIP_ONE] =
n && n->qualified_name ? n->qualified_name : "";
vals[((size_t)v * CYP_EDGE_COLS) + PAIR_LEN] = n && n->label ? n->label : "";
}
rb_add_row(rb, vals);
}
if (bind_count > max_rows) {
g_cypher_truncated = true;
}
}
/* Worst-case binding_t slot count for a node cross-join: one row per
* (existing binding × extra node), plus — for an OPTIONAL join with no extra
* nodes — one fallback row per existing binding, plus a trailing sentinel slot.
*
* The plain-int product bind_count * extra_count overflows to a negative/garbage
* malloc size on large graphs (the failure mode fixed for cross_join_with_rels),
* so the count is computed in size_t and rejected if it would not fit the int
* binding counter or would overflow the size_t byte size. Returns 0 and writes
* *out_n on success, CBM_NOT_FOUND on overflow. Non-static so the arithmetic
* boundary can be unit-tested directly. */
int cbm_cypher_cross_join_alloc(int bind_count, int extra_count, bool opt, size_t *out_n) {
size_t per_binding = extra_count > 0 ? (size_t)extra_count : (opt ? (size_t)SKIP_ONE : 0U);
size_t rows = (size_t)bind_count * per_binding;
if (rows > (size_t)INT_MAX) {
return CBM_NOT_FOUND; /* new_count would not fit the int binding counter */
}
size_t n = rows + SKIP_ONE;
if (n > SIZE_MAX / sizeof(binding_t)) {
return CBM_NOT_FOUND; /* alloc_n * sizeof(binding_t) would overflow size_t */
}
*out_n = n;
return 0;
}
/* Cross-join node-only pattern into existing bindings. Returns 0 on success,
* CBM_NOT_FOUND when the allocation is refused (overflow) or fails (OOM); the
* caller propagates that as a query error rather than silently skipping the
* MATCH, which would return a wrong (short) result. */
static int cross_join_nodes(binding_t **bindings, int *bind_count, cbm_node_t *extra_nodes,
int extra_count, const char *nvar, bool opt) {
size_t alloc_n = 0;
if (cbm_cypher_cross_join_alloc(*bind_count, extra_count, opt, &alloc_n) != 0) {
return CBM_NOT_FOUND; /* product overflows int/size_t: fail the query */
}
binding_t *new_bindings = malloc(alloc_n * sizeof(binding_t));
if (!new_bindings) {
return CBM_NOT_FOUND; /* OOM: propagate, don't silently skip the MATCH */
}
size_t new_count = 0;
for (int bi = 0; bi < *bind_count; bi++) {
for (int ni = 0; ni < extra_count && new_count < alloc_n; ni++) {
binding_t nb = {0};
binding_copy(&nb, &(*bindings)[bi]);
binding_set(&nb, nvar, &extra_nodes[ni]);
new_bindings[new_count++] = nb;
}
if (opt && extra_count == 0 && new_count < alloc_n) {
binding_t nb = {0};
binding_copy(&nb, &(*bindings)[bi]);
new_bindings[new_count++] = nb;
}
}
for (int bi = 0; bi < *bind_count; bi++) {
binding_free(&(*bindings)[bi]);
}
free(*bindings);
*bindings = new_bindings;
*bind_count = (int)new_count; /* new_count <= INT_MAX, guaranteed above */
return 0;
}
/* Cross-join pattern-with-rels into existing bindings */
static void cross_join_with_rels(cbm_store_t *store, cbm_pattern_t *patn, binding_t **bindings,
int *bind_count, cbm_node_t *extra_nodes, int extra_count,
const char *nvar, bool opt) {
/* size_t arithmetic: bind_count * extra_count can exceed INT_MAX on large
* graphs (e.g. an unbound `c` scanned against ~29 K `f` bindings), wrapping
* the int product negative and yielding a tiny/garbage malloc → heap OOB
* write → SIGSEGV/SIGABRT (#627). */
int new_cap = *bind_count > 0 && extra_count > 0 ? *bind_count : SKIP_ONE;
binding_t *new_bindings = malloc((size_t)new_cap * sizeof(binding_t));
if (!new_bindings) {
return; /* OOM: leave existing bindings untouched rather than corrupt */
}
int new_count = 0;
for (int bi = 0; bi < *bind_count; bi++) {
for (int ni = 0; ni < extra_count; ni++) {
binding_t nb = {0};
binding_copy(&nb, &(*bindings)[bi]);
binding_set(&nb, nvar, &extra_nodes[ni]);
binding_t *tmp = malloc(PAIR_LEN * sizeof(binding_t));
tmp[0] = nb;
int tc = SKIP_ONE;
int tcap = SKIP_ONE;
const char *tv = nvar;
expand_pattern_rels(store, patn, &tmp, &tc, &tcap, &tv, opt);
for (int ti = 0; ti < tc; ti++) {
(void)binding_out_append(&new_bindings, &new_count, &new_cap, &tmp[ti]);
}
free(tmp);
}
if (opt && extra_count == 0) {
/* OPTIONAL with no start node: keep the row, pattern vars unbound.
* The buffer was sized for a single row when extra_count == 0, so
* this write must grow it like every other writer here. */
binding_t nb = {0};
binding_copy(&nb, &(*bindings)[bi]);
(void)binding_out_append(&new_bindings, &new_count, &new_cap, &nb);
}
}
for (int bi = 0; bi < *bind_count; bi++) {
binding_free(&(*bindings)[bi]);
}
free(*bindings);
*bindings = new_bindings;
*bind_count = new_count;
}
/* Drive a single-relationship additional pattern from its ALREADY-BOUND
* terminal node, binding the unbound START var to the edge's other endpoint.
*
* Handles `OPTIONAL MATCH (c)-[:CALLS]->(f)` where `f` is bound from an earlier
* MATCH and `c` is new: scanning every node for `c` and cross-joining (a) risks
* an int-overflow OOB write on large graphs and (b) leaves `c` bound to an
* arbitrary node so a later `WHERE c IS NULL` wrongly drops every row (#627).
* Instead we scan only the bound terminal's edges and bind `c` to real
* neighbours; with OPTIONAL we keep the row with `c` unbound when there are
* none — the correct dead-code semantics. */
static void expand_from_bound_terminal(cbm_store_t *store, cbm_pattern_t *patn,
binding_t **bindings, int *bind_count, const char *start_var,
bool opt) {
cbm_rel_pattern_t *rel = &patn->rels[0];
const cbm_node_pattern_t *start_node = &patn->nodes[0];
/* The relationship is written start-[r]->terminal. To enumerate the start
* nodes reachable from the bound terminal we invert the stored direction. */
bool rel_inbound = rel->direction && strcmp(rel->direction, "inbound") == 0;
bool scan_targets =
!rel_inbound; /* (start)->(term): start = edge source = scan term's inbound */
/* Size this hop's output for BOTH writers without dropping any row: the
* materialised expansion is capped at max_new = *bind_count * 10 rows (only
* the WRITE is gated on max_new; match DETECTION below is not, so a full
* buffer never hides a real neighbour), and the OPTIONAL fallback
* emits at most one row per source (<= *bind_count). A source either matches
* (feeds the expansion) or takes the fallback, never both, so the two counts
* are additive and bounded by max_new + *bind_count. Computed in size_t so
* the product cannot overflow.
* The previous "+ SKIP_ONE" sizing tied max_new to the whole buffer, so once
* the expansion saturated it the fallback guard silently dropped every later
* OPTIONAL no-match row (a data-loss bug, not an OOB — the fallback stayed
* in-bounds behind that guard) — exactly the rows
* `OPTIONAL MATCH ... WHERE <start> IS NULL` is meant to surface. */
int new_cap = *bind_count > 0 ? *bind_count : CYP_GROWTH_10;
binding_t *new_bindings = malloc((size_t)new_cap * sizeof(binding_t));
if (!new_bindings) {
return;
}
int new_count = 0;
for (int bi = 0; bi < *bind_count; bi++) {
binding_t *b = &(*bindings)[bi];
cbm_node_t *term = binding_get(b, patn->nodes[1].variable ? patn->nodes[1].variable : "");
int match_count = 0;
if (term) {
/* Detection is decoupled from the write budget: scan every edge and
* type unconditionally so match_count reflects the true neighbour
* count, and gate only the WRITE on the ceiling (below). If detection
* stopped at max_new too, a saturated buffer would leave match_count at
* 0 for a terminal that actually has callers, and the fallback below
* would fabricate an UNBOUND "dead code" row for live code — worse than
* dropping a row. */
for (int ti = 0; ti < (rel->type_count > 0 ? rel->type_count : 1); ti++) {
cbm_edge_t *edges = NULL;
int edge_count = 0;
if (rel->type_count > 0) {
if (scan_targets) {
cbm_store_find_edges_by_target_type(store, term->id, rel->types[ti], &edges,
&edge_count);
} else {
cbm_store_find_edges_by_source_type(store, term->id, rel->types[ti], &edges,
&edge_count);
}
} else if (scan_targets) {
cbm_store_find_edges_by_target(store, term->id, &edges, &edge_count);
} else {
cbm_store_find_edges_by_source(store, term->id, &edges, &edge_count);
}
for (int ei = 0; ei < edge_count; ei++) {
int64_t sid = scan_targets ? edges[ei].source_id : edges[ei].target_id;
cbm_node_t found = {0};
if (cbm_store_find_node_by_id(store, sid, &found) != CBM_STORE_OK) {
continue;
}
if (start_node->label && !label_alt_matches(found.label, start_node->label)) {
node_fields_free(&found);
continue;
}
match_count++;
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, start_var, &found);
if (rel->variable) {
binding_set_edge(&nb, rel->variable, &edges[ei]);
}
(void)binding_out_append(&new_bindings, &new_count, &new_cap, &nb);
node_fields_free(&found);
}
cbm_store_free_edges(edges, edge_count);
}
}
if (opt && match_count == 0) {
/* GENUINELY no matching neighbour: the scan above runs unconditionally,
* so match_count is the true neighbour count and match_count == 0 here
* means the terminal really has none. Keep the row with start_var left
* UNBOUND so `WHERE <start> IS NULL` correctly identifies the no-edge
* case; the shared growable append gives every fallback row a slot. */
binding_t nb = {0};
binding_copy(&nb, b);
(void)binding_out_append(&new_bindings, &new_count, &new_cap, &nb);
}
}
for (int bi = 0; bi < *bind_count; bi++) {
binding_free(&(*bindings)[bi]);
}
free(*bindings);
*bindings = new_bindings;
*bind_count = new_count;
}
/* Expand additional MATCH patterns (pi >= 1) */
static int expand_additional_patterns(cbm_store_t *store, cbm_query_t *q, const char *project,
int max_rows, binding_t **bindings, int *bind_count,
int *bind_cap) {
for (int pi = SKIP_ONE; pi < q->pattern_count; pi++) {
cbm_pattern_t *patn = &q->patterns[pi];
bool opt = q->pattern_optional[pi];
const char *nvar = patn->nodes[0].variable ? patn->nodes[0].variable : "_n_extra";
bool start_bound = *bind_count > 0 && binding_get(&(*bindings)[0], nvar) != NULL;
if (start_bound && patn->rel_count > 0) {
const char *tv = nvar;
expand_pattern_rels(store, patn, bindings, bind_count, bind_cap, &tv, opt);
continue;
}
/* Single-rel pattern whose START is unbound but whose TERMINAL is already
* bound: drive from the bound terminal instead of scanning all nodes for
* the start var (avoids the int-overflow OOB write and the c-IS-NULL
* corruption of #627). */
if (!start_bound && patn->rel_count == 1 && *bind_count > 0) {
const char *term_var = patn->nodes[1].variable;
bool term_bound = term_var && binding_get(&(*bindings)[0], term_var) != NULL;
if (term_bound) {
expand_from_bound_terminal(store, patn, bindings, bind_count, nvar, opt);
continue;
}
}
cbm_node_t *extra_nodes = NULL;
int extra_count = 0;
scan_pattern_nodes(store, project, &patn->nodes[0], &extra_nodes, &extra_count);
int rc = 0;
if (patn->rel_count == 0) {
rc = cross_join_nodes(bindings, bind_count, extra_nodes, extra_count, nvar, opt);
} else {
cross_join_with_rels(store, patn, bindings, bind_count, extra_nodes, extra_count, nvar,
opt);
}
cbm_store_free_nodes(extra_nodes, extra_count);
if (rc != 0) {
return rc; /* allocation refused/failed: propagate as a query error */
}
}
return 0;
}
/* Project RETURN clause results */
static void execute_return_clause(cbm_query_t *q, cbm_return_clause_t *ret, binding_t *bindings,
int bind_count, int max_rows, result_builder_t *rb) {
bool has_agg = false;
for (int i = 0; i < ret->count; i++) {
if (is_aggregate_func(ret->items[i].func)) {
has_agg = true;
break;
}
}
if (ret->star) {
execute_return_star(q, bindings, bind_count, max_rows, rb);
} else {
build_return_columns(rb, ret);
if (has_agg) {
execute_return_agg(ret, bindings, bind_count, rb);
} else {
execute_return_simple(ret, bindings, bind_count, max_rows, rb);
}
}
/* The simple and star projections de-duplicate while projecting (#1364);
* only aggregate rows still need the post-pass. */
if (ret->distinct && has_agg && !ret->star) {
rb_apply_distinct(rb);
}
rb_apply_order_by(rb, ret);
bool limit_is_engine_budget = ret->limit < 0;
rb_apply_skip_limit(rb, ret->skip, limit_is_engine_budget ? max_rows : ret->limit,
limit_is_engine_budget);
}
/* ── Planner: seed a single-hop pattern from its selective end ──────────────
*
* The executor scans pattern node 0 and expands from there. Written as
* `MATCH (a)-[:CALLS]->(b) WHERE b.name = '_printk'`, that scans every node
* in the store before the filter on `b` can discard any — on an 8.5 M node
* kernel graph the query hit the execution-time limit while the anchored
* spelling `MATCH (b:Function {name:'_printk'})<-[:CALLS]-(a)` answered in
* seconds (2026-09-16 probe). A reader should not have to know which end to
* write first: when the far node carries an equality on name/qualified_name,
* an inline property, or a label and the near node carries less, the pattern
* is walked from the far end with the relationship direction inverted. The
* result set is identical; only the enumeration order changes. Restricted to
* one single-relationship pattern with an explicit RETURN, and never to an
* OPTIONAL MATCH, whose anchor is part of its meaning. */
static int cypher_cond_selectivity(const cbm_condition_t *c, const char *var) {
if (!c || !c->variable || !var || strcmp(c->variable, var) != 0 || c->negated) {
return 0;
}
if (!c->op || strcmp(c->op, "=") != 0 || !c->property) {
return 0;
}
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) {
int l = cypher_expr_selectivity(e->left, var);
int r = cypher_expr_selectivity(e->right, var);
return l > r ? l : r;
}
return 0; /* OR / NOT / XOR: no single equality to seed from */
}
static int cypher_node_selectivity(const cbm_node_pattern_t *n, const cbm_where_clause_t *w) {
int s = 0;
for (int i = 0; i < n->prop_count; i++) {
const char *k = n->props[i].key;
int v = (k && (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0)) ? 3 : 2;
if (v > s) {
s = v;
}
}
if (w && n->variable) {
int v = 0;
if (w->root) {
v = cypher_expr_selectivity(w->root, n->variable);
} else if (!w->op || strcmp(w->op, "AND") == 0) {
for (int i = 0; i < w->count; i++) {
int c = cypher_cond_selectivity(&w->conditions[i], n->variable);
if (c > v) {
v = c;
}
}
}
if (v > s) {
s = v;
}
}
if (s == 0 && n->label) {
s = 1;
}
return s;
}
static void cypher_plan_seed_from_selective_end(cbm_query_t *q) {
if (!q || q->pattern_count != 1 || !q->ret || (q->pattern_optional && q->pattern_optional[0])) {
return;
}
cbm_pattern_t *p = &q->patterns[0];
if (p->rel_count != 1 || p->node_count != 2) {
return;
}
if (cypher_node_selectivity(&p->nodes[1], q->where) <=
cypher_node_selectivity(&p->nodes[0], q->where)) {
return;
}
cbm_node_pattern_t tmp = p->nodes[0];
p->nodes[0] = p->nodes[1];
p->nodes[1] = tmp;
cbm_rel_pattern_t *r = &p->rels[0];
const char *inverted = NULL;
if (r->direction && strcmp(r->direction, "outbound") == 0) {
inverted = "inbound";
} else if (r->direction && strcmp(r->direction, "inbound") == 0) {
inverted = "outbound";
}
if (inverted) {
safe_str_free(&r->direction);
r->direction = heap_strdup(inverted);
}
}
static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows,
result_builder_t *rb) {
cypher_plan_seed_from_selective_end(q);
cbm_pattern_t *pat0 = &q->patterns[0];
/* Step 1: Scan initial nodes */
cbm_node_t *scanned = NULL;
int scan_count = 0;
scan_pattern_nodes(store, project, &pat0->nodes[0], &scanned, &scan_count);
/* Build initial bindings with early WHERE */
int bind_cap = scan_count > max_rows ? scan_count : (max_rows > 0 ? max_rows : SKIP_ONE);
binding_t *bindings = malloc(((size_t)bind_cap + SKIP_ONE) * sizeof(binding_t));
if (!bindings) {
cbm_store_free_nodes(scanned, scan_count);
return CBM_NOT_FOUND; /* initial binding array refused */
}
int bind_count = 0;
const char *var_name = pat0->nodes[0].variable ? pat0->nodes[0].variable : CYP_ANON_HEAD_VAR;
for (int i = 0; i < scan_count && bind_count < bind_cap; i++) {
if ((i & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
break;
}
binding_t b = {0};
b.store = store;
binding_set(&b, var_name, &scanned[i]);
bool pass = eval_where_partial(q->where, &b) != CYP_PARTIAL_FALSE;
if (pass) {
bindings[bind_count++] = b;
} else {
binding_free(&b);
}
}
/* Step 2: Expand first pattern's relationships */
expand_pattern_rels(store, pat0, &bindings, &bind_count, &bind_cap, &var_name,
q->pattern_optional[0]);
/* Step 2b: Additional patterns */
if (expand_additional_patterns(store, q, project, max_rows, &bindings, &bind_count,
&bind_cap) != 0) {
for (int bi = 0; bi < bind_count; bi++) {
binding_free(&bindings[bi]);
}
free(bindings);
cbm_store_free_nodes(scanned, scan_count);
return CBM_NOT_FOUND; /* cross-join allocation refused/failed */
}
/* Step 3: Late WHERE */
if (q->where && (pat0->rel_count > 0 || q->pattern_count > SKIP_ONE)) {
filter_bindings_where(q->where, bindings, &bind_count);
}
/* Step 3b: WITH clause */
execute_with_clause(q, &bindings, &bind_count);
/* Step 4: Project results */
rb_init(rb);
if (q->ret) {
execute_return_clause(q, q->ret, bindings, bind_count, max_rows, rb);
} else {
execute_default_projection(pat0, bindings, bind_count, max_rows, rb);
}
for (int bi = 0; bi < bind_count; bi++) {
binding_free(&bindings[bi]);
}
free(bindings);
cbm_store_free_nodes(scanned, scan_count);
return 0;
}
/* ── Main entry point ─────────────────────────────────────────── */
/* ── Is every name a RETURN or WITH item uses actually in scope? ──
*
* An unbound variable resolves to NULL and renders as "" on purpose: an
* OPTIONAL MATCH target that found no row has to project a blank rather than
* drop the row. The cost of that convention is that a name the query NEVER
* carried through looks exactly the same on screen. A typo, or a variable a
* WITH dropped, then reads as "the graph holds no such data" instead of "your
* query named something that is not there" (#1919). Same failure shape as
* #373, and the same answer: say so out loud.
*
* The check runs on the parsed query, not on the run-time bindings, and that
* is the whole trick. It asks whether the query DECLARED the name — a
* different question from whether a row happened to bind it. So the OPTIONAL
* MATCH convention above is untouched: a target that did not match is still
* declared, still legal, and still projects "".
*/
/* Cap for one query's declared names. CYP_MAX_VARS is the binding cap for
* nodes alone; a pattern also names edges, so this is the roomier of the two.
* A query that overruns it skips the check rather than guessing — a wrong
* refusal costs the caller a working query, which is worse than the silence
* this guard removes. */
enum { CYP_SCOPE_MAX_NAMES = 32 };
static bool scope_holds(const char *const *names, int count, const char *want) {
for (int i = 0; i < count; i++) {
if (names[i] && strcmp(names[i], want) == 0) {
return true;
}
}
return false;
}
/* Every name the query's patterns declare, plus an UNWIND alias.
* Answers -1 when there are more names than the cap holds. */
static int collect_declared_names(const cbm_query_t *q, const char **out, int cap) {
int n = 0;
for (int pi = 0; pi < q->pattern_count; pi++) {
const cbm_pattern_t *pat = &q->patterns[pi];
for (int ni = 0; ni < pat->node_count; ni++) {
const char *var = pat->nodes[ni].variable;
if (var && !scope_holds(out, n, var)) {
if (n >= cap) {
return -1;
}
out[n++] = var;
}
}
for (int ri = 0; ri < pat->rel_count; ri++) {
const char *var = pat->rels[ri].variable;
if (var && !scope_holds(out, n, var)) {
if (n >= cap) {
return -1;
}
out[n++] = var;
}
}
}
if (q->unwind_alias && !scope_holds(out, n, q->unwind_alias)) {
if (n >= cap) {
return -1;
}
out[n++] = q->unwind_alias;
}
return n;
}
/* What a WITH leaves behind: its alias where it made one, and the plain
* variable where it carried one through whole. `WITH f.name AS caller` leaves
* `caller` and nothing else — `f` is gone, which is the case #1919 is about. */
static int collect_with_names(const cbm_return_clause_t *wc, const char **out, int cap) {
int n = 0;
for (int i = 0; i < wc->count; i++) {
const cbm_return_item_t *item = &wc->items[i];
const char *name = NULL;
if (item->alias) {
name = item->alias;
} else if (item->variable && !item->property && !item->func) {
name = item->variable;
}
if (name && !scope_holds(out, n, name)) {
if (n >= cap) {
return -1;
}
out[n++] = name;
}
}
return n;
}
/* Build the message. It names the variable and the clause, because an error
* that does not say WHICH name is wrong sends the reader back to guessing. */
static char *scope_error(const char *var, const char *clause, const char *why) {
char buf[CBM_SZ_256];
snprintf(buf, sizeof(buf), "variable '%s' is not in scope for %s — %s", var, clause, why);
return heap_strdup(buf);
}
/* The variable this item really references, or NULL when it references none.
* Two items carry a placeholder in that field rather than a name the query
* declared: `count(*)` stores "*", and a CASE expression stores "CASE". Both
* would read as an unknown variable, so neither is checkable here. */
static const char *scope_checkable_var(const cbm_return_item_t *item) {
if (item->kase || !item->variable) {
return NULL;
}
if (strcmp(item->variable, "*") == 0) {
return NULL;
}
return item->variable;
}
/* Says which limit the query passed and how to get under it. An unnamed node
* takes no slot — except the head of the first pattern, which the engine binds
* either way — so dropping a name the query never uses is the cheap way out.
* Splitting the MATCH is NOT — every pattern of one query shares one binding,
* which is why the caller counts across all of them. Separate queries do work,
* because each gets a binding of its own. */
static char *var_capacity_error(const char *kind, int limit) {
char buf[CBM_SZ_256];
snprintf(buf, sizeof(buf),
"too many %s variables: a query can name at most %d — "
"leave the name off the ones you do not use, or run separate queries",
kind, limit);
return heap_strdup(buf);
}
/* A binding holds a fixed number of variables: CYP_MAX_VARS node variables and
* CYP_MAX_EDGE_VARS edge variables, both in plain arrays (see binding_t).
* binding_set and binding_set_edge drop anything past those without a word, so
* a query naming more variables than a binding holds cannot be answered — the
* extra names bind to nothing and project as empty strings, which reads as
* "the graph holds no such data" rather than "this query is too wide".
*
* Refuse such a query instead, before any row is touched. Bounding the input
* here is also what stops collect_declared_names below overflowing its array:
* the patterns contribute at most CYP_MAX_VARS + CYP_MAX_EDGE_VARS names, plus
* one UNWIND alias, which is well inside CYP_SCOPE_MAX_NAMES.
*
* Counts DISTINCT variables across every pattern, because they all land in the
* same binding: a multi-MATCH query shares one, and an OPTIONAL MATCH pattern
* sits in this same array (q->pattern_optional marks which). A node variable
* and an edge variable may share a name and each take a slot, because the
* binding keeps the two in separate arrays. */
static char *check_pattern_var_capacity(const cbm_query_t *q) {
/* Initialized because cppcheck cannot see that scope_holds reads only the
* node_n / edge_n entries already written, and reports the first call as a
* read of an uninitialized array. */
const char *node_vars[CYP_MAX_VARS] = {NULL};
const char *edge_vars[CYP_MAX_EDGE_VARS] = {NULL};
int node_n = 0;
int edge_n = 0;
/* The head of the first pattern always takes a node slot, named or not:
* execute_single binds it under CYP_ANON_HEAD_VAR when the query leaves it
* unnamed. Count it first, or a query with an unnamed head and CYP_MAX_VARS
* named nodes passes this check and still loses its last name in
* binding_set — the exact silence this guard exists to remove. */
if (q->pattern_count > 0 && q->patterns[0].node_count > 0 &&
!q->patterns[0].nodes[0].variable) {
node_vars[node_n++] = CYP_ANON_HEAD_VAR;
}
for (int pi = 0; pi < q->pattern_count; pi++) {
const cbm_pattern_t *pat = &q->patterns[pi];
for (int ni = 0; ni < pat->node_count; ni++) {
const char *var = pat->nodes[ni].variable;
if (!var || scope_holds(node_vars, node_n, var)) {
continue;
}
if (node_n >= CYP_MAX_VARS) {
return var_capacity_error("node", CYP_MAX_VARS);
}
node_vars[node_n++] = var;
}
for (int ri = 0; ri < pat->rel_count; ri++) {
const char *var = pat->rels[ri].variable;
if (!var || scope_holds(edge_vars, edge_n, var)) {
continue;
}
if (edge_n >= CYP_MAX_EDGE_VARS) {
return var_capacity_error("edge", CYP_MAX_EDGE_VARS);
}
edge_vars[edge_n++] = var;
}
}
return NULL;
}
/* Answers NULL when the query is fine, or a heap message naming the first
* variable that is not in scope. Checks one query; the caller walks a UNION. */
static char *check_projection_scope(const cbm_query_t *q) {
/* Runs first, so the rest of this function can trust that the query names
* no more variables than the arrays below can model. */
char *capacity_err = check_pattern_var_capacity(q);
if (capacity_err) {
return capacity_err;
}
const char *declared[CYP_SCOPE_MAX_NAMES];
int declared_n = collect_declared_names(q, declared, CYP_SCOPE_MAX_NAMES);
if (declared_n < 0) {
/* Unreachable while the capacity check above holds. Kept so the guard
* still stands if either bound ever moves. Skipping the check was the
* old behaviour, and it let an out-of-scope name through in silence. */
return NULL;
}
/* A WITH still reads the pattern variables. */
if (q->with_clause && !q->with_clause->star) {
for (int i = 0; i < q->with_clause->count; i++) {
const char *var = scope_checkable_var(&q->with_clause->items[i]);
if (var && !scope_holds(declared, declared_n, var)) {
return scope_error(var, "WITH", "no pattern in this query names it");
}
}
}
if (!q->ret || q->ret->star) {
return NULL;
}
/* A RETURN after a WITH reads only what the WITH left behind. */
const char *after_with[CYP_SCOPE_MAX_NAMES];
const char *const *scope = declared;
int scope_n = declared_n;
if (q->with_clause) {
scope_n = collect_with_names(q->with_clause, after_with, CYP_SCOPE_MAX_NAMES);
if (scope_n < 0) {
return NULL; /* unreachable: a WITH holds at most CYP_SCOPE_MAX_NAMES items */
}
scope = after_with;
}
for (int i = 0; i < q->ret->count; i++) {
const char *var = scope_checkable_var(&q->ret->items[i]);
if (var && !scope_holds(scope, scope_n, var)) {
return scope_error(var, "RETURN",
q->with_clause ? "the WITH clause did not carry it through"
: "no pattern in this query names it");
}
}
return NULL;
}
int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows,
cbm_cypher_result_t *out) {
memset(out, 0, sizeof(*out));
g_cypher_depth_clamped = 0;
g_cypher_trail_truncated = 0;
g_cypher_truncated = false;
g_cypher_regex_refused = false;
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
/* max_rows sizes the initial binding array: non-positive means the
* ceiling, and nothing above the ceiling is ever materialized anyway. */
if (max_rows <= 0 || max_rows > CYPHER_RESULT_CEILING) {
max_rows = CYPHER_RESULT_CEILING;
}
cbm_query_t *q = NULL;
char *err = NULL;
if (cbm_cypher_parse(query, &q, &err) < 0) {
out->error = err;
return CBM_NOT_FOUND;
}
for (const cbm_query_t *sq = q; sq; sq = sq->union_next) {
char *scope_err = check_projection_scope(sq);
if (scope_err) {
cbm_query_free(q);
out->error = scope_err;
return CBM_NOT_FOUND;
}
}
result_builder_t rb = {0};
if (execute_single(store, q, project, max_rows, &rb) < 0) {
rb_free(&rb);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error = heap_strdup("query aborted: out of memory or an allocation limit was reached");
return CBM_NOT_FOUND;
}
/* UNION chain */
cbm_query_t *uq = q->union_next;
while (uq) {
result_builder_t rb2 = {0};
if (execute_single(store, uq, project, max_rows, &rb2) < 0) {
rb_free(&rb);
rb_free(&rb2);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error =
heap_strdup("query aborted: out of memory or an allocation limit was reached");
return CBM_NOT_FOUND;
}
/* Concatenate rows from rb2 into rb */
for (int i = 0; i < rb2.row_count; i++) {
rb_add_row(&rb, rb2.rows[i]);
}
rb_free(&rb2);
uq = uq->union_next;
}
/* UNION (not ALL) deduplication */
if (q->union_next && !q->union_all) {
rb_apply_distinct(&rb);
}
/* #601: abort a runaway query that blew the wall-clock budget before it can
* return a misleading partial result. Checked before the row ceiling. */
if (g_cypher_timed_out) {
rb_free(&rb);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error =
heap_strdup("query exceeded the execution time limit — narrow the pattern with a WHERE "
"filter, use a directed MATCH instead of an unbounded OPTIONAL MATCH, or "
"add LIMIT");
return CBM_NOT_FOUND;
}
/* Check ceiling */
if (rb.row_count >= CYPHER_RESULT_CEILING) {
g_cypher_truncated = true;
rb_free(&rb);
cbm_query_free(q);
out->truncated = true;
out->error = heap_strdup("result exceeded 100k rows — use narrower filters or add LIMIT");
return CBM_NOT_FOUND;
}
out->columns = rb.columns;
out->col_count = rb.col_count;
out->rows = rb.rows;
out->row_count = rb.row_count;
/* Any internal ceiling that prevented exhaustive evaluation: a candidate or
* traversal budget, or a variable-length range clamped to the engine cap. */
out->truncated = g_cypher_truncated || g_cypher_trail_truncated != 0;
char wbuf[CBM_SZ_512] = "";
if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) {
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
snprintf(wbuf, sizeof(wbuf),
"variable-length hop range clamped to the engine ceiling (%d) and "
"traversal budget was exhausted — results may be partial",
g_cypher_depth_clamped);
} else if (g_cypher_depth_clamped > 0) {
snprintf(wbuf, sizeof(wbuf),
"variable-length hop range clamped to the engine ceiling (%d) — an empty "
"result may mean \"clamped\", not \"no such path\"",
g_cypher_depth_clamped);
} else {
snprintf(wbuf, sizeof(wbuf),
"variable-length traversal budget was exhausted — results may be partial");
}
}
/* A refused `=~` or property pattern matched nothing: say so, once per
* query, next to any traversal warning. */
if (g_cypher_regex_refused) {
size_t used = strlen(wbuf);
snprintf(wbuf + used, sizeof(wbuf) - used,
"%sa =~ or property " CBM_REG_ETOOBIG_REASON " — the comparison matched nothing",
used ? "; " : "");
}
if (wbuf[0]) {
out->warning = heap_strdup(wbuf);
}
cbm_query_free(q);
return 0;
}
void cbm_cypher_result_free(cbm_cypher_result_t *r) {
if (!r) {
return;
}
free(r->warning);
r->warning = NULL;
for (int i = 0; i < r->col_count; i++) {
safe_str_free(&r->columns[i]);
}
free(r->columns);
for (int i = 0; i < r->row_count; i++) {
for (int j = 0; j < r->col_count; j++) {
safe_str_free(&r->rows[i][j]);
}
free(r->rows[i]);
}
free(r->rows);
free(r->error);
memset(r, 0, sizeof(*r));
}