Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/include/duckdb_python/pyconnection/pyconnection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ struct DuckDBPyConnection : public std::enable_shared_from_this<DuckDBPyConnecti

Optional<nb::list> GetDescription();

int GetRowcount();
int64_t GetRowcount();

// these should be functions on the result but well
Optional<nb::tuple> FetchOne();
Expand Down
11 changes: 11 additions & 0 deletions src/include/duckdb_python/pyrelation.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ struct DuckDBPyRelation {

nb::list Description();

//! Cached at construction time (see the DuckDBPyResult constructor overload) so that it survives
//! the Fetch*() methods below, several of which null out `result` once fully consumed. Note this
//! is only ever non-default for relations built directly from a DuckDBPyResult (the connection's
//! execute()/executemany() path) - relations built from a lazy Relation (rel != nullptr, executed
//! via ExecuteOrThrow) are always SELECT-shaped and so never produce a CHANGED_ROWS value anyway.
int64_t GetRowcount() const {
return row_changes;
}

void Close();

std::unique_ptr<DuckDBPyRelation> GetAttribute(const string &name);
Expand Down Expand Up @@ -300,6 +309,8 @@ struct DuckDBPyRelation {
vector<string> names;
std::shared_ptr<DuckDBPyResult> result;
std::string rendered_result;
//! Cached row-changed count - see GetRowcount().
int64_t row_changes = -1;
};

} // namespace duckdb
14 changes: 14 additions & 0 deletions src/include/duckdb_python/pyresult.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ struct DuckDBPyResult {

ClientProperties GetClientProperties();

//! Number of rows changed by the last CHANGED_ROWS-returning statement (INSERT/UPDATE/DELETE/...).
//! Returns -1 when not applicable/unknown, as permitted by the DB-API 2.0 spec for 'rowcount'.
int64_t GetRowcount() const {
return row_changes;
}

private:
void FillNumpy(nb::dict &res, idx_t col_idx, NumpyResultConversion &conversion, const char *name);

Expand All @@ -82,6 +88,12 @@ struct DuckDBPyResult {
duckdb::pyarrow::Table MaterializedResultToArrowTable(const ArrowSchema &arrow_schema, idx_t rows_per_batch);
ArrowArrayStream FetchArrowArrayStream(idx_t rows_per_batch);

//! Computes the CHANGED_ROWS value (if any) up front, before any Fetch call has had a chance to
//! consume it, so that later fetch calls do not affect what GetRowcount() reports. Materializes a
//! streaming result if necessary; this is cheap since CHANGED_ROWS results are always exactly one
//! already-computed row.
int64_t ComputeRowChanges();

private:
idx_t chunk_offset = 0;

Expand All @@ -92,6 +104,8 @@ struct DuckDBPyResult {
// Holds the categorical type of Categorical/ENUM types
unordered_map<idx_t, nb::object> categories_type;
bool result_closed = false;
//! Cached by ComputeRowChanges() at construction time - see GetRowcount().
int64_t row_changes = -1;
};

} // namespace duckdb
9 changes: 7 additions & 2 deletions src/pyconnection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1920,8 +1920,13 @@ Optional<nb::list> DuckDBPyConnection::GetDescription() {
return result.Description();
}

int DuckDBPyConnection::GetRowcount() {
return -1;
int64_t DuckDBPyConnection::GetRowcount() {
ConnectionLockGuard conn_lock(*this);
if (!con.HasResult()) {
return -1;
}
auto &result = con.GetResult();
return result.GetRowcount();
}

void DuckDBPyConnection::Close() {
Expand Down
1 change: 1 addition & 0 deletions src/pyrelation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ DuckDBPyRelation::DuckDBPyRelation(std::shared_ptr<DuckDBPyResult> result_p)
this->executed = true;
this->types = result->GetTypes();
this->names = result->GetNames();
this->row_changes = result->GetRowcount();
}

std::unique_ptr<DuckDBPyRelation> DuckDBPyRelation::ProjectFromExpression(const string &expression) {
Expand Down
40 changes: 40 additions & 0 deletions src/pyresult.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ DuckDBPyResult::DuckDBPyResult(unique_ptr<QueryResult> result_p) : result(std::m
if (!result) {
throw InternalException("PyResult created without a result object");
}
// Must happen before any Fetch call (from this object or a caller holding a reference to the
// same underlying QueryResult) has a chance to consume the single row we're reading here.
row_changes = ComputeRowChanges();
}

DuckDBPyResult::~DuckDBPyResult() {
Expand Down Expand Up @@ -68,6 +71,43 @@ const vector<LogicalType> &DuckDBPyResult::GetTypes() {
return result->types;
}

int64_t DuckDBPyResult::ComputeRowChanges() {
if (!result || result->HasError()) {
return -1;
}
if (result->properties.return_type != StatementReturnType::CHANGED_ROWS) {
// The row count of a SELECT (or a statement that returns nothing) is not known without fully
// consuming the result - report -1, as permitted by the DB-API 2.0 spec for 'rowcount'.
return -1;
}
if (result->type == QueryResultType::STREAM_RESULT) {
// CHANGED_ROWS statements always produce a single already-computed row, so materializing here
// does not trigger any additional query execution - it just changes the in-memory representation.
// Still release the GIL around it though: Materialize() drives the same native Fetch() machinery
// as any other result consumption, and other fetch paths (e.g. Fetchone()) release the GIL around it.
unique_ptr<MaterializedQueryResult> materialized;
{
D_ASSERT(duckdb::PyUtil::GilCheck());
nb::gil_scoped_release release;
auto &stream_result = result->Cast<StreamQueryResult>();
materialized = stream_result.Materialize();
}
if (!materialized || materialized->HasError()) {
return -1;
}
result = std::move(materialized);
}
if (result->type != QueryResultType::MATERIALIZED_RESULT) {
// e.g. an Arrow result - can't peek at the value without disturbing it.
return -1;
}
auto &materialized_result = result->Cast<MaterializedQueryResult>();
if (materialized_result.RowCount() != 1 || materialized_result.ColumnCount() != 1) {
return -1;
}
return materialized_result.GetValue(0, 0).GetValue<int64_t>();
}

unique_ptr<DataChunk> DuckDBPyResult::FetchChunk() {
if (!result) {
throw InternalException("FetchChunk called without a result object");
Expand Down
101 changes: 101 additions & 0 deletions tests/fast/api/test_dbapi10.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,104 @@ def test_none_description(self, duckdb_empty_cursor):
class TestCursorRowcount:
def test_rowcount(self, duckdb_cursor):
assert duckdb_cursor.rowcount == -1

def test_rowcount_no_query_yet(self, duckdb_cursor):
assert duckdb_cursor.rowcount == -1

def test_rowcount_insert(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3)")
assert duckdb_cursor.rowcount == 3

def test_rowcount_insert_select(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t SELECT * FROM range(100)")
assert duckdb_cursor.rowcount == 100

def test_rowcount_update(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t AS SELECT * FROM range(10) t(i)")
duckdb_cursor.execute("UPDATE t SET i = i + 1 WHERE i < 4")
assert duckdb_cursor.rowcount == 4

def test_rowcount_delete(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t AS SELECT * FROM range(10) t(i)")
duckdb_cursor.execute("DELETE FROM t WHERE i < 3")
assert duckdb_cursor.rowcount == 3

def test_rowcount_create_table_as(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t AS SELECT * FROM range(42) t(i)")
assert duckdb_cursor.rowcount == 42

def test_rowcount_select_is_unknown(self, duckdb_cursor):
# Matches DB-API 2.0: rowcount is -1 when it can't be determined without consuming the result.
duckdb_cursor.execute("SELECT * FROM range(10)")
assert duckdb_cursor.rowcount == -1
duckdb_cursor.fetchall()
assert duckdb_cursor.rowcount == -1

def test_rowcount_reset_by_next_execute(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2)")
assert duckdb_cursor.rowcount == 2
duckdb_cursor.execute("SELECT * FROM t")
assert duckdb_cursor.rowcount == -1

def test_rowcount_does_not_disturb_fetch(self, duckdb_cursor):
# Accessing rowcount must not consume the single-row result that fetchone() also reads.
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3)")
assert duckdb_cursor.rowcount == 3
assert duckdb_cursor.fetchone() == (3,)

def test_rowcount_ddl_is_unknown(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
assert duckdb_cursor.rowcount == -1

def test_rowcount_after_fetchall(self, duckdb_cursor):
# Regression test: rowcount must survive full consumption of the result via fetchall(),
# not just a bare execute() with no fetch at all.
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
assert duckdb_cursor.fetchall() == [(4,)]
assert duckdb_cursor.rowcount == 4

def test_rowcount_after_fetchone(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
assert duckdb_cursor.fetchone() == (4,)
assert duckdb_cursor.rowcount == 4

def test_rowcount_after_fetchmany(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
assert duckdb_cursor.fetchmany(1) == [(4,)]
assert duckdb_cursor.rowcount == 4

def test_rowcount_after_fetchdf(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
duckdb_cursor.fetchdf()
assert duckdb_cursor.rowcount == 4

def test_rowcount_after_fetchnumpy(self, duckdb_cursor):
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
duckdb_cursor.fetchnumpy()
assert duckdb_cursor.rowcount == 4

def test_rowcount_after_to_arrow_table(self, duckdb_cursor):
pytest.importorskip("pyarrow")
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.execute("INSERT INTO t VALUES (1), (2), (3), (4)")
duckdb_cursor.to_arrow_table()
assert duckdb_cursor.rowcount == 4

def test_rowcount_executemany_reflects_last_statement_only(self, duckdb_cursor):
# Documents current (limited) behavior: executemany() only keeps the QueryResult of the last
# parameter set it executes, so rowcount reflects that one statement rather than the total
# number of rows affected across all parameter sets. Not the DB-API-idiomatic answer, but
# pinned here so a future change to this behavior is a deliberate, visible decision.
duckdb_cursor.execute("CREATE TABLE t (i INTEGER)")
duckdb_cursor.executemany("INSERT INTO t VALUES (?)", [(1,), (2,), (3,)])
assert duckdb_cursor.table("t").fetchall() == [(1,), (2,), (3,)]
assert duckdb_cursor.rowcount == 1