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
6 changes: 3 additions & 3 deletions ddcdatabases/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ POSTGRESQL_SCHEMA=public
POSTGRESQL_ECHO=false
POSTGRESQL_AUTOFLUSH=false
POSTGRESQL_EXPIRE_ON_COMMIT=false
POSTGRESQL_AUTOCOMMIT=false
POSTGRESQL_AUTOCOMMIT=true
# Pool settings
POSTGRESQL_CONNECTION_TIMEOUT=30
POSTGRESQL_POOL_RECYCLE=3600
Expand Down Expand Up @@ -67,7 +67,7 @@ MSSQL_SCHEMA=dbo
MSSQL_ECHO=false
MSSQL_AUTOFLUSH=false
MSSQL_EXPIRE_ON_COMMIT=false
MSSQL_AUTOCOMMIT=false
MSSQL_AUTOCOMMIT=true
# Pool settings
MSSQL_CONNECTION_TIMEOUT=30
MSSQL_POOL_RECYCLE=3600
Expand Down Expand Up @@ -143,7 +143,7 @@ ORACLE_SERVICENAME=xe
ORACLE_ECHO=false
ORACLE_AUTOFLUSH=false
ORACLE_EXPIRE_ON_COMMIT=false
ORACLE_AUTOCOMMIT=false
ORACLE_AUTOCOMMIT=true
# Pool settings
ORACLE_CONNECTION_TIMEOUT=30
ORACLE_POOL_RECYCLE=3600
Expand Down
28 changes: 11 additions & 17 deletions ddcdatabases/core/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,17 +180,18 @@ 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) -> CursorResult:
def _execute_impl(self, stmt: Any, commit: bool = True) -> CursorResult:
try:
result = self.session.execute(stmt)
self.session.commit()
if commit:
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) -> CursorResult:
def execute(self, stmt: Any, commit: bool = True) -> CursorResult:
"""
Execute a statement that doesn't return rows (INSERT, UPDATE, DELETE) and commit it.

Expand All @@ -204,7 +205,7 @@ def execute(self, stmt: Any) -> CursorResult:
DBExecuteException: If statement execution fails
"""

return self._execute_with_retry(lambda: self._execute_impl(stmt), "execute")
return self._execute_with_retry(lambda: self._execute_impl(stmt, commit), "execute")


class DBUtilsAsync:
Expand Down Expand Up @@ -270,10 +271,6 @@ async def fetchvalue(self, stmt: Any) -> Any:
"""
Execute a SELECT statement asynchronously 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 @@ -380,23 +377,20 @@ 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) -> CursorResult:
async def _execute_impl(self, stmt: Any, commit: bool = True) -> CursorResult:
try:
result = await self.session.execute(stmt)
await self.session.commit()
if commit:
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) -> CursorResult:
async def execute(self, stmt: Any, commit: bool = True) -> CursorResult:
"""
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.
Execute a statement asynchronously that doesn't return rows (INSERT, UPDATE, DELETE) and commit it.

