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.
- {deev-2.0.0/src/deev.egg-info → deev-2.0.2}/PKG-INFO +1 -1
- {deev-2.0.0 → deev-2.0.2}/pyproject.toml +1 -1
- {deev-2.0.0 → deev-2.0.2}/src/deev/SKILL.md +2 -2
- {deev-2.0.0 → deev-2.0.2}/src/deev/__init__.py +2 -2
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_table_adapter.py +0 -2
- deev-2.0.2/src/deev/clickhouse/async_clickhouse_transaction_context.py +272 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_table_adapter.py +0 -2
- deev-2.0.2/src/deev/clickhouse/clickhouse_transaction_context.py +264 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_transaction_context.py +23 -3
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_migrator.py +0 -1
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_transaction_context.py +19 -2
- deev-2.0.2/src/deev/common/noop_cursor.py +64 -0
- deev-2.0.2/src/deev/common/tlc_parser.py +122 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_proxy_cursor.py +14 -6
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_table_adapter.py +0 -2
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_transaction_context.py +149 -90
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_table_adapter.py +0 -2
- deev-2.0.2/src/deev/mongodb/mongo_transaction_context.py +303 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_proxy_cursor.py +2 -4
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_table_adapter.py +0 -2
- deev-2.0.2/src/deev/mysql/async_mysql_transaction_context.py +263 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_proxy_cursor.py +2 -4
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_table_adapter.py +0 -2
- deev-2.0.2/src/deev/mysql/mysql_transaction_context.py +260 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_transaction_context.py +53 -50
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_proxy_cursor.py +2 -5
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_table_adapter.py +0 -2
- deev-2.0.2/src/deev/sqlite/sqlite_transaction_context.py +258 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/utils.py +1 -1
- {deev-2.0.0 → deev-2.0.2/src/deev.egg-info}/PKG-INFO +1 -1
- {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/SOURCES.txt +2 -0
- deev-2.0.0/src/deev/clickhouse/async_clickhouse_transaction_context.py +0 -207
- deev-2.0.0/src/deev/clickhouse/clickhouse_transaction_context.py +0 -194
- deev-2.0.0/src/deev/mongodb/mongo_transaction_context.py +0 -248
- deev-2.0.0/src/deev/mysql/async_mysql_transaction_context.py +0 -215
- deev-2.0.0/src/deev/mysql/mysql_transaction_context.py +0 -209
- deev-2.0.0/src/deev/sqlite/sqlite_transaction_context.py +0 -214
- {deev-2.0.0 → deev-2.0.2}/LICENSE +0 -0
- {deev-2.0.0 → deev-2.0.2}/README.md +0 -0
- {deev-2.0.0 → deev-2.0.2}/setup.cfg +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/_immutable_mixin.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/_migration_data.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/async_clickhouse_proxy_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_ddl_generator.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_proxy_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/clickhouse_type_mapper.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/clickhouse/utils.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_adapter.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/async_db_table_adapter.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/connection_string.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_adapter.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_context.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_error.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_parameters.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_table_adapter.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/db_type_mapper.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/common/description_field.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/db_migrate.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/entity_field_spec.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/entity_spec.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/index_options.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/index_order.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/entities/utils.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/async_mongo_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_proxy_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/mongo_type_mapper.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mongodb/utils.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/async_mysql_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_ddl_generator.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/mysql/mysql_type_mapper.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/py.typed +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_proxy_cursor.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/async_sqlite_table_adapter.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_ddl_generator.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_proxy_connection.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/sqlite/sqlite_type_mapper.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/translation/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/translation/deev_json_decoder.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/translation/deev_json_encoder.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/translation/utils.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/validation/__init__.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/validation/utils.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev/validation/validation_error.py +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/dependency_links.txt +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/entry_points.txt +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/requires.txt +0 -0
- {deev-2.0.0 → deev-2.0.2}/src/deev.egg-info/top_level.txt +0 -0
|
@@ -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 (
|
|
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 (
|
|
406
|
+
| `validate` | Validate entity instance (parameters: `attrs`) |
|
|
407
407
|
| `ValidationError` | Exception raised on validation failure |
|
|
408
408
|
|
|
409
409
|
### From `deev.utils`
|
|
@@ -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
|
|
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()
|