From 53695310e78d8a4df7d763c8b4f76d046eeb2773 Mon Sep 17 00:00:00 2001 From: Dylan Storey Date: Sun, 1 Nov 2026 19:55:23 -0500 Subject: [PATCH] fix: MATCH ... CREATE ... RETURN creates once per row (GQLITE-T-0488) When the RETURN read no variable that only the CREATE bound, the MATCH+CREATE+RETURN handler ran MATCH+CREATE, then the MATCH again for the RETURN: a CREATE that bound no matched variable ran once (or for a MATCH with no rows), and the RETURN saw the new nodes. That fallback now runs in the row pipeline. --- src/backend/executor/query_dispatch.c | 13 ++++--- .../functional/106_create_per_row_return.sql | 39 +++++++++++++++++++ tests/metamorphic/known.json | 3 +- 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 tests/functional/106_create_per_row_return.sql diff --git a/src/backend/executor/query_dispatch.c b/src/backend/executor/query_dispatch.c index 5df09b28..4de60e86 100644 --- a/src/backend/executor/query_dispatch.c +++ b/src/backend/executor/query_dispatch.c @@ -2874,7 +2874,6 @@ static int handle_match_create_return(cypher_executor *executor, cypher_query *q { if (unwind_before_create(query) || create_props_need_pipeline(query)) return handle_rowwise_write(executor, query, result, flags); - (void)flags; cypher_match *match = find_match_clause(query); cypher_create *create = find_create_clause(query); cypher_return *ret = find_return_clause(query); @@ -3014,11 +3013,13 @@ static int handle_match_create_return(cypher_executor *executor, cypher_query *q return 0; } - int rc = execute_match_create_return_query(executor, match, create, ret, result); - if (rc >= 0) { - result->success = true; - } - return rc; + /* The legacy path ran MATCH + CREATE, then the MATCH again for the + * RETURN: the RETURN saw the new nodes, and a CREATE that bound no + * matched variable ran once for every row, or for no row + * (GQLITE-T-0488). The row pipeline runs the CREATE once per row and + * returns those rows. */ + (void)match; + return handle_rowwise_write(executor, query, result, flags); } static int handle_match_return(cypher_executor *executor, cypher_query *query, diff --git a/tests/functional/106_create_per_row_return.sql b/tests/functional/106_create_per_row_return.sql new file mode 100644 index 00000000..956118bf --- /dev/null +++ b/tests/functional/106_create_per_row_return.sql @@ -0,0 +1,39 @@ +-- ======================================================================== +-- Test 106: MATCH ... CREATE ... RETURN creates once per row +-- (GQLITE-T-0488) +-- ======================================================================== +-- PURPOSE: when the created pattern bound no variable that the RETURN read, +-- the CREATE ran once, and the RETURN ran the MATCH again after +-- the write (it saw the new node). A MATCH with no rows and an +-- aggregate RETURN still created a node. +-- NOTE: Assertions are hard: _assert has CHECK (ok = 1), so under +-- `sqlite3 -bail` any failed expectation aborts the run. +-- ======================================================================== + +.load ./build/graphqlite + +SELECT '=== Test 106: CREATE per row with RETURN ===' as test_section; + +CREATE TEMP TABLE _assert(name TEXT, ok INTEGER CHECK (ok = 1)); + +SELECT cypher('CREATE (:S106 {id: 1}), (:S106 {id: 2})') as setup; + +INSERT INTO _assert SELECT 'RETURN gives the MATCH rows only', + json(cypher('MATCH (a:S106) CREATE (a)-[:T]->(:N106) RETURN a.id AS id ORDER BY id')) + = json('[{"id":1},{"id":2}]'); +INSERT INTO _assert SELECT 'one node per row', + json(cypher('MATCH (n:N106) RETURN count(n) AS c')) = json('[{"c":2}]'); + +INSERT INTO _assert SELECT 'aggregate RETURN counts the rows', + json(cypher('MATCH (a:S106) CREATE (:M106 {k: a.id}) RETURN count(*) AS c')) + = json('[{"c":2}]'); +INSERT INTO _assert SELECT 'aggregate RETURN: one node per row', + json(cypher('MATCH (n:M106) RETURN count(n) AS c')) = json('[{"c":2}]'); + +INSERT INTO _assert SELECT 'no rows: count is 0', + json(cypher('MATCH (a:Missing106) CREATE (:Z106) RETURN count(*) AS c')) + = json('[{"c":0}]'); +INSERT INTO _assert SELECT 'no rows: nothing created', + json(cypher('MATCH (n:Z106) RETURN count(n) AS c')) = json('[{"c":0}]'); + +SELECT 'Test 106 passed: ' || count(*) || ' assertions' AS result FROM _assert; diff --git a/tests/metamorphic/known.json b/tests/metamorphic/known.json index 3b574bd8..132af2c6 100644 --- a/tests/metamorphic/known.json +++ b/tests/metamorphic/known.json @@ -10,6 +10,5 @@ {"relation": "^expr_context/(head|reduce|call)/", "kind": "mismatch", "task": "GQLITE-T-0482"}, {"relation": "^expr_context/.*/nested", "kind": "mismatch", "task": "GQLITE-T-0483"}, {"relation": "^expr_context/", "kind": "mismatch", "query": "reverse\\(", "task": "GQLITE-T-0483"}, - {"relation": "^expr_context/", "kind": "mismatch", "query": "reduce\\(acc = ''", "task": "GQLITE-T-0481"}, - {"relation": "^write_return/", "kind": "mismatch", "query": "CREATE (\\(\\w+\\)-\\[:\\w+[^\\]]*\\]->)?\\(:New", "task": "GQLITE-T-0488"} + {"relation": "^expr_context/", "kind": "mismatch", "query": "reduce\\(acc = ''", "task": "GQLITE-T-0481"} ]