Skip to content

Commit 8c9ac98

Browse files
authored
Merge pull request DeusData#1036 from DeusData/fix/797-varlength-paths
fix(cypher): shortest-path var-length semantics + advertised depth clamp
2 parents e19a699 + d0626b0 commit 8c9ac98

5 files changed

Lines changed: 127 additions & 4 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2944,6 +2944,13 @@ static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count,
29442944
}
29452945

29462946
/* Expand variable-length relationship via BFS */
2947+
/* Set when a variable-length hop range is clamped to the engine ceiling
2948+
* during the CURRENT execution; cbm_cypher_execute turns it into
2949+
* result->warning so callers can tell "clamped" from "no such path" (#797). */
2950+
/* C11 _Thread_local directly: cypher.c stays windows.h-free (compat.h pulls
2951+
* in windows.h, whose legacy `far` macro breaks this file's identifiers). */
2952+
static _Thread_local int g_cypher_depth_clamped = 0;
2953+
29472954
static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
29482955
cbm_node_pattern_t *target_node, binding_t *b, cbm_node_t *src,
29492956
const char *to_var, binding_t *new_bindings, int *new_count,
@@ -2960,6 +2967,7 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
29602967
snprintf(req_buf, sizeof(req_buf), "%d", max_depth);
29612968
snprintf(cap_buf, sizeof(cap_buf), "%d", depth_cap);
29622969
cbm_log_warn("cypher.depth_capped", "requested", req_buf, "cap", cap_buf);
2970+
g_cypher_depth_clamped = depth_cap; /* surfaced as result->warning (#797) */
29632971
max_depth = depth_cap;
29642972
}
29652973
cbm_traverse_result_t tr = {0};
@@ -4518,6 +4526,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
45184526
int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows,
45194527
cbm_cypher_result_t *out) {
45204528
memset(out, 0, sizeof(*out));
4529+
g_cypher_depth_clamped = 0;
45214530
if (max_rows <= 0) {
45224531
max_rows = CYPHER_RESULT_CEILING;
45234532
}
@@ -4573,6 +4582,14 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
45734582
out->col_count = rb.col_count;
45744583
out->rows = rb.rows;
45754584
out->row_count = rb.row_count;
4585+
if (g_cypher_depth_clamped > 0) {
4586+
char wbuf[CBM_SZ_256];
4587+
snprintf(wbuf, sizeof(wbuf),
4588+
"variable-length hop range clamped to the engine ceiling (%d) — an empty "
4589+
"result may mean \"clamped\", not \"no such path\"",
4590+
g_cypher_depth_clamped);
4591+
out->warning = heap_strdup(wbuf);
4592+
}
45764593

45774594
cbm_query_free(q);
45784595
return 0;
@@ -4582,6 +4599,8 @@ void cbm_cypher_result_free(cbm_cypher_result_t *r) {
45824599
if (!r) {
45834600
return;
45844601
}
4602+
free(r->warning);
4603+
r->warning = NULL;
45854604
for (int i = 0; i < r->col_count; i++) {
45864605
safe_str_free(&r->columns[i]);
45874606
}

‎src/cypher/cypher.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,10 @@ typedef struct {
310310
int row_count;
311311
/* Non-NULL when the query was rejected (e.g. result too large) */
312312
char *error;
313+
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
314+
* length hop range was clamped to the engine ceiling (#797) — without
315+
* this, a clamped expansion is indistinguishable from "no such path". */
316+
char *warning;
313317
} cbm_cypher_result_t;
314318

315319
/* Execute a Cypher query against a store.

‎src/mcp/mcp.c‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,6 +2486,9 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
24862486
cbm_toon_row_end(&sb);
24872487
}
24882488
cbm_toon_scalar_int(&sb, "total", result.row_count);
2489+
if (result.warning) {
2490+
cbm_toon_scalar_str(&sb, "warning", result.warning);
2491+
}
24892492
if (result.row_count == 0) {
24902493
cbm_toon_scalar_str(&sb, "hint",
24912494
"Query returned no results. Use get_graph_schema() to see "
@@ -2515,6 +2518,9 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
25152518
}
25162519
yyjson_mut_obj_add_val(doc, root, "rows", rows);
25172520
yyjson_mut_obj_add_int(doc, root, "total", result.row_count);
2521+
if (result.warning) {
2522+
yyjson_mut_obj_add_str(doc, root, "warning", result.warning);
2523+
}
25182524

25192525
if (result.row_count == 0) {
25202526
yyjson_mut_obj_add_str(

‎src/store/store.c‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3274,6 +3274,12 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
32743274
}
32753275

32763276
snprintf(sql, sizeof(sql),
3277+
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) PAIRS,
3278+
* so a single self-loop minted every hop level for every node it
3279+
* could reach — walk-padding that fabricated *k..k Cypher matches
3280+
* of arbitrary length and exploded the row set to nodes x depth
3281+
* (#797). MIN(hop) GROUP BY node returns each node once at its
3282+
* minimal distance. */
32773283
"WITH RECURSIVE bfs(node_id, hop) AS ("
32783284
" SELECT %lld, 0"
32793285
" UNION"
@@ -3282,12 +3288,13 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
32823288
" JOIN edges e ON %s"
32833289
" WHERE e.type IN (%s) AND bfs.hop < %d"
32843290
")"
3285-
"SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, "
3286-
"n.file_path, n.start_line, n.end_line, n.properties, bfs.hop "
3291+
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
3292+
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
32873293
"FROM bfs "
32883294
"JOIN nodes n ON n.id = bfs.node_id "
3289-
"WHERE bfs.hop > 0 " /* exclude root */
3290-
"ORDER BY bfs.hop "
3295+
"WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */
3296+
"GROUP BY n.id "
3297+
"ORDER BY hop "
32913298
"LIMIT %d;",
32923299
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
32933300

‎tests/test_cypher.c‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,92 @@ TEST(cypher_exec_where_eq) {
508508
* call outright ("unexpected operator"); RETURN-side coalesce already
509509
* worked, so only the WHERE leaf needs it. Semantics: when the property is
510510
* missing/empty, the literal default is compared instead. */
511+
/* #797: variable-length / repeated-variable path semantics. Fixture:
512+
* loopy has a SELF-LOOP as one of its outbound CALLS edges plus a real
513+
* 2-chain loopy->mid->leaf. Correct openCypher semantics:
514+
* - a repeated node variable must unify: (a)-[:CALLS]->(a) matches ONLY
515+
* the self-loop, not every edge;
516+
* - relationship uniqueness within a path: the self-loop cannot be
517+
* traversed repeatedly, so no *k..k path exists beyond the real chain;
518+
* - the engine hop cap must not fabricate or silently truncate results. */
519+
TEST(cypher_exec_varlength_path_semantics_issue797) {
520+
cbm_store_t *s = cbm_store_open_memory();
521+
cbm_store_upsert_project(s, "test", "/tmp/test");
522+
cbm_node_t loopy = {.project = "test",
523+
.label = "Function",
524+
.name = "loopy",
525+
.qualified_name = "test.mod.loopy",
526+
.file_path = "mod.go",
527+
.start_line = 1,
528+
.end_line = 2};
529+
cbm_node_t mid = {.project = "test",
530+
.label = "Function",
531+
.name = "mid",
532+
.qualified_name = "test.mod.mid",
533+
.file_path = "mod.go",
534+
.start_line = 3,
535+
.end_line = 4};
536+
cbm_node_t leaf = {.project = "test",
537+
.label = "Function",
538+
.name = "leaf",
539+
.qualified_name = "test.mod.leaf",
540+
.file_path = "mod.go",
541+
.start_line = 5,
542+
.end_line = 6};
543+
int64_t id_loopy = cbm_store_upsert_node(s, &loopy);
544+
int64_t id_mid = cbm_store_upsert_node(s, &mid);
545+
int64_t id_leaf = cbm_store_upsert_node(s, &leaf);
546+
ASSERT_GT(id_loopy, 0);
547+
cbm_edge_t self_loop = {
548+
.project = "test", .source_id = id_loopy, .target_id = id_loopy, .type = "CALLS"};
549+
cbm_edge_t e1 = {
550+
.project = "test", .source_id = id_loopy, .target_id = id_mid, .type = "CALLS"};
551+
cbm_edge_t e2 = {.project = "test", .source_id = id_mid, .target_id = id_leaf, .type = "CALLS"};
552+
cbm_store_insert_edge(s, &self_loop);
553+
cbm_store_insert_edge(s, &e1);
554+
cbm_store_insert_edge(s, &e2);
555+
556+
/* Bug 1: repeated variable must unify — only the self-loop matches. */
557+
cbm_cypher_result_t r1 = {0};
558+
ASSERT_EQ(cbm_cypher_execute(s, "MATCH (a)-[:CALLS]->(a) RETURN a.name", "test", 0, &r1), 0);
559+
ASSERT_EQ(r1.row_count, 1);
560+
cbm_cypher_result_free(&r1);
561+
562+
/* Bug 2: *2..2 from loopy — only the REAL 2-chain (leaf); the self-loop
563+
* must not be reused to pad paths (relationship uniqueness). */
564+
cbm_cypher_result_t r2 = {0};
565+
ASSERT_EQ(cbm_cypher_execute(s,
566+
"MATCH (a {name: \"loopy\"})-[:CALLS*2..2]->(b) "
567+
"RETURN DISTINCT b.name",
568+
"test", 0, &r2),
569+
0);
570+
ASSERT_EQ(r2.row_count, 1); /* leaf only */
571+
cbm_cypher_result_free(&r2);
572+
573+
/* Bug 2 amplifier: no directed path of length 5 exists at all. */
574+
cbm_cypher_result_t r3 = {0};
575+
ASSERT_EQ(cbm_cypher_execute(s,
576+
"MATCH (a {name: \"loopy\"})-[:CALLS*5..5]->(b) "
577+
"RETURN b.name",
578+
"test", 0, &r3),
579+
0);
580+
ASSERT_EQ(r3.row_count, 0);
581+
cbm_cypher_result_free(&r3);
582+
583+
/* Bug 3: a hop range beyond the engine ceiling must be an ADVERTISED
584+
* clamp, not silently indistinguishable from "no such path". */
585+
cbm_cypher_result_t r4 = {0};
586+
ASSERT_EQ(
587+
cbm_cypher_execute(s, "MATCH (a)-[:CALLS*150..150]->(b) RETURN b.name", "test", 0, &r4), 0);
588+
ASSERT_EQ(r4.row_count, 0);
589+
ASSERT_NOT_NULL(r4.warning);
590+
ASSERT_NOT_NULL(strstr(r4.warning, "clamped"));
591+
cbm_cypher_result_free(&r4);
592+
593+
cbm_store_close(s);
594+
PASS();
595+
}
596+
511597
TEST(cypher_exec_where_coalesce_issue874) {
512598
cbm_store_t *s = cbm_store_open_memory();
513599
cbm_store_upsert_project(s, "test", "/tmp/test");
@@ -2782,6 +2868,7 @@ SUITE(cypher) {
27822868
RUN_TEST(cypher_issue252_tointeger);
27832869
RUN_TEST(cypher_issue305_count_star_alias);
27842870
RUN_TEST(cypher_exec_where_eq);
2871+
RUN_TEST(cypher_exec_varlength_path_semantics_issue797);
27852872
RUN_TEST(cypher_exec_where_coalesce_issue874);
27862873
RUN_TEST(cypher_exec_where_regex);
27872874
RUN_TEST(cypher_exec_where_contains);

0 commit comments

Comments
 (0)