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
34 changes: 21 additions & 13 deletions ddcdatabases/core/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
)
from .retry import retry_operation, retry_operation_async
from collections.abc import Callable, Sequence
from sqlalchemy import RowMapping
from sqlalchemy import CursorResult, RowMapping
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from typing import Any
Expand Down Expand Up @@ -84,10 +84,6 @@ def fetchvalue(self, stmt: Any) -> Any:
"""
Execute a SELECT statement and fetch a single scalar value.

Changed in 5.0.0: returns the value with its native type. Previously every value
was coerced with str(), so a timestamptz came back as text and a COUNT(*) as "42".
Wrap the call in str() if the old behaviour is wanted.

Args:
stmt: SQLAlchemy statement or raw SQL string to execute

Expand Down Expand Up @@ -184,22 +180,26 @@ def deleteall[T](self, model: type[T]) -> None:

return self._execute_with_retry(lambda: self._deleteall_impl(model), "deleteall")

def _execute_impl(self, stmt: Any) -> None:
def _execute_impl(self, stmt: Any) -> CursorResult:
try:
self.session.execute(stmt)
result = self.session.execute(stmt)
self.session.commit()
return result
except Exception as e:
self.session.rollback()
_logger.exception("execute failed")
raise DBExecuteException(e) from e

def execute(self, stmt: Any) -> None:
def execute(self, stmt: Any) -> CursorResult:
"""
Execute a statement that doesn't return results (INSERT, UPDATE, DELETE).
Execute a statement that doesn't return rows (INSERT, UPDATE, DELETE) and commit it.

Args:
stmt: SQLAlchemy statement or raw SQL string to execute

Returns:
CursorResult: the executed statement's result; `.rowcount` holds rows affected

Raises:
DBExecuteException: If statement execution fails
"""
Expand Down Expand Up @@ -380,22 +380,30 @@ async def deleteall[T](self, model: type[T]) -> None:

return await self._execute_with_retry(lambda: self._deleteall_impl(model), "deleteall")

async def _execute_impl(self, stmt: Any) -> None:
async def _execute_impl(self, stmt: Any) -> CursorResult:
try:
await self.session.execute(stmt)
result = await self.session.execute(stmt)
await self.session.commit()
return result
except Exception as e:
await self.session.rollback()
_logger.exception("async execute failed")
raise DBExecuteException(e) from e

async def execute(self, stmt: Any) -> None:
async def execute(self, stmt: Any) -> CursorResult:
"""
Execute a statement asynchronously that doesn't return results (INSERT, UPDATE, DELETE).
Execute a statement asynchronously that doesn't return rows (INSERT, UPDATE,
DELETE) and commit it.

Returns the CursorResult so callers can read `.rowcount`. See the sync counterpart:
returning None silently breaks any caller that sizes a write by its result.

Args:
stmt: SQLAlchemy statement or raw SQL string to execute

Returns:
CursorResult: the executed statement's result; `.rowcount` holds rows affected

Raises:
DBExecuteException: If statement execution fails
"""
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ packages = ["ddcdatabases"]

[project]
name = "ddcdatabases"
version = "5.0.1"
version = "5.0.2"
description = "Simplified database ORM connections with support for multiple database engines"
urls.Repository = "https://github.com/ddc/ddcDatabases"
urls.Homepage = "https://pypi.org/project/ddcDatabases"
Expand Down Expand Up @@ -58,7 +58,7 @@ dependencies = [

[project.optional-dependencies]
mongodb = ["motor>=3.7.1"]
oracle = ["oracledb>=4.0.2"]
oracle = ["oracledb>=26.0.1"]
mssql = ["pyodbc>=5.3.0", "aioodbc>=0.5.0"]
mysql = ["mysqlclient>=2.3.0", "aiomysql>=0.3.2"]
postgres = ["psycopg[binary]>=3.3.6", "asyncpg>=0.31.0"]
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/core/test_db_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,23 @@ def test_execute_success(self):
mock_session.execute.assert_called_once_with(stmt)
mock_session.commit.assert_called_once()

def test_execute_returns_the_result_so_callers_can_read_rowcount(self):
"""execute() must hand back the CursorResult rather than swallow it.

Returning None makes `getattr(result, "rowcount", 0) or 0` evaluate to 0 for every
caller. A batched write loop using that for control flow reads it as a short batch
and stops after a single pass while reporting success.
"""
mock_session = MagicMock()
sentinel = MagicMock(rowcount=42)
mock_session.execute.return_value = sentinel

out = self.DBUtils(mock_session).execute(sa.text("UPDATE test_model SET name = 'x'"))

assert out is sentinel
assert out.rowcount == 42
mock_session.commit.assert_called_once()

def test_execute_exception(self):
"""Test execute with exception"""
mock_session = MagicMock()
Expand Down Expand Up @@ -616,6 +633,19 @@ async def test_execute_success(self):
mock_session.execute.assert_called_once_with(stmt)
mock_session.commit.assert_called_once()

@pytest.mark.asyncio
async def test_execute_returns_the_result_so_callers_can_read_rowcount(self):
"""Async counterpart - see the sync test for why None is not acceptable here."""
mock_session = AsyncMock()
sentinel = MagicMock(rowcount=42)
mock_session.execute.return_value = sentinel

out = await self.DBUtilsAsync(mock_session).execute(sa.text("UPDATE test_model SET name = 'x'"))

assert out is sentinel
assert out.rowcount == 42
mock_session.commit.assert_called_once()

@pytest.mark.asyncio
async def test_execute_exception(self):
"""Test async execute with exception"""
Expand Down
Loading
Loading