@@ -580,6 +580,88 @@ TEST(cypher_exec_optional_rel_saturated_no_overflow) {
580580 PASS ();
581581}
582582
583+ /* Reproduce-first: after the expansion budget is exhausted, OPTIONAL MATCH must
584+ * not FABRICATE a "no match" row for a source that genuinely has matches.
585+ *
586+ * process_edges / expand_var_length used to carry the budget in the LOOP
587+ * condition (`ei < edge_count && *new_count < max_new`), so once new_count hit
588+ * max_new they stopped iterating entirely and never incremented match_count —
589+ * even though neighbours existed. expand_pattern_rels' ungated fallback then saw
590+ * match_count == 0 and emitted an unbound row. `WHERE b IS NULL` reads that as
591+ * "nothing points here", so a dead-code query reported LIVE code as dead.
592+ *
593+ * Shape: A saturates the budget, B genuinely has no callees, C has 5. Only B may
594+ * appear with an unbound b. Asserting on C specifically is what discriminates —
595+ * the pre-fix code emits C with an empty b, and a `row_count` check would not
596+ * notice. Deterministic: insertion order fixes the scan order (rowid), and every
597+ * count is exact. */
598+ TEST (cypher_exec_optional_saturated_does_not_fabricate_no_match ) {
599+ cbm_store_t * s = cbm_store_open_memory ();
600+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
601+
602+ /* 3 Function nodes → scan_count = 3; max_rows = 3 → bind_cap = 3 and
603+ * max_new = 30. A alone exceeds that, so B and C are reached with the
604+ * budget already spent — the regime that produced the fabrication. */
605+ cbm_node_t a = {.project = "test" , .label = "Function" , .name = "A" , .qualified_name = "test.A" };
606+ cbm_node_t b = {.project = "test" , .label = "Function" , .name = "B" , .qualified_name = "test.B" };
607+ cbm_node_t c = {.project = "test" , .label = "Function" , .name = "C" , .qualified_name = "test.C" };
608+ int64_t a_id = cbm_store_upsert_node (s , & a );
609+ (void )cbm_store_upsert_node (s , & b ); /* B: no outgoing CALLS at all */
610+ int64_t c_id = cbm_store_upsert_node (s , & c );
611+ ASSERT_GT (a_id , 0 );
612+ ASSERT_GT (c_id , 0 );
613+
614+ /* Callees are label Var so they do not inflate scan_count/bind_cap. */
615+ for (int i = 0 ; i < 40 ; i ++ ) {
616+ char nm [32 ];
617+ char qn [48 ];
618+ snprintf (nm , sizeof (nm ), "acallee%d" , i );
619+ snprintf (qn , sizeof (qn ), "test.acallee%d" , i );
620+ cbm_node_t t = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
621+ int64_t tid = cbm_store_upsert_node (s , & t );
622+ cbm_edge_t e = {.project = "test" , .source_id = a_id , .target_id = tid , .type = "CALLS" };
623+ cbm_store_insert_edge (s , & e );
624+ }
625+ for (int i = 0 ; i < 5 ; i ++ ) {
626+ char nm [32 ];
627+ char qn [48 ];
628+ snprintf (nm , sizeof (nm ), "ccallee%d" , i );
629+ snprintf (qn , sizeof (qn ), "test.ccallee%d" , i );
630+ cbm_node_t t = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
631+ int64_t tid = cbm_store_upsert_node (s , & t );
632+ cbm_edge_t e = {.project = "test" , .source_id = c_id , .target_id = tid , .type = "CALLS" };
633+ cbm_store_insert_edge (s , & e );
634+ }
635+
636+ cbm_cypher_result_t r = {0 };
637+ int rc = cbm_cypher_execute (
638+ s , "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) WHERE g IS NULL RETURN f.name" ,
639+ "test" , 3 , & r );
640+ ASSERT_EQ (rc , 0 );
641+
642+ /* Positive control: B genuinely has no callees, so the query must still find
643+ * it. Without this a "C is absent" assertion could pass on an empty result. */
644+ bool saw_b = false;
645+ bool saw_c = false;
646+ for (int i = 0 ; i < r .row_count ; i ++ ) {
647+ const char * name = r .rows [i ][0 ];
648+ if (name && strcmp (name , "B" ) == 0 ) {
649+ saw_b = true;
650+ }
651+ if (name && strcmp (name , "C" ) == 0 ) {
652+ saw_c = true;
653+ }
654+ }
655+ ASSERT_TRUE (saw_b );
656+ /* The discriminator: C has 5 callees, so claiming it has none is a
657+ * fabrication. Pre-fix this is exactly what the saturated path emitted. */
658+ ASSERT_FALSE (saw_c );
659+
660+ cbm_cypher_result_free (& r );
661+ cbm_store_close (s );
662+ PASS ();
663+ }
664+
583665/* Arithmetic-boundary companion to the zero-label overflow above: the node
584666 * cross-join sizes its buffer from bind_count * extra_count. As a plain int that
585667 * product wraps past INT_MAX to a negative/garbage malloc size (the large-graph
@@ -3491,6 +3573,7 @@ SUITE(cypher) {
34913573 RUN_TEST (cypher_exec_optional_empty_label_no_overflow );
34923574 RUN_TEST (cypher_cross_join_alloc_rejects_overflow );
34933575 RUN_TEST (cypher_exec_optional_rel_saturated_no_overflow );
3576+ RUN_TEST (cypher_exec_optional_saturated_does_not_fabricate_no_match );
34943577 RUN_TEST (cypher_exec_optional_rel_leaf_fallback_survives );
34953578 RUN_TEST (cypher_issue240_labels_function );
34963579 RUN_TEST (cypher_issue237_distinct_order_limit );
0 commit comments