Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions ladybug_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,43 @@ ladybug_bridge_fill_tuplestore_from_query(LadybugBridge *b,

if (num_cols != natts)
{
/*
* A successful statement that returns no result schema
* (num_cols == 0) -- e.g. a data CREATE/MERGE/DELETE without
* RETURN, or a CALL of a void procedure -- has already applied
* its side effect to the Ladybug engine. Raising an ERROR here
* would report a successful mutation as a failure and invite
* unsafe retries (see issue #6). Report success instead:
*
* - When the caller supplied a single TEXT column (the
* conventional "AS t(ok text)" status shape), synthesize one
* command-status row so the caller can confirm the command
* completed.
* - Otherwise, return zero rows: an honest empty result set,
* not a false failure.
*/
if (num_cols == 0)
{
lbug_query_result_destroy(&result);

if (natts == 1 &&
TupleDescAttr(tupdesc, 0)->atttypid == TEXTOID)
{
HeapTuple status_tuple;
Datum values[1];
bool nulls[1];

values[0] = CStringGetTextDatum("OK");
nulls[0] = false;
status_tuple = heap_form_tuple(tupdesc, values, nulls);
tuplestore_puttuple(ts, status_tuple);
heap_freetuple(status_tuple);
return 1;
}

return 0;
}

if (err_msg)
*err_msg = psprintf("ladybug: column count mismatch: query returns %d columns, expected %d",
num_cols, natts);
Expand Down Expand Up @@ -815,6 +852,45 @@ ladybug_bridge_execute_collect(LadybugBridge *b,

if (num_cols != natts)
{
/*
* A successful statement that returns no result schema
* (num_cols == 0) -- e.g. a data CREATE/MERGE/DELETE without
* RETURN, or a CALL of a void procedure -- has already applied
* its side effect to the Ladybug engine. Raising an ERROR here
* would report a successful mutation as a failure and invite
* unsafe retries (see issue #6). Report success instead:
*
* - When the caller supplied a single TEXT column (the
* conventional "AS t(ok text)" status shape), synthesize one
* command-status row so the caller can confirm the command
* completed.
* - Otherwise, return zero rows: an honest empty result set,
* not a false failure.
*/
if (num_cols == 0)
{
lbug_query_result_destroy(&result);

if (natts == 1 &&
TupleDescAttr(tupdesc, 0)->atttypid == TEXTOID)
{
HeapTuple status_tuple;
Datum values[1];
bool nulls[1];

values[0] = CStringGetTextDatum("OK");
nulls[0] = false;
status_tuple = heap_form_tuple(tupdesc, values, nulls);

*out_tuples = (HeapTuple *) palloc(sizeof(HeapTuple));
(*out_tuples)[0] = status_tuple;
return 1;
}

*out_tuples = NULL;
return 0;
}

if (err_msg)
*err_msg = psprintf("ladybug: column count mismatch: query returns %d columns, expected %d",
num_cols, natts);
Expand Down
44 changes: 43 additions & 1 deletion scripts/test_with_pgembed.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,48 @@ def check(o, e):
"SELECT ladybug.disable_replication('repl2') AS n",
env, check=lambda o, e: "1" in o)

# ================================================================
# Issue #6 regression: a successful zero-column Cypher statement
# (e.g. a data CREATE / MERGE / DELETE without RETURN) executes
# successfully in Ladybug and then must NOT be reported to the
# caller as a column-count-mismatch error. Before the fix,
# ladybug_bridge_execute_collect rejected any result whose
# column count didn't match the caller's column definition
# list, so a statement that legitimately returns 0 columns
# failed at the column-count check -- after the side effect
# had already landed, inviting unsafe retries.
#
# The whole sequence runs in ONE backend against a dedicated
# storage path: create the native node table (DDL returns a
# status column, already fine), then a data CREATE (returns 0
# columns -> synthesized "OK" status row for the conventional
# AS t(ok text) shape), then a MERGE (0 columns, but the
# caller's column list is int -> honest empty result, count 0,
# NOT an error), then a MATCH confirming both writes landed.
# All four statements share one psql -c so the store is
# created and reused in a single backend.
# ================================================================
ISSUE6_STORE = "/tmp/pglb_issue6.lbdb"
run_test("Issue #6: zero-column Cypher mutations succeed (not reported as errors)",
f"SET ladybug.storage_path = '{ISSUE6_STORE}';"
f"SET ladybug.pg_connstr = '{libpq_connstr}';"
"SELECT * FROM ladybug.cypher(" # create native node table (DDL)
"$$CREATE NODE TABLE City(id INT64, name STRING, PRIMARY KEY(id))$$)"
" AS t(ok text);"
"SELECT * FROM ladybug.cypher(" # data CREATE -> 0 columns -> "OK"
"$$CREATE (n:City {id: 1, name: 'Toronto'})$$)"
" AS t(ok text);"
"SELECT count(*)::int AS cnt FROM ladybug.cypher(" # MERGE -> 0 columns, int col -> empty
"$$MERGE (n:City {id: 2, name: 'Montreal'})$$)"
" AS t(dummy int);"
"SELECT * FROM ladybug.cypher(" # confirm both writes landed
"$$MATCH (n:City) RETURN n.id, n.name ORDER BY n.id$$)"
" AS t(id bigint, name text)",
env, check=lambda o, e: ("OK" in o
and "Toronto" in o
and "Montreal" in o
and "ERROR" not in e.upper()))

xfail_note = f" ({tests_xfail} xfail)" if tests_xfail else ""
print(f"\n=== {tests_passed}/{tests_total} tests passed{xfail_note} ===")
# All existing tests are required.
Expand All @@ -482,4 +524,4 @@ def check(o, e):


if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
Loading