Args:
stmt: SQLAlchemy statement or raw SQL string to execute
Expand All @@ -408,4 +402,4 @@ async def execute(self, stmt: Any) -> CursorResult:
DBExecuteException: If statement execution fails
"""

return await self._execute_with_retry(lambda: self._execute_impl(stmt), "execute")
return await self._execute_with_retry(lambda: self._execute_impl(stmt, commit), "execute")
26 changes: 13 additions & 13 deletions ddcdatabases/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,16 @@ class _NetworkDBSettings(_BaseDBSettings):
# Connection Retry settings
connection_enable_retry: bool = Field(default=True, description=Msg.ENABLE_RETRY_DESCRIPTION)
connection_max_retries: int = Field(default=3, description=Msg.MAX_RETRIES_DESCRIPTION)
connection_initial_retry_delay: float = Field(default=1.0, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
connection_max_retry_delay: float = Field(default=30.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
connection_initial_retry_delay: float | int = Field(default=1.0, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
connection_max_retry_delay: float | int = Field(default=30.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
connection_disconnect_idle_timeout: int = Field(default=300, description=Msg.DISCONNECT_IDLE_TIMEOUT_DESCRIPTION)

# Operation Retry settings
operation_enable_retry: bool = Field(default=True, description=Msg.ENABLE_RETRY_DESCRIPTION)
operation_max_retries: int = Field(default=3, description=Msg.MAX_RETRIES_DESCRIPTION)
operation_initial_retry_delay: float = Field(default=0.5, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
operation_max_retry_delay: float = Field(default=10.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
operation_jitter: float = Field(default=0.1, description=Msg.JITTER_DESCRIPTION)
operation_initial_retry_delay: float | int = Field(default=0.5, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
operation_max_retry_delay: float | int = Field(default=10.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
operation_jitter: float | int = Field(default=0.1, description=Msg.JITTER_DESCRIPTION)

# Persistent connection settings
persistent_idle_timeout: int = Field(default=300, description=Msg.PERSISTENT_IDLE_TIMEOUT_DESCRIPTION)
Expand All @@ -71,15 +71,15 @@ class SQLiteSettings(_BaseDBSettings):
# Connection Retry settings (minimal for file-based database)
connection_enable_retry: bool = Field(default=False, description=Msg.ENABLE_RETRY_DESCRIPTION)
connection_max_retries: int = Field(default=1, description=Msg.MAX_RETRIES_DESCRIPTION)
connection_initial_retry_delay: float = Field(default=1.0, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
connection_max_retry_delay: float = Field(default=30.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
connection_initial_retry_delay: float | int = Field(default=1.0, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
connection_max_retry_delay: float | int = Field(default=30.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)

# Operation Retry settings
operation_enable_retry: bool = Field(default=False, description=Msg.ENABLE_RETRY_DESCRIPTION)
operation_max_retries: int = Field(default=1, description=Msg.MAX_RETRIES_DESCRIPTION)
operation_initial_retry_delay: float = Field(default=0.5, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
operation_max_retry_delay: float = Field(default=10.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
operation_jitter: float = Field(default=0.1, description=Msg.JITTER_DESCRIPTION)
operation_initial_retry_delay: float | int = Field(default=0.5, description=Msg.INITIAL_RETRY_DELAY_DESCRIPTION)
operation_max_retry_delay: float | int = Field(default=10.0, description=Msg.MAX_RETRY_DELAY_DESCRIPTION)
operation_jitter: float | int = Field(default=0.1, description=Msg.JITTER_DESCRIPTION)

model_config = SettingsConfigDict(env_prefix="SQLITE_")

Expand All @@ -97,7 +97,7 @@ class PostgreSQLSettings(_NetworkDBSettings):
echo: bool = Field(default=False, description=Msg.ECHO_DESCRIPTION)
autoflush: bool = Field(default=False, description=Msg.AUTOFLUSH_DESCRIPTION)
expire_on_commit: bool = Field(default=False, description=Msg.EXPIRE_ON_COMMIT_DESCRIPTION)
autocommit: bool = Field(default=False, description=Msg.AUTOCOMMIT_DESCRIPTION)
autocommit: bool = Field(default=True, description=Msg.AUTOCOMMIT_DESCRIPTION)
connection_timeout: int = Field(default=30, description=Msg.CONNECTION_TIMEOUT_DESCRIPTION)
pool_recycle: int = Field(default=3600, description=Msg.POOL_RECYCLE_DESCRIPTION)
pool_size: int = Field(default=25, description=Msg.POOL_SIZE_DESCRIPTION)
Expand Down Expand Up @@ -127,7 +127,7 @@ class MSSQLSettings(_NetworkDBSettings):
echo: bool = Field(default=False, description=Msg.ECHO_DESCRIPTION)
autoflush: bool = Field(default=False, description=Msg.AUTOFLUSH_DESCRIPTION)
expire_on_commit: bool = Field(default=False, description=Msg.EXPIRE_ON_COMMIT_DESCRIPTION)
autocommit: bool = Field(default=False, description=Msg.AUTOCOMMIT_DESCRIPTION)
autocommit: bool = Field(default=True, description=Msg.AUTOCOMMIT_DESCRIPTION)
connection_timeout: int = Field(default=30, description=Msg.CONNECTION_TIMEOUT_DESCRIPTION)
pool_recycle: int = Field(default=3600, description=Msg.POOL_RECYCLE_DESCRIPTION)
pool_size: int = Field(default=25, description=Msg.POOL_SIZE_DESCRIPTION)
Expand Down Expand Up @@ -209,7 +209,7 @@ class OracleSettings(_NetworkDBSettings):
echo: bool = Field(default=False, description=Msg.ECHO_DESCRIPTION)
autoflush: bool = Field(default=False, description=Msg.AUTOFLUSH_DESCRIPTION)
expire_on_commit: bool = Field(default=False, description=Msg.EXPIRE_ON_COMMIT_DESCRIPTION)
autocommit: bool = Field(default=False, description=Msg.AUTOCOMMIT_DESCRIPTION)
autocommit: bool = Field(default=True, description=Msg.AUTOCOMMIT_DESCRIPTION)
connection_timeout: int = Field(default=30, description=Msg.CONNECTION_TIMEOUT_DESCRIPTION)
pool_recycle: int = Field(default=3600, description=Msg.POOL_RECYCLE_DESCRIPTION)
pool_size: int = Field(default=10, description=Msg.POOL_SIZE_DESCRIPTION)
Expand Down
2 changes: 1 addition & 1 deletion 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.2"
version = "5.0.3"
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
20 changes: 20 additions & 0 deletions tests/unit/core/test_db_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,16 @@ def test_execute_returns_the_result_so_callers_can_read_rowcount(self):
assert out.rowcount == 42
mock_session.commit.assert_called_once()

def test_execute_can_defer_the_commit(self):
"""commit=False leaves the transaction open so a caller can make several
statements atomic - delete-then-insert cannot be torn in half by a crash."""
mock_session = MagicMock()

self.DBUtils(mock_session).execute(sa.text("DELETE FROM test_model"), commit=False)

mock_session.execute.assert_called_once()
mock_session.commit.assert_not_called()

def test_execute_exception(self):
"""Test execute with exception"""
mock_session = MagicMock()
Expand Down Expand Up @@ -646,6 +656,16 @@ async def test_execute_returns_the_result_so_callers_can_read_rowcount(self):
assert out.rowcount == 42
mock_session.commit.assert_called_once()

@pytest.mark.asyncio
async def test_execute_can_defer_the_commit(self):
"""Async counterpart - see the sync test."""
mock_session = AsyncMock()

await self.DBUtilsAsync(mock_session).execute(sa.text("DELETE FROM test_model"), commit=False)

mock_session.execute.assert_called_once()
mock_session.commit.assert_not_called()

@pytest.mark.asyncio
async def test_execute_exception(self):
"""Test async execute with exception"""
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading