diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index b8d689614..1905cfc3c 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -3013,31 +3013,14 @@ static void scan_alternation_labels(cbm_store_t *store, const char *project, con free(copy); } -static void scan_pattern_nodes(cbm_store_t *store, const char *project, int max_rows, - cbm_node_pattern_t *first, cbm_node_t **out_nodes, int *out_count) { +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_search_params_t params = {.project = project, - .min_degree = CYP_FOUND_NONE, - .max_degree = CYP_FOUND_NONE, - .limit = max_rows * CYP_GROWTH_10}; - cbm_search_output_t sout = {0}; - cbm_store_search(store, ¶ms, &sout); - *out_count = sout.count; - *out_nodes = malloc(sout.count * sizeof(cbm_node_t)); - for (int i = 0; i < sout.count; i++) { - (*out_nodes)[i] = sout.results[i].node; - sout.results[i].node.name = NULL; - sout.results[i].node.project = NULL; - sout.results[i].node.label = NULL; - sout.results[i].node.qualified_name = NULL; - sout.results[i].node.file_path = NULL; - sout.results[i].node.properties_json = NULL; - } - cbm_store_search_free(&sout); + cbm_store_find_nodes(store, project, out_nodes, out_count); } /* Apply inline property filters — free rejected nodes' strings */ if (first->prop_count > 0) { @@ -4593,7 +4576,7 @@ static void expand_additional_patterns(cbm_store_t *store, cbm_query_t *q, const cbm_node_t *extra_nodes = NULL; int extra_count = 0; - scan_pattern_nodes(store, project, max_rows, &patn->nodes[0], &extra_nodes, &extra_count); + scan_pattern_nodes(store, project, &patn->nodes[0], &extra_nodes, &extra_count); if (patn->rel_count == 0) { cross_join_nodes(bindings, bind_count, extra_nodes, extra_count, nvar, opt); } else { @@ -4640,7 +4623,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec /* Step 1: Scan initial nodes */ cbm_node_t *scanned = NULL; int scan_count = 0; - scan_pattern_nodes(store, project, max_rows, &pat0->nodes[0], &scanned, &scan_count); + 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); diff --git a/src/store/store.c b/src/store/store.c index 39a546dd3..badf5f8cb 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -115,6 +115,7 @@ struct cbm_store { sqlite3_stmt *stmt_find_node_by_id; sqlite3_stmt *stmt_find_node_by_qn; sqlite3_stmt *stmt_find_node_by_qn_any; /* QN lookup without project filter */ + sqlite3_stmt *stmt_find_nodes; sqlite3_stmt *stmt_find_nodes_by_name; sqlite3_stmt *stmt_find_nodes_by_name_any; /* name lookup without project filter */ sqlite3_stmt *stmt_find_nodes_by_label; @@ -1019,6 +1020,7 @@ void cbm_store_close(cbm_store_t *s) { finalize_stmt(&s->stmt_find_node_by_id); finalize_stmt(&s->stmt_find_node_by_qn); finalize_stmt(&s->stmt_find_node_by_qn_any); + finalize_stmt(&s->stmt_find_nodes); finalize_stmt(&s->stmt_find_nodes_by_name); finalize_stmt(&s->stmt_find_nodes_by_name_any); finalize_stmt(&s->stmt_find_nodes_by_label); @@ -1708,7 +1710,9 @@ static int find_nodes_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *s } bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, val); + if (val) { + bind_text(stmt, ST_COL_2, val); + } int cap = ST_INIT_CAP_16; int n = 0; @@ -1736,6 +1740,19 @@ static int find_nodes_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *s return CBM_STORE_OK; } +int cbm_store_find_nodes(cbm_store_t *s, const char *project, cbm_node_t **out, int *count) { + if (!s) { + *out = NULL; + *count = 0; + return CBM_STORE_ERR; + } + return find_nodes_generic(s, &s->stmt_find_nodes, + "SELECT id, project, label, name, qualified_name, file_path, " + "start_line, end_line, properties FROM nodes " + "WHERE project = ?1;", + project, NULL, out, count); +} + int cbm_store_find_nodes_by_name(cbm_store_t *s, const char *project, const char *name, cbm_node_t **out, int *count) { if (!s) { diff --git a/src/store/store.h b/src/store/store.h index 55be4a48b..5185ac0f6 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -327,6 +327,9 @@ int cbm_store_find_node_by_qn(cbm_store_t *s, const char *project, const char *q /* Find node by qualified_name only (no project filter — QNs are globally unique). */ int cbm_store_find_node_by_qn_any(cbm_store_t *s, const char *qn, cbm_node_t *out); +/* Find all nodes in a project. Returns allocated array, caller frees. */ +int cbm_store_find_nodes(cbm_store_t *s, const char *project, cbm_node_t **out, int *count); + /* Find nodes by name (exact match). Returns allocated array, caller frees. */ int cbm_store_find_nodes_by_name(cbm_store_t *s, const char *project, const char *name, cbm_node_t **out, int *count); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 0865ee008..ec26c7982 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -506,6 +506,46 @@ TEST(cypher_exec_where_eq) { PASS(); } +/* #1196: max_rows limits projected results, not the source-node candidates + * considered before WHERE. The old unlabeled scan searched only + * 10 * max_rows nodes, so a valid match later in search order disappeared. */ +TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + for (int i = 0; i < 11; i++) { + char name[32]; + char qn[64]; + snprintf(name, sizeof(name), "early_%02d", i); + snprintf(qn, sizeof(qn), "test.%s", name); + cbm_node_t distractor = {.project = "test", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "early.py"}; + ASSERT_GT(cbm_store_upsert_node(s, &distractor), 0); + } + + cbm_node_t late = {.project = "test", + .label = "Function", + .name = "zz_late_match", + .qualified_name = "test.zz_late_match", + .file_path = "late.py"}; + ASSERT_GT(cbm_store_upsert_node(s, &late), 0); + + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute( + s, "MATCH (n) WHERE n.name = \"zz_late_match\" RETURN n.name", "test", 1, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "zz_late_match"); + + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + /* #874: coalesce(var.prop, literal) in WHERE — null-safe numeric filters * for audit queries over OPTIONAL graph properties. The parser rejected the * call outright ("unexpected operator"); RETURN-side coalesce already @@ -3201,6 +3241,7 @@ SUITE(cypher) { RUN_TEST(cypher_issue252_tointeger); RUN_TEST(cypher_issue305_count_star_alias); RUN_TEST(cypher_exec_where_eq); + RUN_TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196); RUN_TEST(cypher_exec_varlength_path_semantics_issue797); RUN_TEST(cypher_exec_where_coalesce_issue874); RUN_TEST(cypher_exec_where_regex);