deev 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {deev-2.0.0/src/deev.egg-info → deev-2.0.2}/PKG-INFO +1 -1
  2. {deev-2.0.0 → deev-2.0.2}/pyproject.toml +1 -1
  3. {deev-2.0.0 → deev-2.0.2}/src/deev/SKILL.md +2 -2
  4. {deev-2.0.0 → deev-2.0.2}/src/deev/__init__.py +2 -2
  5. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_table_adapter.py +0 -2
  6. deev-2.0.2/src/deev/clickhouse/async_clickhouse_transaction_context.py +272 -0
  7. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_table_adapter.py +0 -2
  8. deev-2.0.2/src/deev/clickhouse/clickhouse_transaction_context.py +264 -0
  9. {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_transaction_context.py +23 -3
  10. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_migrator.py +0 -1
  11. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_transaction_context.py +19 -2
  12. deev-2.0.2/src/deev/common/noop_cursor.py +64 -0
  13. deev-2.0.2/src/deev/common/tlc_parser.py +122 -0
  14. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_proxy_cursor.py +14 -6
  15. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_table_adapter.py +0 -2
  16. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_transaction_context.py +149 -90
  17. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_table_adapter.py +0 -2
  18. deev-2.0.2/src/deev/mongodb/mongo_transaction_context.py +303 -0
  19. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_proxy_cursor.py +2 -4
  20. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_table_adapter.py +0 -2
  21. deev-2.0.2/src/deev/mysql/async_mysql_transaction_context.py +263 -0
  22. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_proxy_cursor.py +2 -4
  23. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_table_adapter.py +0 -2
  24. deev-2.0.2/src/deev/mysql/mysql_transaction_context.py +260 -0
  25. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_transaction_context.py +53 -50
  26. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_proxy_cursor.py +2 -5
  27. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_table_adapter.py +0 -2
  28. deev-2.0.2/src/deev/sqlite/sqlite_transaction_context.py +258 -0
  29. {deev-2.0.0 → deev-2.0.2}/src/deev/utils.py +1 -1
  30. {deev-2.0.0 → deev-2.0.2/src/deev.egg-info}/PKG-INFO +1 -1
  31. {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/SOURCES.txt +2 -0
  32. deev-2.0.0/src/deev/clickhouse/async_clickhouse_transaction_context.py +0 -207
  33. deev-2.0.0/src/deev/clickhouse/clickhouse_transaction_context.py +0 -194
  34. deev-2.0.0/src/deev/mongodb/mongo_transaction_context.py +0 -248
  35. deev-2.0.0/src/deev/mysql/async_mysql_transaction_context.py +0 -215
  36. deev-2.0.0/src/deev/mysql/mysql_transaction_context.py +0 -209
  37. deev-2.0.0/src/deev/sqlite/sqlite_transaction_context.py +0 -214
  38. {deev-2.0.0 → deev-2.0.2}/LICENSE +0 -0
  39. {deev-2.0.0 → deev-2.0.2}/README.md +0 -0
  40. {deev-2.0.0 → deev-2.0.2}/setup.cfg +0 -0
  41. {deev-2.0.0 → deev-2.0.2}/src/deev/_immutable_mixin.py +0 -0
  42. {deev-2.0.0 → deev-2.0.2}/src/deev/_migration_data.py +0 -0
  43. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/__init__.py +0 -0
  44. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_proxy_connection.py +0 -0
  45. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_proxy_cursor.py +0 -0
  46. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_ddl_generator.py +0 -0
  47. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_proxy_connection.py +0 -0
  48. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_proxy_cursor.py +0 -0
  49. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_type_mapper.py +0 -0
  50. {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/utils.py +0 -0
  51. {deev-2.0.0 → deev-2.0.2}/src/deev/common/__init__.py +0 -0
  52. {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_adapter.py +0 -0
  53. {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_connection.py +0 -0
  54. {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_cursor.py +0 -0
  55. {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_table_adapter.py +0 -0
  56. {deev-2.0.0 → deev-2.0.2}/src/deev/common/connection_string.py +0 -0
  57. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_adapter.py +0 -0
  58. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_connection.py +0 -0
  59. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_context.py +0 -0
  60. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_cursor.py +0 -0
  61. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_error.py +0 -0
  62. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_parameters.py +0 -0
  63. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_table_adapter.py +0 -0
  64. {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_type_mapper.py +0 -0
  65. {deev-2.0.0 → deev-2.0.2}/src/deev/common/description_field.py +0 -0
  66. {deev-2.0.0 → deev-2.0.2}/src/deev/db_migrate.py +0 -0
  67. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/__init__.py +0 -0
  68. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/entity_field_spec.py +0 -0
  69. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/entity_spec.py +0 -0
  70. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/index_options.py +0 -0
  71. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/index_order.py +0 -0
  72. {deev-2.0.0 → deev-2.0.2}/src/deev/entities/utils.py +0 -0
  73. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/__init__.py +0 -0
  74. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_proxy_connection.py +0 -0
  75. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_proxy_connection.py +0 -0
  76. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_proxy_cursor.py +0 -0
  77. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_type_mapper.py +0 -0
  78. {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/utils.py +0 -0
  79. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/__init__.py +0 -0
  80. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_proxy_connection.py +0 -0
  81. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_ddl_generator.py +0 -0
  82. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_proxy_connection.py +0 -0
  83. {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_type_mapper.py +0 -0
  84. {deev-2.0.0 → deev-2.0.2}/src/deev/py.typed +0 -0
  85. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/__init__.py +0 -0
  86. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_proxy_connection.py +0 -0
  87. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_proxy_cursor.py +0 -0
  88. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_table_adapter.py +0 -0
  89. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_ddl_generator.py +0 -0
  90. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_proxy_connection.py +0 -0
  91. {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_type_mapper.py +0 -0
  92. {deev-2.0.0 → deev-2.0.2}/src/deev/translation/__init__.py +0 -0
  93. {deev-2.0.0 → deev-2.0.2}/src/deev/translation/deev_json_decoder.py +0 -0
  94. {deev-2.0.0 → deev-2.0.2}/src/deev/translation/deev_json_encoder.py +0 -0
  95. {deev-2.0.0 → deev-2.0.2}/src/deev/translation/utils.py +0 -0
  96. {deev-2.0.0 → deev-2.0.2}/src/deev/validation/__init__.py +0 -0
  97. {deev-2.0.0 → deev-2.0.2}/src/deev/validation/utils.py +0 -0
  98. {deev-2.0.0 → deev-2.0.2}/src/deev/validation/validation_error.py +0 -0
  99. {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/dependency_links.txt +0 -0
  100. {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/entry_points.txt +0 -0
  101. {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/requires.txt +0 -0
  102. {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deev
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: ..an entity framework for Python.
5
5
  Author-email: Shaun Wilson <mrshaunwilson@msn.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deev"
3
- version = "2.0.0"
3
+ version = "2.0.2"
4
4
  description = "..an entity framework for Python."
5
5
  keywords = ["DB", "database", "entity framework", "mapper", "sqlite", "mysql", "mongodb", "clickhouse"]
6
6
  authors = [
@@ -389,7 +389,7 @@ def undo(tx: ClickHouseTransactionContext) -> None:
389
389
 
390
390
  | Name | Purpose |
391
391
  |------|---------|
392
- | `splat` | Extract entity field values from object (params: `attrs`, `to_sql`, `to_bson`) |
392
+ | `splat` | Extract entity field values from object (parameters: `attrs`, `to_sql`, `to_bson`) |
393
393
  | `hydrate` | Create entity instances from dicts |
394
394
  | `configure_serialization` | Customize JSON serialization |
395
395
  | `DeevJsonEncoder` | Custom JSON encoder for deev types |
@@ -403,7 +403,7 @@ def undo(tx: ClickHouseTransactionContext) -> None:
403
403
 
404
404
  | Name | Purpose |
405
405
  |------|---------|
406
- | `validate` | Validate entity instance (params: `attrs`) |
406
+ | `validate` | Validate entity instance (parameters: `attrs`) |
407
407
  | `ValidationError` | Exception raised on validation failure |
408
408
 
409
409
  ### From `deev.utils`
@@ -21,8 +21,8 @@ from .translation import hydrate, splat
21
21
  from .utils import connect
22
22
 
23
23
 
24
- __version__ = '2.0.0'
25
- __commit__ = '2f75316'
24
+ __version__ = '2.0.2'
25
+ __commit__ = 'a337fba'
26
26
  __all__ = [
27
27
  '__version__',
28
28
  '__commit__',
@@ -45,7 +45,6 @@ class AsyncClickHouseTableAdapter(AsyncDbTableAdapter[TEntity]):
45
45
  __logger: logging.Logger
46
46
  __dbtype_mapper: DbTypeMapper
47
47
  __table_name: str | None
48
- __transaction_state: int
49
48
 
50
49
  def __init__(
51
50
  self,
@@ -59,7 +58,6 @@ class AsyncClickHouseTableAdapter(AsyncDbTableAdapter[TEntity]):
59
58
  self.__create_table = create_table is True
60
59
  self.__initialized = False
61
60
  self.__table_name = table_name
62
- self.__transaction_state = 0
63
61
  self.__is_sync_replicas_enabled = sync_replicas is True and getattr(self.__context, 'is_replicated', False) is True
64
62
  self.__logger = hanaro.get_logger()
65
63
 
@@ -0,0 +1,272 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from contextvars import ContextVar
8
+ import logging
9
+ from types import TracebackType
10
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Literal, Self, cast
11
+ from uuid import UUID, uuid4
12
+
13
+ import hanaro
14
+
15
+ from ..common.async_db_connection import AsyncDbConnection
16
+ from ..common.db_context import AsyncDbContext
17
+ from ..common.async_db_cursor import AsyncDbCursor
18
+ from ..common.async_db_transaction_context import AsyncDbTransactionContext
19
+ from ..common.db_error import DbError
20
+ from ..common.db_parameters import DbParameters
21
+ from ..common.tlc_parser import extract_begin_name, extract_rollback_name, extract_savepoint_name
22
+ from .async_clickhouse_proxy_connection import AsyncClickHouseProxyConnection
23
+
24
+
25
+ class AsyncClickHouseTransactionContext(AsyncDbTransactionContext):
26
+ """
27
+ Async transaction context for ClickHouse.
28
+
29
+ ClickHouse does not support traditional ACID transactions. All transaction methods are
30
+ no-ops. This context enables using ClickHouse connections with code that expects
31
+ transactional semantics. For example, when swapping providers.
32
+ """
33
+
34
+ __ambient_transaction_id: ContextVar[str | None] = ContextVar[str | None]('ambient_transaction_id', default=None)
35
+ __context: AsyncDbContext | None
36
+ __cursor: Any | None
37
+ __logger: logging.Logger
38
+ __savepoints: list[str]
39
+ __transaction_depth: int
40
+ __transaction_id: str
41
+ __transaction_name: str | None
42
+
43
+ def __init__(self, context: AsyncDbContext, *, owns_context: bool | None = None) -> None:
44
+ self.__owns_context = owns_context is True
45
+ self.__is_deev_context = isinstance(context, (AsyncClickHouseProxyConnection, AsyncClickHouseTransactionContext))
46
+ self.__context = context if self.__is_deev_context else AsyncClickHouseProxyConnection(context) # type: ignore[arg-type]
47
+ self.__logger = hanaro.get_logger()
48
+ self.__transaction_id = uuid4().hex
49
+ self.__transaction_depth = 0
50
+ self.__savepoints: list[str] = []
51
+ self.__transaction_name: str | None = None
52
+ self.__cursor = None
53
+
54
+ def __del__(self) -> None:
55
+ try:
56
+ try:
57
+ loop = asyncio.get_running_loop()
58
+ except RuntimeError:
59
+ loop = None
60
+ if loop and loop.is_running():
61
+ loop.create_task(self.close())
62
+ else:
63
+ asyncio.run(self.close())
64
+ except Exception:
65
+ pass
66
+
67
+ async def __aenter__(self) -> Self:
68
+ await self.begin_transaction()
69
+ return self
70
+
71
+ async def __aexit__(
72
+ self,
73
+ exc_type: type[BaseException] | None = None,
74
+ exc_value: BaseException | None = None,
75
+ traceback: TracebackType | None = None
76
+ ) -> Literal[False]:
77
+ try:
78
+ if self.__transaction_depth > 0:
79
+ if exc_type is not None:
80
+ await self.rollback()
81
+ else:
82
+ await self.rollback()
83
+ raise DbError('Detected uncommitted transaction, rolling back. You must explicitly call commit or rollback.')
84
+ finally:
85
+ await self.close()
86
+ self.__transaction_depth = -1
87
+ return False
88
+
89
+ def __preprocess_sql(self, sql: str) -> str | None:
90
+ """
91
+ Parse SQL to determine whether it is a transaction-control (TLC) keyword
92
+ or a regular SQL statement.
93
+
94
+ For ClickHouse: all TLC keywords return None (scrubbed) since ClickHouse
95
+ has no transaction support. Depth tracking is for diagnostics/consistency.
96
+ """
97
+ if self.__transaction_depth == -1:
98
+ raise DbError('Cannot use a transaction context that has exited.')
99
+ elif self.__transaction_depth == -2:
100
+ raise DbError('Cannot use a transaction context that has been committed.')
101
+ elif self.__transaction_depth == -3:
102
+ raise DbError('Cannot use a transaction context that has been rolled back.')
103
+
104
+ sql_upper = sql.lstrip().upper()
105
+ prefix = sql_upper[:4]
106
+
107
+ if prefix == 'BEGI':
108
+ self.__transaction_depth += 1
109
+ self.__transaction_name = extract_begin_name(sql)
110
+ return None
111
+
112
+ elif prefix == 'SAVE' or sql_upper.startswith('SAVEPOINT '):
113
+ name_token = extract_savepoint_name(sql)
114
+ if name_token:
115
+ self.__savepoints.append(name_token)
116
+ return None
117
+
118
+ elif prefix == 'COMM':
119
+ if self.__transaction_depth == 0:
120
+ raise DbError('No active transaction to commit.')
121
+ self.__transaction_depth -= 1
122
+ if self.__transaction_depth == 0:
123
+ self.__savepoints.clear()
124
+ self.__transaction_name = None
125
+ self.__ambient_transaction_id.set(None)
126
+ self.__transaction_depth = -2
127
+ return None
128
+
129
+ elif prefix == 'ROLL':
130
+ if self.__transaction_depth == 0:
131
+ raise DbError('Cannot rollback, no transaction.')
132
+ name = extract_rollback_name(sql)
133
+
134
+ if name:
135
+ if name in self.__savepoints:
136
+ while self.__savepoints:
137
+ sp = self.__savepoints.pop()
138
+ if sp == name:
139
+ break
140
+ return None
141
+ elif name == self.__transaction_name:
142
+ pass # full rollback, depth reset below
143
+ else:
144
+ raise DbError('Invalid Transaction Name')
145
+ else:
146
+ pass # full rollback, depth reset below
147
+
148
+ self.__transaction_depth = -3
149
+ self.__savepoints.clear()
150
+ self.__transaction_name = None
151
+ self.__ambient_transaction_id.set(None)
152
+ return None
153
+
154
+ return sql
155
+
156
+ @property
157
+ def connection(self) -> AsyncDbConnection:
158
+ if isinstance(self.__context, AsyncDbTransactionContext):
159
+ return cast(AsyncDbTransactionContext, self.__context).connection
160
+ else:
161
+ return cast(AsyncDbConnection, self.__context)
162
+
163
+ @property
164
+ def clickhouse_client(self) -> Any:
165
+ assert self.__context is not None, 'no context'
166
+ if hasattr(self.__context, 'clickhouse_client'):
167
+ return self.__context.clickhouse_client # type: ignore[return-value]
168
+ return None
169
+
170
+ @property
171
+ def transaction_name(self) -> str | None:
172
+ return self.__transaction_name
173
+
174
+ @property
175
+ def savepoints(self) -> tuple[str, ...]:
176
+ return tuple(self.__savepoints)
177
+
178
+ async def begin_transaction(self, name: str | None = None) -> Self:
179
+ assert self.__context is not None, 'no context'
180
+ self.__cursor = await self.__context.cursor()
181
+ if AsyncClickHouseTransactionContext.__ambient_transaction_id.get(None) is None:
182
+ AsyncClickHouseTransactionContext.__ambient_transaction_id.set(name or self.__transaction_id)
183
+ sql = 'BEGIN TRANSACTION'
184
+ if name:
185
+ sql += f' {name}'
186
+ await self.execute(sql)
187
+ return self
188
+
189
+ async def create_savepoint(self, name: str | None = None) -> Self:
190
+ if name is None:
191
+ name = f'TID_{uuid4().hex[:24]}'
192
+ sql = f'SAVE TRANSACTION {name}'
193
+ await self.execute(sql)
194
+ return self
195
+
196
+ async def rollback_savepoint(self, name: str | None = None) -> None:
197
+ if name is None:
198
+ if not self.__savepoints:
199
+ return
200
+ name = self.__savepoints[-1]
201
+ sql = f'ROLLBACK TRANSACTION {name}'
202
+ await self.execute(sql)
203
+
204
+ async def close(self) -> None:
205
+ try:
206
+ if self.__cursor is not None:
207
+ await self.__cursor.close()
208
+ except Exception:
209
+ pass
210
+ self.__cursor = None
211
+ try:
212
+ if self.__context is not None and self.__owns_context and hasattr(self.__context, 'close'):
213
+ await self.__context.close()
214
+ except Exception:
215
+ pass
216
+ self.__context = None
217
+
218
+ async def commit(self) -> None:
219
+ await self.execute('COMMIT')
220
+
221
+ async def cursor(self) -> AsyncDbCursor:
222
+ assert self.__context is not None, 'no context'
223
+ return await self.__context.cursor()
224
+
225
+ async def execute(self, sql: str, parameters: DbParameters | None = None, raw: bool = False) -> AsyncDbCursor:
226
+ if not raw:
227
+ modified = self.__preprocess_sql(sql)
228
+ if modified is None:
229
+ from ..common.noop_cursor import NoopCursor
230
+ return cast(AsyncDbCursor, NoopCursor()) # type: ignore[return-value]
231
+ sql = modified
232
+ assert self.__context is not None, 'no context'
233
+ if self.__cursor is None:
234
+ self.__cursor = await self.__context.cursor()
235
+ assert self.__cursor is not None
236
+ await self.__cursor.execute(sql, parameters)
237
+ return cast(AsyncDbCursor, self.__cursor)
238
+
239
+ async def execute_nonquery(self, sql: str, parameters: DbParameters | None = None) -> None:
240
+ await self.execute(sql, parameters)
241
+
242
+ async def execute_reader(self, sql: str, parameters: DbParameters | None = None) -> AsyncGenerator[tuple[Any, ...], None]: # type: ignore[override]
243
+ cursor = await self.execute(sql, parameters)
244
+ row = await cursor.fetchone()
245
+ while row is not None:
246
+ yield row
247
+ row = await cursor.fetchone()
248
+
249
+ async def execute_scalar(self, sql: str, parameters: DbParameters | None = None) -> Any:
250
+ cursor = await self.execute(sql, parameters)
251
+ try:
252
+ row = await cursor.fetchone()
253
+ return None if row is None else row[0]
254
+ except Exception:
255
+ return cursor.rowcount
256
+
257
+ async def execute_script(self, sql: str, raw: bool = False) -> None:
258
+ if raw is True:
259
+ await self.execute(sql, raw=True)
260
+ return
261
+ lines = sql.split('\n')
262
+ scrubbed = [line for line in lines if self.__preprocess_sql(line) is not None]
263
+ joined = '\n'.join(scrubbed)
264
+ if joined:
265
+ await self.execute(joined, raw=True)
266
+
267
+ async def rollback(self, name: str | None = None) -> None:
268
+ sql = 'ROLLBACK' if name is None else f'ROLLBACK TRANSACTION {name}'
269
+ await self.execute(sql)
270
+
271
+
272
+ __all__ = ['AsyncClickHouseTransactionContext']
@@ -41,7 +41,6 @@ class ClickHouseTableAdapter(Generic[TEntity]):
41
41
  __initialized: bool
42
42
  __logger: logging.Logger
43
43
  __table_name: str | None
44
- __transaction_state: int
45
44
 
46
45
  def __init__(
47
46
  self,
@@ -55,7 +54,6 @@ class ClickHouseTableAdapter(Generic[TEntity]):
55
54
  self.__create_table = create_table is True
56
55
  self.__initialized = False
57
56
  self.__table_name = table_name
58
- self.__transaction_state = 0
59
57
  self.__is_sync_replicas_enabled = sync_replicas is True and getattr(self.__context, 'is_replicated', False) is True
60
58
  self.__logger = hanaro.get_logger()
61
59
 
@@ -0,0 +1,264 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from contextvars import ContextVar
7
+ import logging
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING, Any, Generator, Literal, Self, cast
10
+
11
+ import hanaro
12
+ from uuid import uuid4
13
+
14
+ from ..common.db_connection import DbConnection
15
+ from ..common.db_cursor import DbCursor
16
+ from ..common.db_error import DbError
17
+ from ..common.db_parameters import DbParameters
18
+ from ..common.db_transaction_context import DbTransactionContext
19
+ from ..common.tlc_parser import extract_begin_name, extract_rollback_name, extract_savepoint_name
20
+ from .clickhouse_proxy_connection import ClickHouseProxyConnection
21
+
22
+ if TYPE_CHECKING:
23
+ from ..common.db_context import DbContext
24
+
25
+
26
+ class ClickHouseTransactionContext(DbTransactionContext):
27
+ """
28
+ Transaction context for ClickHouse.
29
+
30
+ ClickHouse does not support traditional ACID transactions. All transaction methods are
31
+ no-ops. This context enables using ClickHouse connections with code that expects
32
+ transactional semantics. For example, when swapping providers.
33
+ """
34
+
35
+ __ambient_transaction_id: ContextVar[str | None] = ContextVar[str | None]('ambient_transaction_id', default=None)
36
+ __context: DbContext | None
37
+ __cursor: DbCursor | None
38
+ __logger: logging.Logger
39
+ __savepoints: list[str]
40
+ __transaction_depth: int
41
+ __transaction_id: str
42
+ __transaction_name: str | None
43
+
44
+ def __init__(self, context: DbContext, *, owns_context: bool | None = None) -> None:
45
+ """
46
+ Initialize the ClickHouse transaction context.
47
+
48
+ ClickHouse does not support traditional ACID transactions; all transaction methods
49
+ are no-ops. This context enables using ClickHouse with code that expects
50
+ transactional semantics.
51
+
52
+ :param context: A :class:`ClickHouseProxyConnection` or related context.
53
+ :param owns_context: Whether this transaction context owns *context* and should close it.
54
+ """
55
+ self.__owns_context = owns_context is True
56
+ self.__is_deev_context = isinstance(context, (ClickHouseProxyConnection, ClickHouseTransactionContext))
57
+ self.__context = context if self.__is_deev_context else ClickHouseProxyConnection(context) # type: ignore[arg-type]
58
+ self.__logger = hanaro.get_logger()
59
+ self.__transaction_id = uuid4().hex
60
+ self.__transaction_depth = 0
61
+ self.__savepoints: list[str] = []
62
+ self.__transaction_name: str | None = None
63
+ self.__cursor = None
64
+
65
+ def __del__(self) -> None:
66
+ self.close()
67
+
68
+ def __enter__(self) -> Self:
69
+ self.begin_transaction()
70
+ return self
71
+
72
+ def __exit__(self, exc_type: type[BaseException] | None = None, exc_value: BaseException | None = None, traceback: TracebackType | None = None) -> Literal[False]:
73
+ try:
74
+ if self.__transaction_depth > 0:
75
+ if exc_type is not None:
76
+ self.rollback()
77
+ else:
78
+ self.rollback()
79
+ raise DbError('Detected uncommitted transaction, rolling back. You must explicitly call commit or rollback.')
80
+ finally:
81
+ self.close()
82
+ self.__transaction_depth = -1
83
+ self.__ambient_transaction_id.set(None)
84
+ return False
85
+
86
+ def __preprocess_sql(self, sql: str) -> str | None:
87
+ """
88
+ Parse SQL to determine whether it is a transaction-control (TLC) keyword
89
+ or a regular SQL statement.
90
+
91
+ For ClickHouse: all TLC keywords return None (scrubbed) since ClickHouse
92
+ has no transaction support. Depth tracking is for diagnostics/consistency.
93
+ """
94
+ if self.__transaction_depth == -1:
95
+ raise DbError('Cannot use a transaction context that has exited.')
96
+ elif self.__transaction_depth == -2:
97
+ raise DbError('Cannot use a transaction context that has been committed.')
98
+ elif self.__transaction_depth == -3:
99
+ raise DbError('Cannot use a transaction context that has been rolled back.')
100
+
101
+ sql_upper = sql.lstrip().upper()
102
+ prefix = sql_upper[:4]
103
+
104
+ if prefix == 'BEGI':
105
+ self.__transaction_depth += 1
106
+ self.__transaction_name = extract_begin_name(sql)
107
+ return None
108
+
109
+ elif prefix == 'SAVE' or sql_upper.startswith('SAVEPOINT '):
110
+ name_token = extract_savepoint_name(sql)
111
+ if name_token:
112
+ self.__savepoints.append(name_token)
113
+ return None
114
+
115
+ elif prefix == 'COMM':
116
+ if self.__transaction_depth == 0:
117
+ raise DbError('No active transaction to commit.')
118
+ self.__transaction_depth -= 1
119
+ if self.__transaction_depth == 0:
120
+ self.__savepoints.clear()
121
+ self.__transaction_name = None
122
+ self.__ambient_transaction_id.set(None)
123
+ self.__transaction_depth = -2
124
+ return None
125
+
126
+ elif prefix == 'ROLL':
127
+ if self.__transaction_depth == 0:
128
+ raise DbError('Cannot rollback, no transaction.')
129
+ name = extract_rollback_name(sql)
130
+
131
+ if name:
132
+ if name in self.__savepoints:
133
+ while self.__savepoints:
134
+ sp = self.__savepoints.pop()
135
+ if sp == name:
136
+ break
137
+ return None
138
+ elif name == self.__transaction_name:
139
+ pass # full rollback, depth reset below
140
+ else:
141
+ raise DbError('Invalid Transaction Name')
142
+ else:
143
+ pass # full rollback, depth reset below
144
+
145
+ self.__transaction_depth = -3
146
+ self.__savepoints.clear()
147
+ self.__transaction_name = None
148
+ self.__ambient_transaction_id.set(None)
149
+ return None
150
+
151
+ return sql
152
+
153
+ @property
154
+ def connection(self) -> DbConnection:
155
+ if isinstance(self.__context, DbTransactionContext):
156
+ return cast(DbTransactionContext, self.__context).connection
157
+ else:
158
+ return cast(DbConnection, self.__context)
159
+
160
+ @property
161
+ def clickhouse_client(self) -> Any:
162
+ return cast(ClickHouseProxyConnection, self.__context).clickhouse_client
163
+
164
+ @property
165
+ def transaction_name(self) -> str | None:
166
+ return self.__transaction_name
167
+
168
+ @property
169
+ def savepoints(self) -> tuple[str, ...]:
170
+ return tuple(self.__savepoints)
171
+
172
+ def begin_transaction(self, name: str | None = None) -> Self:
173
+ if self.__cursor is None:
174
+ assert self.__context is not None, 'no context'
175
+ self.__cursor = self.__context.cursor()
176
+ sql = 'BEGIN TRANSACTION'
177
+ if name:
178
+ sql += f' {name}'
179
+ self.execute(sql)
180
+ return self
181
+
182
+ def create_savepoint(self, name: str | None = None) -> Self:
183
+ if name is None:
184
+ name = f'TID_{uuid4().hex[:24]}'
185
+ sql = f'SAVE TRANSACTION {name}'
186
+ self.execute(sql)
187
+ return self
188
+
189
+ def rollback_savepoint(self, name: str | None = None) -> None:
190
+ if name is None:
191
+ if not self.__savepoints:
192
+ return
193
+ name = self.__savepoints[-1]
194
+ sql = f'ROLLBACK TRANSACTION {name}'
195
+ self.execute(sql)
196
+
197
+ def close(self) -> None:
198
+ try:
199
+ if self.__cursor is not None:
200
+ self.__cursor.close()
201
+ except Exception:
202
+ pass
203
+ self.__cursor = None
204
+ try:
205
+ if self.__context is not None and self.__owns_context and hasattr(self.__context, 'close'):
206
+ self.__context.close()
207
+ except Exception:
208
+ pass
209
+ self.__context = None
210
+
211
+ def commit(self) -> None:
212
+ self.execute('COMMIT')
213
+
214
+ def cursor(self) -> DbCursor:
215
+ assert self.__context is not None, 'no context'
216
+ return self.__context.cursor()
217
+
218
+ def execute(self, sql: str, parameters: DbParameters | None = None, raw: bool = False) -> DbCursor:
219
+ if not raw:
220
+ modified = self.__preprocess_sql(sql)
221
+ if modified is None:
222
+ from ..common.noop_cursor import NoopCursor
223
+ return NoopCursor()
224
+ sql = modified
225
+ if self.__cursor is None:
226
+ assert self.__context is not None, 'no context'
227
+ self.__cursor = self.__context.cursor()
228
+ self.__cursor.execute(sql, parameters)
229
+ return cast(DbCursor, self.__cursor)
230
+
231
+ def execute_nonquery(self, sql: str, parameters: DbParameters | None = None) -> None:
232
+ self.execute(sql, parameters)
233
+
234
+ def execute_reader(self, sql: str, parameters: DbParameters | None = None) -> Generator[Any, None, None]:
235
+ cursor = self.execute(sql, parameters)
236
+ row = cursor.fetchone()
237
+ while row is not None:
238
+ yield row
239
+ row = cursor.fetchone()
240
+
241
+ def execute_scalar(self, sql: str, parameters: DbParameters | None = None) -> Any:
242
+ cursor = self.execute(sql, parameters)
243
+ try:
244
+ row = cursor.fetchone()
245
+ return None if row is None else row[0]
246
+ except Exception:
247
+ return cursor.rowcount
248
+
249
+ def execute_script(self, sql: str, raw: bool = False) -> None:
250
+ if raw is True:
251
+ self.execute(sql, raw=True)
252
+ return
253
+ lines = sql.split('\n')
254
+ scrubbed = [line for line in lines if self.__preprocess_sql(line) is not None]
255
+ joined = '\n'.join(scrubbed)
256
+ if joined:
257
+ self.execute(joined, raw=True)
258
+
259
+ def rollback(self, name: str | None = None) -> None:
260
+ sql = 'ROLLBACK' if name is None else f'ROLLBACK TRANSACTION {name}'
261
+ self.execute(sql)
262
+
263
+
264
+ __all__ = ['ClickHouseTransactionContext']
@@ -41,16 +41,30 @@ class AsyncDbTransactionContext(Protocol):
41
41
  def connection(self) -> AsyncDbConnection:
42
42
  ...
43
43
 
44
+ @property
45
+ def transaction_name(self) -> str | None:
46
+ ...
47
+
48
+ @property
49
+ def savepoints(self) -> tuple[str, ...]:
50
+ ...
51
+
44
52
  async def cursor(self) -> AsyncDbCursor:
45
53
  ...
46
54
 
55
+ async def begin_transaction(self, name: str | None = None) -> Self:
56
+ ...
57
+
47
58
  async def commit(self) -> None:
48
59
  ...
49
60
 
50
- async def execute(self, sql: str, parameters: DbParameters | None = ...) -> AsyncDbCursor:
61
+ async def create_savepoint(self, name: str | None = None) -> Self:
62
+ ...
63
+
64
+ async def execute(self, sql: str, parameters: DbParameters | None = ..., raw: bool = ...) -> AsyncDbCursor:
51
65
  ...
52
66
 
53
- async def execute_script(self, sql: str) -> None:
67
+ async def execute_script(self, sql: str, raw: bool = ...) -> None:
54
68
  ...
55
69
 
56
70
  async def execute_nonquery(self, sql: str, parameters: DbParameters | None = ...) -> None:
@@ -62,7 +76,13 @@ class AsyncDbTransactionContext(Protocol):
62
76
  async def execute_scalar(self, sql: str, parameters: DbParameters | None = ...) -> Any:
63
77
  ...
64
78
 
65
- async def rollback(self) -> None:
79
+ async def rollback(self, name: str | None = None) -> None:
80
+ ...
81
+
82
+ async def rollback_savepoint(self, name: str | None = None) -> None:
83
+ ...
84
+
85
+ async def close(self) -> None:
66
86
  ...
67
87
 
68
88
  @classmethod
@@ -161,7 +161,6 @@ class DbMigrator:
161
161
  if migration_func is not None:
162
162
  with begin_transaction(self.__connectionstring) as db_transaction:
163
163
  migration_func(db_transaction)
164
- db_transaction.commit()
165
164
  # NOTE: callee (migration_func) is responsible for calling commit (or rollback)
166
165
  migrations_table.delete(id=applied_migrations.get(migration_name, 0))
167
166
  migrations_table.commit()