From ed4b80d6ab235116cc2f583a9607170e2a1aef21 Mon Sep 17 00:00:00 2001 From: ddc Date: Wed, 23 Sep 2026 14:11:16 -0300 Subject: [PATCH] v5.0.3 --- ddcdatabases/.env.example | 6 +++--- ddcdatabases/core/operations.py | 28 +++++++++++----------------- ddcdatabases/core/settings.py | 26 +++++++++++++------------- pyproject.toml | 2 +- tests/unit/core/test_db_utils.py | 20 ++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 49 insertions(+), 35 deletions(-) diff --git a/ddcdatabases/.env.example b/ddcdatabases/.env.example index c646397..f93d648 100644 --- a/ddcdatabases/.env.example +++ b/ddcdatabases/.env.example @@ -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 @@ -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 @@ -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 diff --git a/ddcdatabases/core/operations.py b/ddcdatabases/core/operations.py index 3cf2e2b..c4da5d3 100644 --- a/ddcdatabases/core/operations.py +++ b/ddcdatabases/core/operations.py @@ -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. @@ -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: @@ -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 @@ -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 @@ -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") diff --git a/ddcdatabases/core/settings.py b/ddcdatabases/core/settings.py index ffa15f6..b61150a 100644 --- a/ddcdatabases/core/settings.py +++ b/ddcdatabases/core/settings.py @@ -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) @@ -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_") @@ -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) @@ -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) @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 1c4031f..6afe87a 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/unit/core/test_db_utils.py b/tests/unit/core/test_db_utils.py index 371080f..1c94649 100644 --- a/tests/unit/core/test_db_utils.py +++ b/tests/unit/core/test_db_utils.py @@ -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() @@ -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""" diff --git a/uv.lock b/uv.lock index 36589d8..6b011fc 100644 --- a/uv.lock +++ b/uv.lock @@ -460,7 +460,7 @@ wheels = [ [[package]] name = "ddcdatabases" -version = "5.0.2" +version = "5.0.3" source = { editable = "." } dependencies = [ { name = "pydantic-settings" },