Skip to content

test(query): executing with an empty bound batch keeps the result schema - #7

Open
fornwall wants to merge 1 commit into
mainfrom
test/bind-empty-batch-schema
Open

test(query): executing with an empty bound batch keeps the result schema#7
fornwall wants to merge 1 commit into
mainfrom
test/bind-empty-batch-schema

Conversation

@fornwall

@fornwall fornwall commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Fixes:

Split out of #4 (one PR per test). Adds one generic, driver-agnostic test for a gap found while reviewing an ADBC driver (the adbc-spanner Rust driver) against this suite; candidate for upstreaming to adbc-drivers/validation later.

TestStatement.test_parameter_execute_empty_bind (gated on statement_bind)

Implements the long-standing # TODO: also test with empty stream/empty batch in TestQuery.test_query (comment adjusted there): executes a parameterized SELECT with a zero-row bound batch and asserts the returned stream has zero rows but still carries the query's real result schema, identical to a non-empty execution of the same query.

  • Origin finding: adbc-spanner REVIEW.md COR-9 (bound query with zero total bound rows advertises an empty schema).
  • Spec reference: no ADBC spec text spells out the zero-bound-rows case (AdbcStatementBind says only that bind is for "bulk inserts or prepared statements"). This test asserts a self-consistency invariant, stated here explicitly rather than cited: the result schema is a property of the query, not of the number of bound rows, so an empty executemany parameter set must not change the schema a client sees.

Results on real drivers

  • adbc-spanner (current main): FAIL — verified by an actual emulator run via its foundry harness: the empty-bind execution returns an empty (0-column) schema instead of the query's schema (assertion: empty bind returned schema <empty>, but a non-empty execution returns : int64).
  • adbc-driver-sqlite 1.11.0 (released PyPI wheel): FAIL (segfault) — verified with a throwaway quirks harness: ExecuteQuery with the zero-row bound batch returns a stream whose import crashes the process (Fatal Python error: Segmentation fault inside pyarrow ImportRecordBatchReaderImportSchema). This is the released manifestation of the C driver-framework zero-row-bind bug just fixed in test(c/validation): add coverage for option, GetInfo and bind gaps found reviewing adbc-spanner arrow-adbc#21; the released 1.11.0 wheel predates that fix, so the test should start passing once a release containing it ships. The crash (rather than a clean wrong-schema failure) is stronger evidence that this case needs coverage.

Why this test binds an Arrow batch rather than using executemany

The zero-row bind has to reach the driver for this test to mean anything, and the
DBAPI layer deliberately prevents that for the most obvious spelling of it.
Cursor.executemany short-circuits an empty parameter set entirely
(dbapi.py#L981-L995, per apache/arrow-adbc#3319):

if arrow_parameters is not None:
    self._bind(arrow_parameters)
elif seq_of_parameters is not None:
    # NOTE(https://github.com/apache/arrow-adbc/issues/3319): If there
    # are no parameters, don't do anything.  ...  Note that for C Data
    # objects, we can't check the length so the driver might end up
    # having to handle them after all.
    return

So executemany(query, []) never binds and never executes -- no AdbcStatementBind,
no AdbcStatementExecuteQuery, nothing for a driver to get wrong. That path is safe
by construction and is not what this test covers.

Two things put the test on the driver-reaching path instead:

  • It binds a zero-row Arrow batch, not an empty Python sequence. The return
    above only fires for the latter. Arrow data takes the earlier
    _is_arrow_data branch and is bound unconditionally, with no
    row-count check -- the comment above notes why: for C Data objects the length
    can't be inspected without consuming the stream.
  • It uses execute_query, which produces a result set. executemany can afford
    to do nothing because it "does not generate a result set"
    (dbapi.py#L934); there is no schema to answer for. A query must
    still produce one, and ArrowArrayStream::get_schema is mandatory and invariant --
    which is exactly what the sqlite 1.11.0 segfault is: the driver had no schema to
    give and emitted a malformed one rather than failing cleanly.

The test therefore drives set_sql_query / prepare / bind / execute_query on
cursor.adbc_statement directly. Those are 1:1 with the C entry points, so despite
being written in Python this exercises the same call sequence a C or Go client would.

Framework checks

uv run pytest tests/ (210 passed, 1 skipped), uv run ty check (clean), pre-commit run --all-files (all hooks pass) — all on this branch independently.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LGf8PVEe2tYkw8Q6Pd95tq

Implements the 'empty stream/empty batch' TODO in TestQuery.test_query
as a self-contained statement-level test, gated on statement_bind:
test_parameter_execute_empty_bind executes a parameterized SELECT with
a zero-row bound batch (the DBAPI executemany empty-parameter-set
shape) and asserts the returned stream has zero rows but still carries
the query's real result schema, matching a non-empty execution of the
same query. The result schema is a property of the query, not of the
number of bound rows; the ADBC spec does not spell the zero-row case
out explicitly, so this asserts the self-consistency invariant.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant