From 72dc59fed58444ca7d5e7a4749fb767c28e48b94 Mon Sep 17 00:00:00 2001 From: Edson Date: Thu, 1 Oct 2026 10:54:24 -0400 Subject: [PATCH 1/5] fix(haskell): name infix-form definitions after the operator (#2440) A Haskell definition written in infix form (`a <+> b = ...`, ``x `plus` y = ...``) has no `name` field. resolve_haskell_func_name took the first child of its `infix` head, which is the left operand, so the def was indexed as `a` / `x` and the operator itself got no node. Resolve the head's `operator` field instead: a backticked `infix_id` yields its inner variable (`plus`), and a symbolic `operator` is parenthesised in cbm_func_name_node_text (`(<+>)`), matching the name the prefix form `(<+>) a b = ...` already gets. The def extractor and the call-scope QN both go through these two functions, so they stay in agreement. Fixes #2440 Signed-off-by: Edson --- internal/cbm/extract_defs.c | 30 ++++++++++++++++++++++++++++++ internal/cbm/helpers.c | 8 ++++++++ tests/test_extraction.c | 28 ++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b655d06c1..ac8d449f0 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -405,6 +405,33 @@ static TSNode resolve_lean_func_name(TSNode node, TSNode name) { return cbm_find_child_by_kind(node, "ident"); } +// Haskell infix-form definition head (`a <+> b`, ``x `plus` y``): the defined +// name is the operator, not the left operand that is the head's first child. A +// backticked `infix_id` yields its inner variable (`plus`); a symbolic `operator` +// is returned as-is and parenthesised by cbm_func_name_node_text, matching the +// prefix form `(<+>) a b = ...` (#2440). +static TSNode resolve_haskell_infix_head_name(TSNode head) { + enum { INFIX_MIN_CHILDREN = 3, INFIX_OP_IDX = 1 }; + TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator")); + if (ts_node_is_null(op) && ts_node_child_count(head) >= INFIX_MIN_CHILDREN) { + op = ts_node_child(head, INFIX_OP_IDX); + } + if (!ts_node_is_null(op)) { + const char *ok = ts_node_type(op); + if (strcmp(ok, "operator") == 0) { + return op; + } + if (strcmp(ok, "infix_id") == 0) { + TSNode v = cbm_find_child_by_kind(op, "variable"); + if (!ts_node_is_null(v)) { + return v; + } + } + } + TSNode null_node = {0}; + return null_node; +} + // Haskell: resolve function name from first named child (variable/name). static TSNode resolve_haskell_func_name(TSNode node) { if (ts_node_named_child_count(node) > 0) { @@ -413,6 +440,9 @@ static TSNode resolve_haskell_func_name(TSNode node) { if (strcmp(hk, "variable") == 0 || strcmp(hk, "name") == 0) { return head; } + if (strcmp(hk, "infix") == 0) { + return resolve_haskell_infix_head_name(head); + } if (ts_node_named_child_count(head) > 0) { TSNode v = ts_node_named_child(head, 0); const char *vk = ts_node_type(v); diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 4c4d340f0..630e8f038 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1042,6 +1042,14 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, if (text && lang == CBM_LANG_NIX) { cbm_nix_strip_attr_quotes(text); } + /* Haskell infix-form operator definition: `a <+> b = …` resolves to the bare + * `operator` node `<+>`, while the prefix form `(<+>) a b = …` is named by its + * `prefix_id` text `(<+>)`. Parenthesise here, not in the resolver, so both + * forms mint the same name and the def name and the call-scope QN, which both + * route through this function, cannot disagree (#2440). */ + if (text && lang == CBM_LANG_HASKELL && strcmp(ts_node_type(name_node), "operator") == 0) { + return cbm_arena_sprintf(a, "(%s)", text); + } return text; } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5a21fe3e2..a6aa82cf7 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1377,6 +1377,33 @@ TEST(haskell_function) { PASS(); } +/* #2440: an infix-form definition (`a <+> b = ...`, ``x `plus` y = ...``) has + * no `name` field; its first named child is an `infix` node whose first child + * is the LEFT OPERAND, so the def was named `a` / `x`. It must be named after + * the operator, consistent with the prefix form `(<+>) a b = ...`. */ +TEST(haskell_infix_definition_name) { + CBMFileResult *r = extract("module M where\n" + "\n" + "(<+>) :: Int -> Int -> Int\n" + "a <+> b = a + b\n" + "\n" + "plus :: Int -> Int -> Int\n" + "x `plus` y = x + y\n" + "\n" + "f :: Int\n" + "f = 1 <+> 2\n", + CBM_LANG_HASKELL, "t", "M.hs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "f")); + ASSERT(has_def(r, "Function", "plus")); + ASSERT(has_def(r, "Function", "(<+>)")); + ASSERT_FALSE(has_def(r, "Function", "a")); + ASSERT_FALSE(has_def(r, "Function", "x")); + cbm_free_result(r); + PASS(); +} + /* --- OCaml --- */ TEST(ocaml_function) { CBMFileResult *r = @@ -8954,6 +8981,7 @@ SUITE(extraction) { RUN_TEST(elixir_function); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); + RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From 937ad47e55706e086777a8690008bb202199c883 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sat, 3 Oct 2026 13:50:43 +0800 Subject: [PATCH 2/5] fix(cypher): defer WHERE until referenced bindings exist Signed-off-by: lorenzozanee --- src/cypher/cypher.c | 52 +++++++++++++++++++++++++- tests/test_cypher.c | 89 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 3cc31f9d7..2a18db7e8 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4145,7 +4145,54 @@ static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, in } } -/* Apply post-WITH WHERE filter */ +static bool condition_bindings_available(const cbm_condition_t *condition, binding_t *binding) { + if (condition->variable && !binding_get(binding, condition->variable) && + !binding_get_edge(binding, condition->variable)) { + return false; + } + for (int i = 0; i < condition->arg_count; i++) { + const char *var = condition->args[i].variable; + if (var && !binding_get(binding, var) && !binding_get_edge(binding, var)) { + return false; + } + } + return true; +} + +static bool expr_bindings_available(const cbm_expr_t *expr, binding_t *binding) { + if (!expr) { + return true; + } + switch (expr->type) { + case EXPR_CONDITION: + return condition_bindings_available(&expr->cond, binding); + case EXPR_AND: + case EXPR_OR: + case EXPR_XOR: + return expr_bindings_available(expr->left, binding) && + expr_bindings_available(expr->right, binding); + case EXPR_NOT: + return expr_bindings_available(expr->left, binding); + } + return false; +} + +static bool where_bindings_available(const cbm_where_clause_t *where, binding_t *binding) { + if (!where) { + return true; + } + if (where->root) { + return expr_bindings_available(where->root, binding); + } + for (int i = 0; i < where->count; i++) { + if (!condition_bindings_available(&where->conditions[i], binding)) { + return false; + } + } + return true; +} + +/* Apply a WHERE filter after its referenced variables have been bound. */ static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings, int *vcount) { int kept = 0; @@ -5149,7 +5196,8 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); - bool pass = !q->where || eval_where(q->where, &b); + bool pass = !q->where || !where_bindings_available(q->where, &b) || + eval_where(q->where, &b); if (pass) { bindings[bind_count++] = b; } else { diff --git a/tests/test_cypher.c b/tests/test_cypher.c index abd334683..b8e4ddb1f 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -1757,6 +1757,32 @@ TEST(cypher_exists_has_outgoing_calls) { ASSERT_EQ(rc, 0); ASSERT_EQ(r.row_count, 2); cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name <> \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name = \"SubmitOrder\" OR " + "NOT b.name = \"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "3"); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = " + "\"SubmitOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); PASS(); } @@ -3150,6 +3176,65 @@ TEST(cypher_exec_where_not) { PASS(); } +TEST(cypher_exec_where_not_target_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_relationship) { + cbm_store_t *s = setup_cypher_multi_edge_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[r:HTTP_CALLS]->(b) WHERE NOT r.url_path = " + "\"/health\" RETURN r.url_path", + "testproj", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "/api/orders"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_source_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = " + "\"HandleOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_node_count) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = " + "\"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "2"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -5005,6 +5090,10 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_neq_bang); RUN_TEST(cypher_exec_where_ends_with); RUN_TEST(cypher_exec_where_not); + RUN_TEST(cypher_exec_where_not_target_node); + RUN_TEST(cypher_exec_where_not_target_relationship); + RUN_TEST(cypher_exec_where_not_source_node); + RUN_TEST(cypher_exec_where_not_target_node_count); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null); From f1e36b583d3a21664b373f854466c676897a5560 Mon Sep 17 00:00:00 2001 From: H4M4CHi-ttr <42773515+H4M4CHi-ttr@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:49:39 +0900 Subject: [PATCH 3/5] fix(watcher): inspect repository and cache paths as UTF-8 Use existing UTF-8 filesystem inspection for watcher roots, .git markers, and cached graph databases. Preserve symlink following and distinguish confirmed absence from unavailable inspection before pruning projects. Cover an existing Unicode root with zero prune grace and capture the blocked-git test environment through the existing UTF-8 runtime helper. Split the independently applicable bug fix from the original certified EmbeddingGemma 2 contribution without changing its file contents. Signed-off-by: H4M4CHi-ttr <42773515+H4M4CHi-ttr@users.noreply.github.com> --- src/daemon/application.c | 4 ++-- src/watcher/watcher.c | 36 +++++++++++++++++++++++++--------- tests/test_watcher.c | 42 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index ee121b5c6..581d435a5 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -448,8 +448,8 @@ static bool application_regular_db_exists(const char *project) { if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat status; - return stat(path, &status) == 0 && S_ISREG(status.st_mode); + cbm_path_info_t status; + return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular; } static bool application_canonical_directory_exists(const char *path) { diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index f8d04c4c6..ac7c47b7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s * a `.git` file (the gitlink form used by linked worktrees and initialized * submodules). Deliberately a filesystem check, not a git invocation — the whole * point is to learn something `rev-parse` cannot tell us, because it walks up. */ +/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */ +static int watcher_path_info(const char *path, cbm_path_info_t *info) { + int status = cbm_path_info_utf8(path, info); + if (status == CBM_PATH_INFO_OK && info->is_symlink) { + char canonical[CBM_SZ_4K]; + if (!cbm_canonical_path(path, canonical, sizeof(canonical))) { + return CBM_PATH_INFO_UNAVAILABLE; + } + status = cbm_path_info_utf8(canonical, info); + } + return status; +} + static bool git_has_own_dot_git(const char *root_path) { char path[CBM_SZ_4K]; int written = snprintf(path, sizeof(path), "%s/.git", root_path); if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat st; - return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode)); + cbm_path_info_t info; + return watcher_path_info(path, &info) == CBM_PATH_INFO_OK && + (info.is_directory || info.is_regular); } /* Does the ancestor repository actually track anything inside this directory? @@ -990,13 +1004,17 @@ static root_status_t root_status(const char *root_path, int *out_errno) { if (!root_path) { return ROOT_UNCERTAIN; } - struct stat st; - if (stat(root_path, &st) == 0) { - /* Exists but is no longer a directory → the root directory is gone. */ - return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING; + cbm_path_info_t info; + int status = watcher_path_info(root_path, &info); + if (status == CBM_PATH_INFO_OK) { + return info.is_directory ? ROOT_PRESENT : ROOT_MISSING; } +#ifdef _WIN32 + *out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO; +#else *out_errno = errno; - return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN; +#endif + return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN; } /* Sustained-absence window (seconds) before a missing root may be pruned. @@ -1338,8 +1356,8 @@ static void publish_status(project_state_t *s, bool scanned) { /* Init baseline for a project: check if git, get HEAD, count files */ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { - struct stat st; - if (stat(s->root_path, &st) != 0) { + cbm_path_info_t info; + if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); s->baseline_done = true; s->is_git = false; diff --git a/tests/test_watcher.c b/tests/test_watcher.c index e0b78cffc..8be483ca8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1103,8 +1103,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { watcher_windows_image_identity_t expected_git; ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git)); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -1238,8 +1238,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { ASSERT_TRUE(watcher_test_self_image(self)); ASSERT_EQ(symlink(self, fake_git), 0); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -3995,7 +3995,41 @@ TEST(watcher_null_watch_count) { * SUITE * ══════════════════════════════════════════════════════════════════ */ +TEST(watcher_unicode_root_is_not_pruned_as_missing) { + char *parent = th_mktempdir("cbm_watch_utf8"); + ASSERT_NOT_NULL(parent); + char root[CBM_SZ_4K]; + snprintf(root, sizeof(root), "%s/\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E", parent); + ASSERT_EQ(th_mkdir_p(root), 0); + const char *saved = cbm_runtime_getenv("CBM_WATCHER_PRUNE_GRACE_S"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1); + cbm_store_t *store = cbm_store_open_memory(); + cbm_store_upsert_project(store, "unicode-root", root); + cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); + cbm_watcher_watch(watcher, "unicode-root", root); + for (int i = 0; i < 8; i++) { + cbm_watcher_poll_once(watcher); + } + cbm_project_t info = {0}; + bool kept = cbm_watcher_watch_count(watcher) == 1 && + cbm_store_get_project(store, "unicode-root", &info) == CBM_STORE_OK; + cbm_project_free_fields(&info); + cbm_watcher_free(watcher); + cbm_store_close(store); + if (saved_copy) { + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_copy, 1); + } else { + cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_copy); + ASSERT_EQ(th_rmtree(parent), 0); + ASSERT_TRUE(kept); + PASS(); +} + SUITE(watcher) { + RUN_TEST(watcher_unicode_root_is_not_pruned_as_missing); /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling); From 43a7e592027d3f4282cdb0314d92c142fca0165a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:23:03 +0200 Subject: [PATCH 4/5] revert(cypher): drop the early-WHERE deferral that #2253 superseded The early WHERE pass in execute_single runs on seed rows where only the first alias is bound, so a NOT on the target node or the relationship pruned every row (#2250). This branch fixed that by skipping the early pass until every alias the WHERE reads was bound. While it waited for review, #2253 (8ab555ee) landed the same fix on main as a three-valued early pass: an unbound alias is UNKNOWN, NOT keeps it UNKNOWN, and only a definite FALSE prunes. This branch's regression tests pass on main's production code unchanged, so restore src/cypher/cypher.c to the branch's base and keep the tests; the merge of main that follows takes main's implementation. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 52 ++------------------------------------------- 1 file changed, 2 insertions(+), 50 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 2a18db7e8..3cc31f9d7 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4145,54 +4145,7 @@ static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, in } } -static bool condition_bindings_available(const cbm_condition_t *condition, binding_t *binding) { - if (condition->variable && !binding_get(binding, condition->variable) && - !binding_get_edge(binding, condition->variable)) { - return false; - } - for (int i = 0; i < condition->arg_count; i++) { - const char *var = condition->args[i].variable; - if (var && !binding_get(binding, var) && !binding_get_edge(binding, var)) { - return false; - } - } - return true; -} - -static bool expr_bindings_available(const cbm_expr_t *expr, binding_t *binding) { - if (!expr) { - return true; - } - switch (expr->type) { - case EXPR_CONDITION: - return condition_bindings_available(&expr->cond, binding); - case EXPR_AND: - case EXPR_OR: - case EXPR_XOR: - return expr_bindings_available(expr->left, binding) && - expr_bindings_available(expr->right, binding); - case EXPR_NOT: - return expr_bindings_available(expr->left, binding); - } - return false; -} - -static bool where_bindings_available(const cbm_where_clause_t *where, binding_t *binding) { - if (!where) { - return true; - } - if (where->root) { - return expr_bindings_available(where->root, binding); - } - for (int i = 0; i < where->count; i++) { - if (!condition_bindings_available(&where->conditions[i], binding)) { - return false; - } - } - return true; -} - -/* Apply a WHERE filter after its referenced variables have been bound. */ +/* Apply post-WITH WHERE filter */ static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings, int *vcount) { int kept = 0; @@ -5196,8 +5149,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); - bool pass = !q->where || !where_bindings_available(q->where, &b) || - eval_where(q->where, &b); + bool pass = !q->where || eval_where(q->where, &b); if (pass) { bindings[bind_count++] = b; } else { From 6d2d30c3ea2191adfc000f57d31a95d14b7b290b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:23:17 +0200 Subject: [PATCH 5/5] fix(cypher): the early WHERE pass waits for every alias a condition reads eval_condition_partial (from #2253) answers UNKNOWN for a condition whose alias is not bound yet, but it only looked at c->variable. A function condition is filed under its first argument's alias, so in MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = 'x' the seed row saw a bound a, read coalesce(NULL, ) as a definite mismatch and pruned every row before CALLS expansion: zero rows, while the same predicate after WITH returned the right ones. Check every function argument's alias as well, the way this branch's original fix did (condition_bindings_available), inside main's three-valued leaf. RED before: cypher_exec_where_func_arg_on_unbound_target, row_count 0, expected 1. GREEN after; cypher suite 237 passed. Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 17 ++++++++++++++--- tests/test_cypher.c | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 8eaae7a26..c86be43bf 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2899,12 +2899,23 @@ static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) { : 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 bool partial_alias_unbound(binding_t *b, const char *alias) { + return alias && !binding_get(b, alias) && !binding_get_edge(b, alias); +} + +/* A condition is UNKNOWN while any alias it reads is not bound yet: its own + * variable, and every function argument's -- a func condition is filed under + * its first argument's alias, so the b in coalesce(a.x, b.name) is only seen + * here. A literal-only LHS 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)) { + if (partial_alias_unbound(b, c->variable)) { return CYP_PARTIAL_UNKNOWN; } + for (int i = 0; i < c->arg_count; i++) { + if (partial_alias_unbound(b, c->args[i].variable)) { + return CYP_PARTIAL_UNKNOWN; + } + } return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; } diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 06c3131da..ec3afbfc2 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -3463,6 +3463,27 @@ TEST(cypher_exec_where_not_target_node_count) { PASS(); } +/* The early WHERE pass must wait for every alias a condition reads. A function + * condition is filed under its first argument's alias, so in + * coalesce(a.no_such_prop, b.name) the target b hid behind the bound a: the + * seed row read coalesce(NULL, ) as a definite mismatch and pruned + * every row before CALLS expansion, while the same predicate after WITH was + * right. */ +TEST(cypher_exec_where_func_arg_on_unbound_target) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = " + "\"SubmitOrder\" RETURN a.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -6332,6 +6353,7 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_not_target_relationship); RUN_TEST(cypher_exec_where_not_source_node); RUN_TEST(cypher_exec_where_not_target_node_count); + RUN_TEST(cypher_exec_where_func_arg_on_unbound_target); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null);