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,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL filter expression parser.
|
|
3
|
+
|
|
4
|
+
Converts filter expressions to SQL WHERE clauses.
|
|
5
|
+
Extracted to avoid code duplication in builders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Literal, Union
|
|
9
|
+
|
|
10
|
+
from .dialect import Dialect
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Type definitions
|
|
14
|
+
SQLOperator = Literal[
|
|
15
|
+
"=",
|
|
16
|
+
">",
|
|
17
|
+
"<",
|
|
18
|
+
"!=",
|
|
19
|
+
">=",
|
|
20
|
+
"<=",
|
|
21
|
+
"like",
|
|
22
|
+
"ilike",
|
|
23
|
+
"=like",
|
|
24
|
+
"=ilike",
|
|
25
|
+
"not ilike",
|
|
26
|
+
"not like",
|
|
27
|
+
"in",
|
|
28
|
+
"not in",
|
|
29
|
+
"is null",
|
|
30
|
+
"is not null",
|
|
31
|
+
"between",
|
|
32
|
+
"not between",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
FilterTriplet = tuple[str, SQLOperator, Any]
|
|
36
|
+
|
|
37
|
+
FilterExpression = list[
|
|
38
|
+
FilterTriplet
|
|
39
|
+
| tuple[Literal["not"], "FilterExpression"]
|
|
40
|
+
| list[Union["FilterExpression", Literal["and", "or"]]],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FilterParser:
|
|
45
|
+
"""
|
|
46
|
+
Parses filter expressions into SQL WHERE clauses.
|
|
47
|
+
|
|
48
|
+
Supports:
|
|
49
|
+
- Simple triplets: ("field", "=", value)
|
|
50
|
+
- NOT expressions: ("not", ("field", "=", value))
|
|
51
|
+
- Nested logic: [("a", "=", 1), "or", ("b", "=", 2)]
|
|
52
|
+
- Complex nesting with AND/OR
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
parser = FilterParser(POSTGRES)
|
|
56
|
+
clause, values = parser.parse([
|
|
57
|
+
("active", "=", True),
|
|
58
|
+
"or",
|
|
59
|
+
[("role", "=", "admin"), ("verified", "=", True)]
|
|
60
|
+
])
|
|
61
|
+
# clause: '"active" = $1 OR ("role" = $2 AND "verified" = $3)'
|
|
62
|
+
# values: (True, "admin", True)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, dialect: Dialect):
|
|
66
|
+
self.dialect = dialect
|
|
67
|
+
|
|
68
|
+
def _is_triplet(self, expr: Any) -> bool:
|
|
69
|
+
"""Check if expression is a simple triplet."""
|
|
70
|
+
return (
|
|
71
|
+
isinstance(expr, (list, tuple))
|
|
72
|
+
and len(expr) == 3
|
|
73
|
+
and isinstance(expr[0], str)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def parse(self, filter_expr: FilterExpression) -> tuple[str, tuple]:
|
|
77
|
+
"""Recursively parse filter expression."""
|
|
78
|
+
escape = self.dialect.escape
|
|
79
|
+
|
|
80
|
+
# NOT expression: ("not", expr)
|
|
81
|
+
if (
|
|
82
|
+
isinstance(filter_expr, (list, tuple))
|
|
83
|
+
and len(filter_expr) == 2
|
|
84
|
+
and filter_expr[0] == "not"
|
|
85
|
+
):
|
|
86
|
+
inner_expr = filter_expr[1]
|
|
87
|
+
clause, values = self.parse(inner_expr) # type: ignore
|
|
88
|
+
return f"NOT ({clause})", values
|
|
89
|
+
|
|
90
|
+
# Simple triplet: ("field", "op", value)
|
|
91
|
+
if self._is_triplet(filter_expr):
|
|
92
|
+
field, op, value = filter_expr
|
|
93
|
+
field = f"{escape}{field}{escape}"
|
|
94
|
+
assert isinstance(op, str)
|
|
95
|
+
op = op.lower()
|
|
96
|
+
|
|
97
|
+
if op in ("in", "not in"):
|
|
98
|
+
if not isinstance(value, (list, tuple)):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Operator '{op}' requires list/tuple value"
|
|
101
|
+
)
|
|
102
|
+
placeholders = ", ".join(["%s"] * len(value))
|
|
103
|
+
clause = f"{field} {op.upper()} ({placeholders})"
|
|
104
|
+
return clause, tuple(value)
|
|
105
|
+
|
|
106
|
+
elif op in (
|
|
107
|
+
"like",
|
|
108
|
+
"ilike",
|
|
109
|
+
"=like",
|
|
110
|
+
"=ilike",
|
|
111
|
+
"not like",
|
|
112
|
+
"not ilike",
|
|
113
|
+
):
|
|
114
|
+
clause = f"{field} {op.upper()} %s"
|
|
115
|
+
return clause, ("%" + str(value) + "%",)
|
|
116
|
+
|
|
117
|
+
elif op in ("=", "!=", ">", "<", ">=", "<="):
|
|
118
|
+
# None -> IS NULL / IS NOT NULL
|
|
119
|
+
if value is None:
|
|
120
|
+
if op == "=":
|
|
121
|
+
return f"{field} IS NULL", ()
|
|
122
|
+
elif op == "!=":
|
|
123
|
+
return f"{field} IS NOT NULL", ()
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Operator '{op}' cannot be used with None"
|
|
127
|
+
)
|
|
128
|
+
clause = f"{field} {op} %s"
|
|
129
|
+
return clause, (value,)
|
|
130
|
+
|
|
131
|
+
elif op == "is null":
|
|
132
|
+
return f"{field} IS NULL", ()
|
|
133
|
+
|
|
134
|
+
elif op == "is not null":
|
|
135
|
+
return f"{field} IS NOT NULL", ()
|
|
136
|
+
|
|
137
|
+
elif op in ("between", "not between"):
|
|
138
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Operator '{op}' requires list of two values"
|
|
141
|
+
)
|
|
142
|
+
clause = f"{field} {op.upper()} %s AND %s"
|
|
143
|
+
return clause, (value[0], value[1])
|
|
144
|
+
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
147
|
+
|
|
148
|
+
# Nested expression: list with possible AND/OR
|
|
149
|
+
elif isinstance(filter_expr, list):
|
|
150
|
+
parts: list[tuple[str, str, bool]] = []
|
|
151
|
+
values: list[Any] = []
|
|
152
|
+
|
|
153
|
+
i = 0
|
|
154
|
+
while i < len(filter_expr):
|
|
155
|
+
item = filter_expr[i]
|
|
156
|
+
|
|
157
|
+
if isinstance(item, (list, tuple)):
|
|
158
|
+
clause, clause_values = self.parse(item) # type: ignore
|
|
159
|
+
wrap = not self._is_triplet(item)
|
|
160
|
+
parts.append(("EXPR", clause, wrap))
|
|
161
|
+
values.extend(clause_values)
|
|
162
|
+
i += 1
|
|
163
|
+
|
|
164
|
+
elif isinstance(item, str) and item.lower() in ("and", "or"):
|
|
165
|
+
parts.append(("OP", item.upper(), False))
|
|
166
|
+
i += 1
|
|
167
|
+
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Invalid filter element at position {i}: {item}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Auto-insert AND between consecutive expressions
|
|
174
|
+
normalized: list[tuple[str, str, bool]] = []
|
|
175
|
+
for idx, part in enumerate(parts):
|
|
176
|
+
if (
|
|
177
|
+
idx > 0
|
|
178
|
+
and part[0] == "EXPR"
|
|
179
|
+
and parts[idx - 1][0] == "EXPR"
|
|
180
|
+
):
|
|
181
|
+
normalized.append(("OP", "AND", False))
|
|
182
|
+
normalized.append(part)
|
|
183
|
+
|
|
184
|
+
sql_parts: list[str] = []
|
|
185
|
+
for part in normalized:
|
|
186
|
+
kind, content, wrap = part
|
|
187
|
+
if kind == "EXPR":
|
|
188
|
+
sql_parts.append(f"({content})" if wrap else content)
|
|
189
|
+
elif kind == "OP":
|
|
190
|
+
sql_parts.append(content)
|
|
191
|
+
|
|
192
|
+
return " ".join(sql_parts), tuple(values)
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
raise ValueError("Unsupported filter expression format")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Abstract database interfaces."""
|
|
2
|
+
|
|
3
|
+
from .pool import PoolAbstract
|
|
4
|
+
from .session import SessionAbstract
|
|
5
|
+
from .dialect import Dialect, PostgresDialect, MySQLDialect, ClickHouseDialect, CursorType
|
|
6
|
+
from .types import (
|
|
7
|
+
ContainerSettings,
|
|
8
|
+
PostgresPoolSettings,
|
|
9
|
+
MysqlPoolSettings,
|
|
10
|
+
ClickhousePoolSettings,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PoolAbstract",
|
|
15
|
+
"SessionAbstract",
|
|
16
|
+
"Dialect",
|
|
17
|
+
"PostgresDialect",
|
|
18
|
+
"MySQLDialect",
|
|
19
|
+
"ClickHouseDialect",
|
|
20
|
+
"CursorType",
|
|
21
|
+
"ContainerSettings",
|
|
22
|
+
"PostgresPoolSettings",
|
|
23
|
+
"MysqlPoolSettings",
|
|
24
|
+
"ClickhousePoolSettings",
|
|
25
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Database dialect abstraction (Strategy pattern)."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Unified cursor types for all dialects
|
|
8
|
+
CursorType = Literal[
|
|
9
|
+
"fetchall",
|
|
10
|
+
"fetch",
|
|
11
|
+
"fetchrow",
|
|
12
|
+
"fetchval",
|
|
13
|
+
"executemany",
|
|
14
|
+
"lastrowid", # MySQL-specific
|
|
15
|
+
"void", # Execute without returning results
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Dialect(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Abstract dialect defining database-specific behavior.
|
|
22
|
+
|
|
23
|
+
Each database has its own dialect that handles:
|
|
24
|
+
- Placeholder conversion (%s → $1 for Postgres, %s stays for MySQL)
|
|
25
|
+
- Cursor method mapping (fetchall → fetch for asyncpg)
|
|
26
|
+
- Result conversion (Record → dict)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_cursor_map: dict[str, str]
|
|
30
|
+
|
|
31
|
+
def convert_placeholders(self, stmt: str) -> str:
|
|
32
|
+
"""Convert %s placeholders to database-specific format."""
|
|
33
|
+
return stmt
|
|
34
|
+
|
|
35
|
+
def get_cursor_method(self, cursor: CursorType) -> str:
|
|
36
|
+
"""Map CursorType to actual driver method name."""
|
|
37
|
+
return self._cursor_map.get(cursor, "void")
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def convert_result(self, rows: Any, cursor: CursorType) -> Any:
|
|
41
|
+
"""Convert raw database result to standard format (list of dicts, etc)."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PostgresDialect(Dialect):
|
|
46
|
+
"""PostgreSQL dialect for asyncpg driver."""
|
|
47
|
+
|
|
48
|
+
_cursor_map = {
|
|
49
|
+
"fetchall": "fetch",
|
|
50
|
+
"fetch": "fetch",
|
|
51
|
+
"fetchrow": "fetchrow",
|
|
52
|
+
"fetchval": "fetchval",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def convert_placeholders(self, stmt: str) -> str:
|
|
56
|
+
"""Convert %s to $1, $2, $3..."""
|
|
57
|
+
counter = 1
|
|
58
|
+
while "%s" in stmt:
|
|
59
|
+
stmt = stmt.replace("%s", f"${counter}", 1)
|
|
60
|
+
counter += 1
|
|
61
|
+
return stmt
|
|
62
|
+
|
|
63
|
+
def convert_result(self, rows: Any, cursor: CursorType) -> Any:
|
|
64
|
+
"""Convert asyncpg Record objects to dicts."""
|
|
65
|
+
if rows is None or cursor in ("void", "executemany"):
|
|
66
|
+
return rows
|
|
67
|
+
|
|
68
|
+
if cursor == "fetchval":
|
|
69
|
+
return rows # Single value
|
|
70
|
+
|
|
71
|
+
if cursor == "fetchrow":
|
|
72
|
+
return dict(rows) if rows else None
|
|
73
|
+
|
|
74
|
+
# fetchall/fetch
|
|
75
|
+
return [dict(rec) for rec in rows] if rows else []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MySQLDialect(Dialect):
|
|
79
|
+
"""MySQL dialect for aiomysql driver."""
|
|
80
|
+
|
|
81
|
+
_cursor_map = {
|
|
82
|
+
"fetchall": "fetchall",
|
|
83
|
+
"fetch": "fetchall",
|
|
84
|
+
"fetchrow": "fetchone",
|
|
85
|
+
"fetchval": "fetchone",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def convert_result(self, rows: Any, cursor: CursorType) -> Any:
|
|
89
|
+
"""Convert MySQL results."""
|
|
90
|
+
if rows is None or cursor in ("void", "executemany", "lastrowid"):
|
|
91
|
+
return rows
|
|
92
|
+
|
|
93
|
+
if cursor == "fetchval":
|
|
94
|
+
# fetchone returns tuple, get first element
|
|
95
|
+
return (
|
|
96
|
+
rows[0] if rows and isinstance(rows, (tuple, list)) else rows
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if cursor == "fetchrow":
|
|
100
|
+
return dict(rows) if rows else None
|
|
101
|
+
|
|
102
|
+
# fetchall/fetch
|
|
103
|
+
return [dict(rec) for rec in rows] if rows else []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ClickHouseDialect(Dialect):
|
|
107
|
+
"""ClickHouse dialect for asynch driver."""
|
|
108
|
+
|
|
109
|
+
_cursor_map = {
|
|
110
|
+
"fetchall": "fetchall",
|
|
111
|
+
"fetch": "fetchall",
|
|
112
|
+
"fetchrow": "fetchone",
|
|
113
|
+
"fetchval": "fetchone",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def convert_result(self, rows: Any, cursor: CursorType) -> Any:
|
|
117
|
+
"""Convert ClickHouse results to dicts."""
|
|
118
|
+
if rows is None or cursor in ("void", "executemany"):
|
|
119
|
+
return rows
|
|
120
|
+
|
|
121
|
+
if cursor == "fetchval":
|
|
122
|
+
# fetchone returns tuple, get first element
|
|
123
|
+
if rows and isinstance(rows, (tuple, list)):
|
|
124
|
+
return rows[0]
|
|
125
|
+
return rows
|
|
126
|
+
|
|
127
|
+
if cursor == "fetchrow":
|
|
128
|
+
# Single row - convert to dict if tuple with column info
|
|
129
|
+
return dict(rows) if rows and hasattr(rows, "_fields") else rows
|
|
130
|
+
|
|
131
|
+
# fetchall/fetch - list of tuples
|
|
132
|
+
if rows and hasattr(rows[0], "_fields"):
|
|
133
|
+
return [dict(rec._asdict()) for rec in rows]
|
|
134
|
+
return rows if rows else []
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Abstract session interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .dialect import CursorType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionAbstract(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract database session.
|
|
13
|
+
|
|
14
|
+
Subclasses must implement execute() method.
|
|
15
|
+
Use dialect helper for SQL transformations.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def execute(
|
|
20
|
+
self,
|
|
21
|
+
stmt: str,
|
|
22
|
+
values: Any = None,
|
|
23
|
+
*,
|
|
24
|
+
prepare: Callable | None = None,
|
|
25
|
+
cursor: "CursorType" = "fetchall",
|
|
26
|
+
) -> Any:
|
|
27
|
+
"""
|
|
28
|
+
Execute SQL query.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
stmt: SQL statement with %s placeholders
|
|
32
|
+
values: Query parameters:
|
|
33
|
+
- Tuple/list for single query
|
|
34
|
+
- List of tuples for executemany
|
|
35
|
+
prepare: Optional function to transform results
|
|
36
|
+
cursor: Fetch mode:
|
|
37
|
+
- "fetchall"/"fetch": Return list of dicts
|
|
38
|
+
- "fetchrow": Return single dict or None
|
|
39
|
+
- "fetchval": Return single value or None
|
|
40
|
+
- "executemany": Execute multiple inserts
|
|
41
|
+
- "lastrowid": Return last inserted row ID (MySQL only)
|
|
42
|
+
- "void": Execute without returning rows (INSERT/UPDATE/DELETE)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Query results based on cursor mode
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
# Fetch all rows
|
|
49
|
+
rows = await session.execute("SELECT * FROM users WHERE active = %s", (True,))
|
|
50
|
+
|
|
51
|
+
# Fetch single row
|
|
52
|
+
user = await session.execute("SELECT * FROM users WHERE id = %s", (1,), cursor="fetchrow")
|
|
53
|
+
|
|
54
|
+
# Fetch single value
|
|
55
|
+
count = await session.execute("SELECT COUNT(*) FROM users", cursor="fetchval")
|
|
56
|
+
|
|
57
|
+
# Execute without return
|
|
58
|
+
await session.execute("UPDATE users SET active = %s", (False,), cursor="void")
|
|
59
|
+
|
|
60
|
+
# Execute many
|
|
61
|
+
await session.execute(
|
|
62
|
+
"INSERT INTO users (name) VALUES (%s)",
|
|
63
|
+
[("Alice",), ("Bob",)],
|
|
64
|
+
cursor="executemany"
|
|
65
|
+
)
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Database configuration types."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic_settings import BaseSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContainerSettings(BaseSettings):
|
|
8
|
+
reconnect_timeout: int = 10
|
|
9
|
+
driver: Literal["asynch", "aiomysql", "asyncpg"]
|
|
10
|
+
ssl: str = ""
|
|
11
|
+
sync_db: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PostgresPoolSettings(BaseSettings):
|
|
15
|
+
host: str
|
|
16
|
+
port: int
|
|
17
|
+
user: str
|
|
18
|
+
password: str
|
|
19
|
+
database: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClickhousePoolSettings(BaseSettings):
|
|
23
|
+
host: str
|
|
24
|
+
port: int
|
|
25
|
+
user: str
|
|
26
|
+
password: str
|
|
27
|
+
database: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# настройки драйвера для mysql отличаются
|
|
31
|
+
class MysqlPoolSettings(BaseSettings):
|
|
32
|
+
host: str
|
|
33
|
+
port: int
|
|
34
|
+
user: str
|
|
35
|
+
password: str
|
|
36
|
+
db: str
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import asynch
|
|
7
|
+
except ImportError:
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
from ..abstract.types import (
|
|
11
|
+
ClickhousePoolSettings,
|
|
12
|
+
ContainerSettings,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__package__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContainerClickhouse:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
pool_settings: ClickhousePoolSettings,
|
|
22
|
+
container_settings: ContainerSettings,
|
|
23
|
+
):
|
|
24
|
+
self.pool_settings = pool_settings
|
|
25
|
+
self.container_settings = container_settings
|
|
26
|
+
|
|
27
|
+
async def create_pool(self):
|
|
28
|
+
try:
|
|
29
|
+
start_time: float = time.time()
|
|
30
|
+
pool = await asynch.create_pool(
|
|
31
|
+
**self.pool_settings.model_dump(),
|
|
32
|
+
min_size=5,
|
|
33
|
+
max_size=15,
|
|
34
|
+
# command_timeout=60,
|
|
35
|
+
# 15 minutes
|
|
36
|
+
# max_inactive_connection_lifetime
|
|
37
|
+
# pool_recycle=60 * 15,
|
|
38
|
+
)
|
|
39
|
+
assert isinstance(pool, asynch.Pool)
|
|
40
|
+
assert pool is not None
|
|
41
|
+
self.pool = pool
|
|
42
|
+
start_time: float = time.time()
|
|
43
|
+
|
|
44
|
+
log.debug(
|
|
45
|
+
"Connection Clickhouse db: %s, created time: [%0.3fs]",
|
|
46
|
+
self.pool_settings.database,
|
|
47
|
+
time.time() - start_time,
|
|
48
|
+
)
|
|
49
|
+
return self.pool
|
|
50
|
+
except (ConnectionError, TimeoutError):
|
|
51
|
+
# Если не смогли подключиться к базе пробуем переподключиться
|
|
52
|
+
log.exception(
|
|
53
|
+
"Clickhouse create poll connection lost, reconnect after 10 seconds: "
|
|
54
|
+
)
|
|
55
|
+
await asyncio.sleep(self.container_settings.reconnect_timeout)
|
|
56
|
+
await self.create_pool()
|
|
57
|
+
except Exception as e:
|
|
58
|
+
# если ошибка не связанна с сетью, завершаем выполнение программы
|
|
59
|
+
log.exception("Clickhouse create pool error:")
|
|
60
|
+
raise e
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""ClickHouse session implementations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..abstract.session import SessionAbstract
|
|
6
|
+
from ..abstract.dialect import ClickHouseDialect, CursorType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import asynch
|
|
11
|
+
from asynch.cursors import Cursor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Shared dialect instance
|
|
15
|
+
_dialect = ClickHouseDialect()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClickhouseSession(SessionAbstract):
|
|
19
|
+
"""Base ClickHouse session."""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
async def _do_execute(
|
|
23
|
+
cursor: "Cursor",
|
|
24
|
+
stmt: str,
|
|
25
|
+
values: Any,
|
|
26
|
+
cursor_type: CursorType,
|
|
27
|
+
) -> Any:
|
|
28
|
+
"""
|
|
29
|
+
Execute query on cursor (shared logic).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cursor: asynch cursor
|
|
33
|
+
stmt: SQL with %s placeholders
|
|
34
|
+
values: Query values
|
|
35
|
+
cursor_type: Cursor type
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Raw result from asynch
|
|
39
|
+
"""
|
|
40
|
+
# executemany
|
|
41
|
+
if cursor_type == "executemany":
|
|
42
|
+
if not values:
|
|
43
|
+
raise ValueError("executemany requires values")
|
|
44
|
+
for row in values:
|
|
45
|
+
await cursor.execute(stmt, row)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Execute query
|
|
49
|
+
if values:
|
|
50
|
+
await cursor.execute(stmt, values)
|
|
51
|
+
else:
|
|
52
|
+
await cursor.execute(stmt)
|
|
53
|
+
|
|
54
|
+
# void - execute only (INSERT without return)
|
|
55
|
+
if cursor_type == "void":
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# fetch operations
|
|
59
|
+
method_name = _dialect.get_cursor_method(cursor_type)
|
|
60
|
+
if method_name:
|
|
61
|
+
method = getattr(cursor, method_name)
|
|
62
|
+
return await method()
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NoTransactionSession(ClickhouseSession):
|
|
67
|
+
"""
|
|
68
|
+
Session for non-transactional queries.
|
|
69
|
+
Acquires connection from pool per query.
|
|
70
|
+
|
|
71
|
+
Note: ClickHouse doesn't support transactions.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
default_pool: "asynch.Pool | None" = None
|
|
75
|
+
|
|
76
|
+
def __init__(self, pool: "asynch.Pool | None" = None) -> None:
|
|
77
|
+
if pool is None:
|
|
78
|
+
assert self.default_pool is not None
|
|
79
|
+
self.pool = self.default_pool
|
|
80
|
+
else:
|
|
81
|
+
self.pool = pool
|
|
82
|
+
|
|
83
|
+
async def execute(
|
|
84
|
+
self,
|
|
85
|
+
stmt: str,
|
|
86
|
+
values: Any = None,
|
|
87
|
+
*,
|
|
88
|
+
prepare: Callable | None = None,
|
|
89
|
+
cursor: CursorType = "fetchall",
|
|
90
|
+
) -> Any:
|
|
91
|
+
stmt = _dialect.convert_placeholders(stmt)
|
|
92
|
+
|
|
93
|
+
async with self.pool.acquire() as conn:
|
|
94
|
+
async with conn.cursor() as cur:
|
|
95
|
+
result = await self._do_execute(cur, stmt, values, cursor)
|
|
96
|
+
result = _dialect.convert_result(result, cursor)
|
|
97
|
+
|
|
98
|
+
if prepare and result:
|
|
99
|
+
return prepare(result)
|
|
100
|
+
return result
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MySQL database support."""
|
|
2
|
+
|
|
3
|
+
from .pool import ContainerMysql
|
|
4
|
+
from .session import MysqlSession, TransactionSession, NoTransactionSession
|
|
5
|
+
from .transaction import ContainerTransaction
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ContainerMysql",
|
|
9
|
+
"MysqlSession",
|
|
10
|
+
"TransactionSession",
|
|
11
|
+
"NoTransactionSession",
|
|
12
|
+
"ContainerTransaction",
|
|
13
|
+
]
|