dotorm 2.0.8__py3-none-any.whl
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.
- dotorm/__init__.py +87 -0
- dotorm/access.py +151 -0
- dotorm/builder/__init__.py +0 -0
- dotorm/builder/builder.py +72 -0
- dotorm/builder/helpers.py +63 -0
- dotorm/builder/mixins/__init__.py +11 -0
- dotorm/builder/mixins/crud.py +246 -0
- dotorm/builder/mixins/m2m.py +110 -0
- dotorm/builder/mixins/relations.py +96 -0
- dotorm/builder/protocol.py +63 -0
- dotorm/builder/request_builder.py +144 -0
- dotorm/components/__init__.py +18 -0
- dotorm/components/dialect.py +99 -0
- dotorm/components/filter_parser.py +195 -0
- dotorm/databases/__init__.py +13 -0
- dotorm/databases/abstract/__init__.py +25 -0
- dotorm/databases/abstract/dialect.py +134 -0
- dotorm/databases/abstract/pool.py +10 -0
- dotorm/databases/abstract/session.py +67 -0
- dotorm/databases/abstract/types.py +36 -0
- dotorm/databases/clickhouse/__init__.py +8 -0
- dotorm/databases/clickhouse/pool.py +60 -0
- dotorm/databases/clickhouse/session.py +100 -0
- dotorm/databases/mysql/__init__.py +13 -0
- dotorm/databases/mysql/pool.py +69 -0
- dotorm/databases/mysql/session.py +128 -0
- dotorm/databases/mysql/transaction.py +39 -0
- dotorm/databases/postgres/__init__.py +23 -0
- dotorm/databases/postgres/pool.py +133 -0
- dotorm/databases/postgres/session.py +174 -0
- dotorm/databases/postgres/transaction.py +82 -0
- dotorm/decorators.py +379 -0
- dotorm/exceptions.py +9 -0
- dotorm/fields.py +604 -0
- dotorm/integrations/__init__.py +0 -0
- dotorm/integrations/pydantic.py +275 -0
- dotorm/model.py +802 -0
- dotorm/orm/__init__.py +15 -0
- dotorm/orm/mixins/__init__.py +13 -0
- dotorm/orm/mixins/access.py +67 -0
- dotorm/orm/mixins/ddl.py +250 -0
- dotorm/orm/mixins/many2many.py +175 -0
- dotorm/orm/mixins/primary.py +218 -0
- dotorm/orm/mixins/relations.py +513 -0
- dotorm/orm/protocol.py +147 -0
- dotorm/orm/utils.py +39 -0
- dotorm-2.0.8.dist-info/METADATA +1240 -0
- dotorm-2.0.8.dist-info/RECORD +50 -0
- dotorm-2.0.8.dist-info/WHEEL +4 -0
- dotorm-2.0.8.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""MySQL connection pool management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import aiomysql
|
|
9
|
+
except ImportError:
|
|
10
|
+
...
|
|
11
|
+
from ..abstract.types import ContainerSettings, MysqlPoolSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("dotorm")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContainerMysql:
|
|
18
|
+
"""
|
|
19
|
+
MySQL connection pool container.
|
|
20
|
+
|
|
21
|
+
Manages pool lifecycle.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
pool_settings: MysqlPoolSettings,
|
|
27
|
+
container_settings: ContainerSettings,
|
|
28
|
+
):
|
|
29
|
+
self.pool_settings = pool_settings
|
|
30
|
+
self.container_settings = container_settings
|
|
31
|
+
self.pool: "aiomysql.Pool | None" = None
|
|
32
|
+
|
|
33
|
+
async def create_pool(self) -> "aiomysql.Pool":
|
|
34
|
+
"""Create connection pool with retry on failure."""
|
|
35
|
+
try:
|
|
36
|
+
start_time = time.time()
|
|
37
|
+
self.pool = await aiomysql.create_pool(
|
|
38
|
+
**self.pool_settings.model_dump(),
|
|
39
|
+
minsize=5,
|
|
40
|
+
maxsize=15,
|
|
41
|
+
autocommit=True,
|
|
42
|
+
# 15 minutes
|
|
43
|
+
pool_recycle=60 * 15,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
log.debug(
|
|
47
|
+
"Connection MySQL db: %s, created time: [%0.3fs]",
|
|
48
|
+
self.pool_settings.db,
|
|
49
|
+
time.time() - start_time,
|
|
50
|
+
)
|
|
51
|
+
return self.pool
|
|
52
|
+
|
|
53
|
+
except (ConnectionError, TimeoutError, aiomysql.OperationalError):
|
|
54
|
+
log.exception(
|
|
55
|
+
"MySQL create pool connection lost, reconnect after %d seconds",
|
|
56
|
+
self.container_settings.reconnect_timeout,
|
|
57
|
+
)
|
|
58
|
+
await asyncio.sleep(self.container_settings.reconnect_timeout)
|
|
59
|
+
return await self.create_pool()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
log.exception("MySQL create pool error:")
|
|
62
|
+
raise e
|
|
63
|
+
|
|
64
|
+
async def close_pool(self):
|
|
65
|
+
"""Close connection pool."""
|
|
66
|
+
if self.pool:
|
|
67
|
+
self.pool.close()
|
|
68
|
+
await self.pool.wait_closed()
|
|
69
|
+
self.pool = None
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""MySQL session implementations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..abstract.session import SessionAbstract
|
|
6
|
+
from ..abstract.dialect import MySQLDialect, CursorType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import aiomysql
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Shared dialect instance
|
|
14
|
+
_dialect = MySQLDialect()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MysqlSession(SessionAbstract):
|
|
18
|
+
"""Base MySQL session."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
async def _do_execute(
|
|
22
|
+
cursor: "aiomysql.Cursor",
|
|
23
|
+
stmt: str,
|
|
24
|
+
values: Any,
|
|
25
|
+
cursor_type: CursorType,
|
|
26
|
+
) -> Any:
|
|
27
|
+
"""
|
|
28
|
+
Execute query on cursor (shared logic).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cursor: aiomysql cursor
|
|
32
|
+
stmt: SQL with %s placeholders
|
|
33
|
+
values: Query values
|
|
34
|
+
cursor_type: Cursor type
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Raw result from aiomysql
|
|
38
|
+
"""
|
|
39
|
+
# executemany
|
|
40
|
+
if cursor_type == "executemany":
|
|
41
|
+
if not values:
|
|
42
|
+
raise ValueError("executemany requires values")
|
|
43
|
+
await cursor.executemany(stmt, values)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Execute query
|
|
47
|
+
if values:
|
|
48
|
+
await cursor.execute(stmt, values)
|
|
49
|
+
else:
|
|
50
|
+
await cursor.execute(stmt)
|
|
51
|
+
|
|
52
|
+
# void - execute only (INSERT/UPDATE/DELETE without return)
|
|
53
|
+
if cursor_type == "void":
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# lastrowid special case
|
|
57
|
+
if cursor_type == "lastrowid":
|
|
58
|
+
return cursor.lastrowid
|
|
59
|
+
|
|
60
|
+
# fetch operations
|
|
61
|
+
method = getattr(cursor, _dialect.get_cursor_method(cursor_type))
|
|
62
|
+
return await method()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TransactionSession(MysqlSession):
|
|
66
|
+
"""
|
|
67
|
+
Session for transactional queries.
|
|
68
|
+
Uses single connection within transaction context.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, connection: "aiomysql.Connection", cursor: "aiomysql.Cursor"
|
|
73
|
+
) -> None:
|
|
74
|
+
self.connection = connection
|
|
75
|
+
self.cursor = cursor
|
|
76
|
+
|
|
77
|
+
async def execute(
|
|
78
|
+
self,
|
|
79
|
+
stmt: str,
|
|
80
|
+
values: Any = None,
|
|
81
|
+
*,
|
|
82
|
+
prepare: Callable | None = None,
|
|
83
|
+
cursor: CursorType = "fetchall",
|
|
84
|
+
) -> Any:
|
|
85
|
+
stmt = _dialect.convert_placeholders(stmt)
|
|
86
|
+
result = await self._do_execute(self.cursor, stmt, values, cursor)
|
|
87
|
+
result = _dialect.convert_result(result, cursor)
|
|
88
|
+
|
|
89
|
+
if prepare and result:
|
|
90
|
+
return prepare(result)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NoTransactionSession(MysqlSession):
|
|
95
|
+
"""
|
|
96
|
+
Session for non-transactional queries.
|
|
97
|
+
Acquires connection from pool per query.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
default_pool: "aiomysql.Pool | None" = None
|
|
101
|
+
|
|
102
|
+
def __init__(self, pool: "aiomysql.Pool | None" = None) -> None:
|
|
103
|
+
if pool is None:
|
|
104
|
+
assert self.default_pool is not None
|
|
105
|
+
self.pool = self.default_pool
|
|
106
|
+
else:
|
|
107
|
+
self.pool = pool
|
|
108
|
+
|
|
109
|
+
async def execute(
|
|
110
|
+
self,
|
|
111
|
+
stmt: str,
|
|
112
|
+
values: Any = None,
|
|
113
|
+
*,
|
|
114
|
+
prepare: Callable | None = None,
|
|
115
|
+
cursor: CursorType = "fetchall",
|
|
116
|
+
) -> Any:
|
|
117
|
+
import aiomysql
|
|
118
|
+
|
|
119
|
+
stmt = _dialect.convert_placeholders(stmt)
|
|
120
|
+
|
|
121
|
+
async with self.pool.acquire() as conn:
|
|
122
|
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
123
|
+
result = await self._do_execute(cur, stmt, values, cursor)
|
|
124
|
+
result = _dialect.convert_result(result, cursor)
|
|
125
|
+
|
|
126
|
+
if prepare and result:
|
|
127
|
+
return prepare(result)
|
|
128
|
+
return result
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""MySQL transaction management."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import aiomysql
|
|
5
|
+
except ImportError:
|
|
6
|
+
aiomysql = None # type: ignore
|
|
7
|
+
|
|
8
|
+
from .session import TransactionSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ContainerTransaction:
|
|
12
|
+
"""
|
|
13
|
+
Transaction context manager for MySQL.
|
|
14
|
+
|
|
15
|
+
Acquires connection, executes queries,
|
|
16
|
+
commits on success, rollbacks on exception.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, pool: "aiomysql.Pool"):
|
|
20
|
+
self.pool = pool
|
|
21
|
+
|
|
22
|
+
async def __aenter__(self):
|
|
23
|
+
connection: "aiomysql.Connection" = await self.pool.acquire()
|
|
24
|
+
cursor: "aiomysql.Cursor" = await connection.cursor(
|
|
25
|
+
aiomysql.DictCursor
|
|
26
|
+
)
|
|
27
|
+
self.session = TransactionSession(connection, cursor)
|
|
28
|
+
return self.session
|
|
29
|
+
|
|
30
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
31
|
+
if exc_type is not None:
|
|
32
|
+
# Выпало исключение вызвать ролбек
|
|
33
|
+
await self.session.connection.rollback()
|
|
34
|
+
else:
|
|
35
|
+
# Не выпало исключение вызвать комит
|
|
36
|
+
await self.session.connection.commit()
|
|
37
|
+
await self.session.cursor.close()
|
|
38
|
+
# В любом случае закрыть соединение и курсор
|
|
39
|
+
self.pool.release(self.session.connection)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""PostgreSQL database support."""
|
|
2
|
+
|
|
3
|
+
from .pool import ContainerPostgres
|
|
4
|
+
from .session import (
|
|
5
|
+
PostgresSession,
|
|
6
|
+
TransactionSession,
|
|
7
|
+
NoTransactionSession,
|
|
8
|
+
NoTransactionNoPoolSession,
|
|
9
|
+
)
|
|
10
|
+
from .transaction import ContainerTransaction, get_current_session
|
|
11
|
+
from ..abstract.dialect import CursorType, PostgresDialect
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ContainerPostgres",
|
|
15
|
+
"PostgresSession",
|
|
16
|
+
"TransactionSession",
|
|
17
|
+
"NoTransactionSession",
|
|
18
|
+
"NoTransactionNoPoolSession",
|
|
19
|
+
"ContainerTransaction",
|
|
20
|
+
"get_current_session",
|
|
21
|
+
"CursorType",
|
|
22
|
+
"PostgresDialect",
|
|
23
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""PostgreSQL connection pool management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import asyncpg
|
|
9
|
+
except ImportError:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
from .transaction import ContainerTransaction
|
|
13
|
+
from ..abstract.types import ContainerSettings, PostgresPoolSettings
|
|
14
|
+
from .session import NoTransactionNoPoolSession
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("dotorm")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContainerPostgres:
|
|
21
|
+
"""
|
|
22
|
+
PostgreSQL connection pool container.
|
|
23
|
+
|
|
24
|
+
Manages pool lifecycle and provides utilities
|
|
25
|
+
for database and table creation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
pool_settings: PostgresPoolSettings,
|
|
31
|
+
container_settings: ContainerSettings,
|
|
32
|
+
):
|
|
33
|
+
self.pool_settings = pool_settings
|
|
34
|
+
self.container_settings = container_settings
|
|
35
|
+
self.pool: "asyncpg.Pool | None" = None
|
|
36
|
+
|
|
37
|
+
async def create_pool(self) -> "asyncpg.Pool":
|
|
38
|
+
"""Create connection pool with retry on failure."""
|
|
39
|
+
try:
|
|
40
|
+
start_time = time.time()
|
|
41
|
+
pool = await asyncpg.create_pool(
|
|
42
|
+
**self.pool_settings.model_dump(),
|
|
43
|
+
min_size=5,
|
|
44
|
+
max_size=15,
|
|
45
|
+
command_timeout=60,
|
|
46
|
+
# 15 minutes
|
|
47
|
+
# max_inactive_connection_lifetime
|
|
48
|
+
# pool_recycle=60 * 15,
|
|
49
|
+
)
|
|
50
|
+
assert pool is not None
|
|
51
|
+
self.pool = pool
|
|
52
|
+
|
|
53
|
+
log.debug(
|
|
54
|
+
"Connection PostgreSQL db: %s, created time: [%0.3fs]",
|
|
55
|
+
self.pool_settings.database,
|
|
56
|
+
time.time() - start_time,
|
|
57
|
+
)
|
|
58
|
+
return self.pool
|
|
59
|
+
except (
|
|
60
|
+
asyncpg.InvalidCatalogNameError,
|
|
61
|
+
asyncpg.ConnectionDoesNotExistError,
|
|
62
|
+
):
|
|
63
|
+
# БД не существует — пробрасываем наверх для создания
|
|
64
|
+
raise
|
|
65
|
+
except (
|
|
66
|
+
ConnectionError,
|
|
67
|
+
TimeoutError,
|
|
68
|
+
asyncpg.exceptions.ConnectionFailureError,
|
|
69
|
+
):
|
|
70
|
+
log.exception(
|
|
71
|
+
"Postgres create pool connection lost, reconnect after %d seconds",
|
|
72
|
+
self.container_settings.reconnect_timeout,
|
|
73
|
+
)
|
|
74
|
+
await asyncio.sleep(self.container_settings.reconnect_timeout)
|
|
75
|
+
return await self.create_pool()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
log.exception("Postgres create pool error:")
|
|
78
|
+
raise e
|
|
79
|
+
|
|
80
|
+
async def close_pool(self):
|
|
81
|
+
"""Close connection pool."""
|
|
82
|
+
if self.pool:
|
|
83
|
+
# await self.pool.close()
|
|
84
|
+
self.pool.terminate()
|
|
85
|
+
self.pool = None
|
|
86
|
+
|
|
87
|
+
async def create_database(self):
|
|
88
|
+
"""Create database if it doesn't exist."""
|
|
89
|
+
db_to_create = self.pool_settings.database
|
|
90
|
+
# Connect to default postgres database
|
|
91
|
+
temp_settings = self.pool_settings.model_copy()
|
|
92
|
+
temp_settings.database = "postgres"
|
|
93
|
+
|
|
94
|
+
conn = await NoTransactionNoPoolSession.get_connection(temp_settings)
|
|
95
|
+
sql = f'CREATE DATABASE "{db_to_create}"'
|
|
96
|
+
try:
|
|
97
|
+
await conn.execute(sql)
|
|
98
|
+
finally:
|
|
99
|
+
await conn.close()
|
|
100
|
+
|
|
101
|
+
async def create_and_update_tables(self, models: list):
|
|
102
|
+
"""
|
|
103
|
+
Create/update tables for given models.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
models: List of DotModel classes
|
|
107
|
+
"""
|
|
108
|
+
stmt_foreign_keys: list[tuple[str, str]] = []
|
|
109
|
+
|
|
110
|
+
async with ContainerTransaction(self.pool) as session:
|
|
111
|
+
# создаем модели в БД, без FK
|
|
112
|
+
for model in models:
|
|
113
|
+
foreign_keys = await model.__create_table__(session)
|
|
114
|
+
stmt_foreign_keys += foreign_keys
|
|
115
|
+
|
|
116
|
+
if not stmt_foreign_keys:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Дедупликация по имени FK (M2M таблицы могут дублироваться)
|
|
120
|
+
unique_fks = {
|
|
121
|
+
fk_name: fk_sql for fk_name, fk_sql in stmt_foreign_keys
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Получаем существующие FK одним запросом
|
|
125
|
+
existing_fk_result = await session.execute(
|
|
126
|
+
"SELECT conname FROM pg_constraint WHERE contype = 'f'"
|
|
127
|
+
)
|
|
128
|
+
existing_fk_names = {row["conname"] for row in existing_fk_result}
|
|
129
|
+
|
|
130
|
+
# Создаём только отсутствующие FK
|
|
131
|
+
for fk_name, fk_sql in unique_fks.items():
|
|
132
|
+
if fk_name not in existing_fk_names:
|
|
133
|
+
await session.execute(fk_sql)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""PostgreSQL session implementations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..abstract.types import PostgresPoolSettings
|
|
6
|
+
from ..abstract.session import SessionAbstract
|
|
7
|
+
from ..abstract.dialect import PostgresDialect, CursorType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import asyncpg
|
|
12
|
+
from asyncpg.transaction import Transaction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Shared dialect instance
|
|
16
|
+
_dialect = PostgresDialect()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PostgresSession(SessionAbstract):
|
|
20
|
+
"""Base PostgreSQL session."""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
async def _do_execute(
|
|
24
|
+
conn: "asyncpg.Connection",
|
|
25
|
+
stmt: str,
|
|
26
|
+
values: Any,
|
|
27
|
+
cursor: CursorType,
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""
|
|
30
|
+
Execute query on connection (shared logic).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
conn: asyncpg connection
|
|
34
|
+
stmt: SQL with $1, $2... placeholders (already converted)
|
|
35
|
+
values: Query values
|
|
36
|
+
cursor: Cursor type
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Raw result from asyncpg
|
|
40
|
+
"""
|
|
41
|
+
# executemany
|
|
42
|
+
if cursor == "executemany":
|
|
43
|
+
if not values:
|
|
44
|
+
raise ValueError("executemany requires values")
|
|
45
|
+
# Handle [[v1, v2], [v3, v4]] or [[[v1, v2], [v3, v4]]] format
|
|
46
|
+
rows = (
|
|
47
|
+
values[0]
|
|
48
|
+
if isinstance(values[0], list)
|
|
49
|
+
and values[0]
|
|
50
|
+
and isinstance(values[0][0], (list, tuple))
|
|
51
|
+
else values
|
|
52
|
+
)
|
|
53
|
+
for row in rows:
|
|
54
|
+
await conn.execute(stmt, *row)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# void - execute only (INSERT/UPDATE/DELETE without return)
|
|
58
|
+
if cursor == "void":
|
|
59
|
+
if values:
|
|
60
|
+
await conn.execute(stmt, *values)
|
|
61
|
+
else:
|
|
62
|
+
await conn.execute(stmt)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# fetch operations
|
|
66
|
+
method = getattr(conn, _dialect.get_cursor_method(cursor))
|
|
67
|
+
if values:
|
|
68
|
+
return await method(stmt, *values)
|
|
69
|
+
return await method(stmt)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TransactionSession(PostgresSession):
|
|
73
|
+
"""
|
|
74
|
+
Session for transactional queries.
|
|
75
|
+
Uses single connection within transaction context.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self, connection: "asyncpg.Connection", transaction: "Transaction"
|
|
80
|
+
) -> None:
|
|
81
|
+
self.connection = connection
|
|
82
|
+
self.transaction = transaction
|
|
83
|
+
|
|
84
|
+
async def execute(
|
|
85
|
+
self,
|
|
86
|
+
stmt: str,
|
|
87
|
+
values: Any = None,
|
|
88
|
+
*,
|
|
89
|
+
prepare: Callable | None = None,
|
|
90
|
+
cursor: CursorType = "fetchall",
|
|
91
|
+
) -> Any:
|
|
92
|
+
stmt = _dialect.convert_placeholders(stmt)
|
|
93
|
+
result = await self._do_execute(self.connection, stmt, values, cursor)
|
|
94
|
+
result = _dialect.convert_result(result, cursor)
|
|
95
|
+
|
|
96
|
+
if prepare and result:
|
|
97
|
+
return prepare(result)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NoTransactionSession(PostgresSession):
|
|
102
|
+
"""
|
|
103
|
+
Session for non-transactional queries.
|
|
104
|
+
Acquires connection from pool per query.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
default_pool: "asyncpg.Pool | None" = None
|
|
108
|
+
|
|
109
|
+
def __init__(self, pool: "asyncpg.Pool | None" = None) -> None:
|
|
110
|
+
if pool is None:
|
|
111
|
+
assert self.default_pool is not None
|
|
112
|
+
self.pool = self.default_pool
|
|
113
|
+
else:
|
|
114
|
+
self.pool = pool
|
|
115
|
+
|
|
116
|
+
async def execute(
|
|
117
|
+
self,
|
|
118
|
+
stmt: str,
|
|
119
|
+
values: Any = None,
|
|
120
|
+
*,
|
|
121
|
+
prepare: Callable | None = None,
|
|
122
|
+
cursor: CursorType = "fetchall",
|
|
123
|
+
) -> Any:
|
|
124
|
+
stmt = _dialect.convert_placeholders(stmt)
|
|
125
|
+
|
|
126
|
+
async with self.pool.acquire() as conn:
|
|
127
|
+
result = await self._do_execute(conn, stmt, values, cursor)
|
|
128
|
+
result = _dialect.convert_result(result, cursor)
|
|
129
|
+
|
|
130
|
+
if prepare and result:
|
|
131
|
+
return prepare(result)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class NoTransactionNoPoolSession(PostgresSession):
|
|
136
|
+
"""
|
|
137
|
+
Session without pool.
|
|
138
|
+
Opens connection, executes, closes. For admin tasks.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
async def get_connection(
|
|
143
|
+
cls, settings: PostgresPoolSettings
|
|
144
|
+
) -> "asyncpg.Connection":
|
|
145
|
+
"""Create new connection without pool."""
|
|
146
|
+
import asyncpg
|
|
147
|
+
|
|
148
|
+
return await asyncpg.connect(**settings.model_dump())
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
async def execute(
|
|
152
|
+
cls,
|
|
153
|
+
settings: PostgresPoolSettings,
|
|
154
|
+
stmt: str,
|
|
155
|
+
values: Any = None,
|
|
156
|
+
*,
|
|
157
|
+
prepare: Callable | None = None,
|
|
158
|
+
cursor: str = "execute",
|
|
159
|
+
) -> Any:
|
|
160
|
+
conn = await cls.get_connection(settings)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
if values:
|
|
164
|
+
await conn.execute(stmt, values)
|
|
165
|
+
else:
|
|
166
|
+
await conn.execute(stmt)
|
|
167
|
+
|
|
168
|
+
rows = await getattr(conn, cursor)()
|
|
169
|
+
|
|
170
|
+
if prepare:
|
|
171
|
+
return prepare(rows)
|
|
172
|
+
return rows
|
|
173
|
+
finally:
|
|
174
|
+
await conn.close()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""PostgreSQL transaction management."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import asyncpg
|
|
7
|
+
from asyncpg.transaction import Transaction
|
|
8
|
+
except ImportError:
|
|
9
|
+
asyncpg = None # type: ignore
|
|
10
|
+
Transaction = None # type: ignore
|
|
11
|
+
|
|
12
|
+
from .session import TransactionSession
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Context variable для хранения текущей сессии транзакции
|
|
16
|
+
_current_session: ContextVar["TransactionSession | None"] = ContextVar(
|
|
17
|
+
"current_session", default=None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_current_session() -> "TransactionSession | None":
|
|
22
|
+
"""Получить текущую сессию из контекста (если есть активная транзакция)."""
|
|
23
|
+
return _current_session.get()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContainerTransaction:
|
|
27
|
+
"""
|
|
28
|
+
Transaction context manager for PostgreSQL.
|
|
29
|
+
|
|
30
|
+
Acquires connection, starts transaction, executes queries,
|
|
31
|
+
commits on success, rollbacks on exception.
|
|
32
|
+
|
|
33
|
+
Автоматически устанавливает текущую сессию в contextvars,
|
|
34
|
+
так что методы ORM могут использовать её без явной передачи.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
async with ContainerTransaction(pool) as session:
|
|
38
|
+
await session.execute("INSERT INTO users ...")
|
|
39
|
+
# Или без явной передачи session:
|
|
40
|
+
await User.create(payload=user) # session подставится из контекста
|
|
41
|
+
# Commits on exit
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
default_pool: "asyncpg.Pool | None" = None
|
|
45
|
+
|
|
46
|
+
def __init__(self, pool: "asyncpg.Pool | None" = None):
|
|
47
|
+
self.session_factory = TransactionSession
|
|
48
|
+
if pool is None:
|
|
49
|
+
assert self.default_pool is not None
|
|
50
|
+
self.pool = self.default_pool
|
|
51
|
+
else:
|
|
52
|
+
self.pool = pool
|
|
53
|
+
self._token = None
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self):
|
|
56
|
+
connection: "asyncpg.Connection" = await self.pool.acquire()
|
|
57
|
+
transaction = connection.transaction()
|
|
58
|
+
|
|
59
|
+
assert isinstance(transaction, Transaction)
|
|
60
|
+
assert isinstance(connection, asyncpg.Connection)
|
|
61
|
+
|
|
62
|
+
await transaction.start()
|
|
63
|
+
self.session = self.session_factory(connection, transaction)
|
|
64
|
+
|
|
65
|
+
# Устанавливаем текущую сессию в контекст
|
|
66
|
+
self._token = _current_session.set(self.session)
|
|
67
|
+
|
|
68
|
+
return self.session
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
71
|
+
# Сбрасываем контекст
|
|
72
|
+
if self._token is not None:
|
|
73
|
+
_current_session.reset(self._token)
|
|
74
|
+
|
|
75
|
+
if exc_type is not None:
|
|
76
|
+
# Выпало исключение вызвать ролбек
|
|
77
|
+
await self.session.transaction.rollback()
|
|
78
|
+
else:
|
|
79
|
+
# Не выпало исключение вызвать комит
|
|
80
|
+
await self.session.transaction.commit()
|
|
81
|
+
# В любом случае вернуть соединение в пул
|
|
82
|
+
await self.pool.release(self.session.connection)
|