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.
Files changed (50) hide show
  1. dotorm/__init__.py +87 -0
  2. dotorm/access.py +151 -0
  3. dotorm/builder/__init__.py +0 -0
  4. dotorm/builder/builder.py +72 -0
  5. dotorm/builder/helpers.py +63 -0
  6. dotorm/builder/mixins/__init__.py +11 -0
  7. dotorm/builder/mixins/crud.py +246 -0
  8. dotorm/builder/mixins/m2m.py +110 -0
  9. dotorm/builder/mixins/relations.py +96 -0
  10. dotorm/builder/protocol.py +63 -0
  11. dotorm/builder/request_builder.py +144 -0
  12. dotorm/components/__init__.py +18 -0
  13. dotorm/components/dialect.py +99 -0
  14. dotorm/components/filter_parser.py +195 -0
  15. dotorm/databases/__init__.py +13 -0
  16. dotorm/databases/abstract/__init__.py +25 -0
  17. dotorm/databases/abstract/dialect.py +134 -0
  18. dotorm/databases/abstract/pool.py +10 -0
  19. dotorm/databases/abstract/session.py +67 -0
  20. dotorm/databases/abstract/types.py +36 -0
  21. dotorm/databases/clickhouse/__init__.py +8 -0
  22. dotorm/databases/clickhouse/pool.py +60 -0
  23. dotorm/databases/clickhouse/session.py +100 -0
  24. dotorm/databases/mysql/__init__.py +13 -0
  25. dotorm/databases/mysql/pool.py +69 -0
  26. dotorm/databases/mysql/session.py +128 -0
  27. dotorm/databases/mysql/transaction.py +39 -0
  28. dotorm/databases/postgres/__init__.py +23 -0
  29. dotorm/databases/postgres/pool.py +133 -0
  30. dotorm/databases/postgres/session.py +174 -0
  31. dotorm/databases/postgres/transaction.py +82 -0
  32. dotorm/decorators.py +379 -0
  33. dotorm/exceptions.py +9 -0
  34. dotorm/fields.py +604 -0
  35. dotorm/integrations/__init__.py +0 -0
  36. dotorm/integrations/pydantic.py +275 -0
  37. dotorm/model.py +802 -0
  38. dotorm/orm/__init__.py +15 -0
  39. dotorm/orm/mixins/__init__.py +13 -0
  40. dotorm/orm/mixins/access.py +67 -0
  41. dotorm/orm/mixins/ddl.py +250 -0
  42. dotorm/orm/mixins/many2many.py +175 -0
  43. dotorm/orm/mixins/primary.py +218 -0
  44. dotorm/orm/mixins/relations.py +513 -0
  45. dotorm/orm/protocol.py +147 -0
  46. dotorm/orm/utils.py +39 -0
  47. dotorm-2.0.8.dist-info/METADATA +1240 -0
  48. dotorm-2.0.8.dist-info/RECORD +50 -0
  49. dotorm-2.0.8.dist-info/WHEEL +4 -0
  50. dotorm-2.0.8.dist-info/licenses/LICENSE +21 -0
dotorm/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ DotORM v2
3
+ """
4
+
5
+ # Fields
6
+ from .fields import (
7
+ Field,
8
+ Integer,
9
+ BigInteger,
10
+ SmallInteger,
11
+ Char,
12
+ Selection,
13
+ Text,
14
+ Boolean,
15
+ Decimal,
16
+ Datetime,
17
+ Date,
18
+ Time,
19
+ Float,
20
+ JSONField,
21
+ Binary,
22
+ Many2one,
23
+ One2many,
24
+ Many2many,
25
+ One2one,
26
+ AttachmentMany2one,
27
+ AttachmentOne2many,
28
+ )
29
+
30
+ # Model
31
+ from .model import DotModel
32
+ from .model import Model, JsonMode
33
+
34
+ # Components
35
+ from .components import (
36
+ Dialect,
37
+ POSTGRES,
38
+ MYSQL,
39
+ FilterParser,
40
+ FilterExpression,
41
+ )
42
+
43
+ # Exceptions
44
+ from .exceptions import (
45
+ OrmConfigurationFieldException,
46
+ OrmUpdateEmptyParamsException,
47
+ )
48
+
49
+ __version__ = "2.0.8"
50
+
51
+ __all__ = [
52
+ # Fields
53
+ "Field",
54
+ "Integer",
55
+ "BigInteger",
56
+ "SmallInteger",
57
+ "Char",
58
+ "Selection",
59
+ "Text",
60
+ "Boolean",
61
+ "Decimal",
62
+ "Datetime",
63
+ "Date",
64
+ "Time",
65
+ "Float",
66
+ "JSONField",
67
+ "Binary",
68
+ "Many2one",
69
+ "One2many",
70
+ "Many2many",
71
+ "One2one",
72
+ "AttachmentMany2one",
73
+ "AttachmentOne2many",
74
+ # Model
75
+ "DotModel",
76
+ "Model",
77
+ "JsonMode",
78
+ # Components
79
+ "Dialect",
80
+ "POSTGRES",
81
+ "MYSQL",
82
+ "FilterParser",
83
+ "FilterExpression",
84
+ # Exceptions
85
+ "OrmConfigurationFieldException",
86
+ "OrmUpdateEmptyParamsException",
87
+ ]
dotorm/access.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Контекст доступа для DotORM.
3
+
4
+ Использование:
5
+
6
+ # При старте приложения (security/app.py):
7
+ set_access_checker(SecurityAccessChecker(env))
8
+
9
+ # При каждом запросе (verify_access):
10
+ set_access_session(session) # Session из security модуля
11
+
12
+ # В DotModel автоматически:
13
+ # - check_table_access() перед CRUD операциями
14
+ # - check_row_access() для конкретных записей (get/update/delete)
15
+ # - get_domain_filter() для фильтрации выборки (search)
16
+ """
17
+
18
+ from contextvars import ContextVar
19
+ from enum import StrEnum
20
+ from typing import TypeVar, Generic
21
+
22
+
23
+ class Operation(StrEnum):
24
+ """Операции доступа."""
25
+
26
+ READ = "read"
27
+ CREATE = "create"
28
+ UPDATE = "update"
29
+ DELETE = "delete"
30
+
31
+
32
+ # Generic тип для Session
33
+ TSession = TypeVar("TSession")
34
+
35
+
36
+ class AccessChecker(Generic[TSession]):
37
+ """
38
+ Базовый класс проверки доступа.
39
+
40
+ По умолчанию разрешает всё.
41
+ Модуль security наследует и переопределяет методы.
42
+ """
43
+
44
+ async def check_access(
45
+ self,
46
+ session: TSession,
47
+ model: str,
48
+ operation: Operation,
49
+ record_ids: list[int] | None = None,
50
+ ) -> tuple[bool, list]:
51
+ """
52
+ Единая проверка доступа: ACL + Rules.
53
+
54
+ Args:
55
+ session: Сессия пользователя
56
+ model: Имя модели (таблицы)
57
+ operation: Операция (read/create/update/delete)
58
+ record_ids: ID записей (для проверки Rules)
59
+
60
+ Returns:
61
+ (has_access, domain_filter):
62
+ - has_access: True если доступ разрешён
63
+ - domain_filter: фильтр для search (пустой если не нужен)
64
+ """
65
+ return True, []
66
+
67
+ async def check_table_access(
68
+ self,
69
+ session: TSession,
70
+ model: str,
71
+ operation: Operation,
72
+ ) -> bool:
73
+ """
74
+ Проверяет доступ к таблице (ACL уровень).
75
+ """
76
+ return True
77
+
78
+ async def check_row_access(
79
+ self,
80
+ session: TSession,
81
+ model: str,
82
+ operation: Operation,
83
+ record_ids: list[int],
84
+ ) -> bool:
85
+ """
86
+ Проверяет доступ к записям (Rules уровень).
87
+
88
+ Для одной или нескольких записей проверяет что они
89
+ попадают под domain из Rules.
90
+ """
91
+ return True
92
+
93
+ async def get_domain_filter(
94
+ self,
95
+ session: TSession,
96
+ model: str,
97
+ operation: Operation,
98
+ ) -> list:
99
+ """
100
+ Возвращает domain-фильтр для ограничения выборки.
101
+
102
+ Используется для search — добавляется к filter ДО запроса.
103
+ """
104
+ return []
105
+
106
+
107
+ class AccessDenied(Exception):
108
+ """Доступ запрещён."""
109
+
110
+ def __init__(self, message: str = "Access denied"):
111
+ self.message = message
112
+ super().__init__(self.message)
113
+
114
+
115
+ # ============================================================
116
+ # State
117
+ # ============================================================
118
+
119
+ _state: dict = {"checker": AccessChecker()}
120
+
121
+ _access_session: ContextVar = ContextVar("access_session", default=None)
122
+
123
+
124
+ # ============================================================
125
+ # Public API
126
+ # ============================================================
127
+
128
+
129
+ def set_access_checker(checker: AccessChecker) -> None:
130
+ """Устанавливает AccessChecker (один раз при старте)."""
131
+ _state["checker"] = checker
132
+
133
+
134
+ def get_access_checker() -> AccessChecker:
135
+ """Возвращает текущий AccessChecker."""
136
+ return _state["checker"]
137
+
138
+
139
+ def set_access_session(session) -> None:
140
+ """Устанавливает сессию для текущего запроса."""
141
+ _access_session.set(session)
142
+
143
+
144
+ def get_access_session():
145
+ """Возвращает сессию текущего запроса."""
146
+ return _access_session.get()
147
+
148
+
149
+ def clear_access_session() -> None:
150
+ """Очищает сессию (после завершения post_init)."""
151
+ _access_session.set(None)
File without changes
@@ -0,0 +1,72 @@
1
+ """
2
+ SQL Query Builder.
3
+
4
+ Combines base state with mixin functionality.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..components.filter_parser import FilterParser
10
+
11
+ from .mixins import (
12
+ CRUDMixin,
13
+ Many2ManyMixin,
14
+ RelationsMixin,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from ..fields import Field
19
+ from ..components.dialect import Dialect
20
+
21
+
22
+ class Builder(
23
+ CRUDMixin,
24
+ Many2ManyMixin,
25
+ RelationsMixin,
26
+ ):
27
+ """
28
+ Unified SQL query builder.
29
+
30
+ Inherits from:
31
+ - CRUDMixin: create, read, update, delete operations
32
+ - Many2ManyMixin: M2M relation queries
33
+ - RelationsMixin: Batch relation loading
34
+
35
+ Example:
36
+ builder = Builder(
37
+ table="users",
38
+ fields=model_fields,
39
+ dialect=POSTGRES,
40
+ )
41
+
42
+ # Simple queries
43
+ sql, vals = builder.build_get(id=1)
44
+ sql, vals = builder.build_delete(id=1)
45
+
46
+ # Search with filter
47
+ sql, vals = builder.build_search(
48
+ fields=["id", "name"],
49
+ filter=[("status", "=", "active")],
50
+ limit=10,
51
+ )
52
+
53
+ MRO ensures BuilderBase.__init__ is called and provides
54
+ all attributes that mixins expect.
55
+ """
56
+
57
+ __slots__ = ("table", "fields", "dialect", "filter_parser")
58
+
59
+ def __init__(
60
+ self,
61
+ table: str,
62
+ fields: dict[str, "Field"],
63
+ dialect: "Dialect",
64
+ ) -> None:
65
+ self.table = table
66
+ self.fields = fields
67
+ self.dialect = dialect
68
+ self.filter_parser = FilterParser(dialect)
69
+
70
+ def get_store_fields(self) -> list[str]:
71
+ """Returns only fields that are stored in DB (store=True)."""
72
+ return [name for name, field in self.fields.items() if field.store]
@@ -0,0 +1,63 @@
1
+ """Helper functions for SQL building."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+
6
+
7
+ def build_sql_update_from_schema(
8
+ sql: str,
9
+ payload_dict: dict[str, Any],
10
+ id: int | list[int],
11
+ ) -> tuple[str, tuple]:
12
+ """Составляет запрос обновления (update).
13
+
14
+ Arguments:
15
+ sql -- текст шаблона запроса
16
+ payload_dict -- сериализованные данные модели
17
+ id -- идентификатор или список идентификаторов
18
+
19
+ Returns:
20
+ sql -- текст запроса с подстановками (биндингами)
21
+ values_list -- значения для биндинга
22
+ """
23
+ if not payload_dict:
24
+ raise ValueError("payload_dict cannot be empty")
25
+
26
+ fields_list, values_list = zip(*payload_dict.items())
27
+ values_list = tuple(values_list)
28
+
29
+ if isinstance(id, list):
30
+ values_list += tuple(id)
31
+ where_placeholder = ", ".join(["%s"] * len(id))
32
+ else:
33
+ values_list += (id,)
34
+ where_placeholder = "%s"
35
+
36
+ query_placeholders = ", ".join(f"{field}=%s" for field in fields_list)
37
+ sql = sql % (query_placeholders, where_placeholder)
38
+ return sql, values_list
39
+
40
+
41
+ def build_sql_create_from_schema(
42
+ sql: str,
43
+ payload_dict: dict[str, Any],
44
+ ) -> tuple[str, tuple]:
45
+ """Составляет запрос создания (insert).
46
+
47
+ Arguments:
48
+ sql -- текст шаблона запроса
49
+ payload_dict -- сериализованные данные модели
50
+
51
+ Returns:
52
+ sql -- текст запроса с подстановками (биндингами)
53
+ values_list -- значения для биндинга
54
+ """
55
+ if not payload_dict:
56
+ raise ValueError("payload_dict cannot be empty")
57
+
58
+ fields_list, values_list = zip(*payload_dict.items())
59
+
60
+ query_columns = ", ".join(fields_list)
61
+ query_placeholders = ", ".join(["%s"] * len(values_list))
62
+ sql = sql % (query_columns, query_placeholders)
63
+ return sql, values_list
@@ -0,0 +1,11 @@
1
+ """Builder mixins - stateless method providers."""
2
+
3
+ from .crud import CRUDMixin
4
+ from .m2m import Many2ManyMixin
5
+ from .relations import RelationsMixin
6
+
7
+ __all__ = [
8
+ "CRUDMixin",
9
+ "Many2ManyMixin",
10
+ "RelationsMixin",
11
+ ]
@@ -0,0 +1,246 @@
1
+ """CRUD operations mixin."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Literal
4
+
5
+ from ...components.filter_parser import FilterExpression
6
+
7
+ if TYPE_CHECKING:
8
+ from ..protocol import BuilderProtocol
9
+
10
+ from ..helpers import (
11
+ build_sql_create_from_schema,
12
+ build_sql_update_from_schema,
13
+ )
14
+
15
+
16
+ # Allowed order values (uppercase for comparison)
17
+ _ALLOWED_ORDER = frozenset({"ASC", "DESC"})
18
+
19
+
20
+ class CRUDMixin:
21
+ """
22
+ Mixin providing basic CRUD query builders.
23
+
24
+ Builder работает с dict, а не с моделью.
25
+ Сериализация модели в dict происходит в ORM слое.
26
+
27
+ Expects: table, fields, dialect, get_store_fields(), filter_parser
28
+ """
29
+
30
+ __slots__ = ()
31
+
32
+ def build_delete(self: "BuilderProtocol") -> str:
33
+ return f"DELETE FROM {self.table} WHERE id=%s"
34
+
35
+ def build_delete_bulk(self: "BuilderProtocol", count: int) -> str:
36
+ args = ",".join(["%s"] * count)
37
+ return f"DELETE FROM {self.table} WHERE id IN ({args})"
38
+
39
+ def build_create(
40
+ self: "BuilderProtocol",
41
+ payload_dict: dict[str, Any],
42
+ ) -> tuple[str, tuple]:
43
+ """Build INSERT query from dict."""
44
+ stmt = f"INSERT INTO {self.table} (%s) VALUES (%s)"
45
+ stmt, values_list = build_sql_create_from_schema(stmt, payload_dict)
46
+ return stmt, values_list
47
+
48
+ def build_create_bulk(
49
+ self: "BuilderProtocol",
50
+ payloads_dicts: list[dict[str, Any]],
51
+ ) -> tuple[str, list]:
52
+ """Build bulk INSERT query with multiple VALUES."""
53
+ if not payloads_dicts:
54
+ raise ValueError("payloads_dicts cannot be empty")
55
+
56
+ # Берём ключи из первого payload
57
+ fields_list = list(payloads_dicts[0].keys())
58
+ query_columns = ", ".join(fields_list)
59
+
60
+ # Собираем все значения в плоский список
61
+ all_values = []
62
+ value_groups = []
63
+ param_index = 1
64
+
65
+ for payload_dict in payloads_dicts:
66
+ placeholders = []
67
+ for field in fields_list:
68
+ placeholders.append(f"${param_index}")
69
+ all_values.append(payload_dict[field])
70
+ param_index += 1
71
+ value_groups.append(f"({', '.join(placeholders)})")
72
+
73
+ values_clause = ", ".join(value_groups)
74
+ stmt = f"INSERT INTO {self.table} ({query_columns}) VALUES {values_clause}"
75
+
76
+ return stmt, all_values
77
+
78
+ def build_update(
79
+ self: "BuilderProtocol",
80
+ payload_dict: dict[str, Any],
81
+ id: int,
82
+ ) -> tuple[str, tuple]:
83
+ """Build UPDATE query from dict."""
84
+ stmt = f"UPDATE {self.table} SET %s WHERE id = %s"
85
+ stmt, values_list = build_sql_update_from_schema(
86
+ stmt, payload_dict, id
87
+ )
88
+ return stmt, values_list
89
+
90
+ def build_update_bulk(
91
+ self: "BuilderProtocol",
92
+ payload_dict: dict[str, Any],
93
+ ids: list[int],
94
+ ) -> tuple[str, tuple]:
95
+ """Build bulk UPDATE query from dict."""
96
+ stmt = f"UPDATE {self.table} SET %s WHERE id IN (%s)"
97
+ stmt, values_list = build_sql_update_from_schema(
98
+ stmt, payload_dict, ids
99
+ )
100
+ return stmt, values_list
101
+
102
+ def build_get(
103
+ self: "BuilderProtocol",
104
+ id: int,
105
+ fields: list[str] | None = None,
106
+ ) -> tuple[str, list]:
107
+ """
108
+ Build SELECT by ID query.
109
+
110
+ Args:
111
+ id: Record ID
112
+ fields: Fields to select (empty = all stored)
113
+ """
114
+ escape = self.dialect.escape
115
+ store_fields = self.get_store_fields()
116
+
117
+ selected_fields = fields if fields else store_fields
118
+ fields_stmt = ", ".join(
119
+ f"{escape}{name}{escape}" for name in selected_fields
120
+ )
121
+
122
+ stmt = f"SELECT {fields_stmt} FROM {self.table} WHERE id = %s LIMIT 1"
123
+ return stmt, [id]
124
+
125
+ def build_table_len(self: "BuilderProtocol") -> tuple[str, None]:
126
+ stmt = f"SELECT COUNT(*) FROM {self.table}"
127
+ return stmt, None
128
+
129
+ def build_search(
130
+ self: "BuilderProtocol",
131
+ fields: list[str] | None = None,
132
+ start: int | None = None,
133
+ end: int | None = None,
134
+ limit: int = 80,
135
+ order: Literal["DESC", "ASC", "desc", "asc"] = "DESC",
136
+ sort: str = "id",
137
+ filter: FilterExpression | None = None,
138
+ raw: bool = False,
139
+ ) -> tuple[str, tuple]:
140
+ """
141
+ Build search query.
142
+
143
+ Args:
144
+ fields: Fields to select (default: ["id"])
145
+ start: Offset start
146
+ end: Offset end
147
+ limit: Max records
148
+ order: Sort order (ASC/DESC)
149
+ sort: Sort field
150
+ filter: Filter expression
151
+ raw: Return raw dict instead of model
152
+ """
153
+ escape = self.dialect.escape
154
+ store_fields = self.get_store_fields()
155
+
156
+ if fields is None:
157
+ fields = ["id"]
158
+
159
+ # поставить защиту, хотя по идее защита есть в ОРМ
160
+ order_upper = order.upper()
161
+ if order_upper not in _ALLOWED_ORDER:
162
+ raise ValueError(f"Invalid order: {order}")
163
+ if sort not in store_fields:
164
+ sort = store_fields[0]
165
+ # raise ValueError(f"Invalid sort field: {sort}")
166
+
167
+ fields_store_stmt = ", ".join(
168
+ f"{escape}{name}{escape}"
169
+ for name in fields
170
+ if name in store_fields
171
+ )
172
+
173
+ where = ""
174
+ where_values: tuple = ()
175
+
176
+ if filter:
177
+ where_clause, where_values = self.filter_parser.parse(filter)
178
+ where = f"WHERE {where_clause}"
179
+
180
+ stmt = (
181
+ f"SELECT {fields_store_stmt} FROM {self.table} "
182
+ f"{where} ORDER BY {sort} {order_upper} "
183
+ )
184
+
185
+ val: tuple = ()
186
+
187
+ if end is not None and start is not None:
188
+ stmt += "LIMIT %s OFFSET %s"
189
+ val = (end - start, start)
190
+ elif limit:
191
+ stmt += "LIMIT %s"
192
+ val = (limit,)
193
+
194
+ # Prepend where values
195
+ if where_values:
196
+ val = where_values + val
197
+
198
+ return stmt, val
199
+
200
+ def build_search_count(
201
+ self: "BuilderProtocol",
202
+ filter: FilterExpression | None = None,
203
+ ) -> tuple[str, tuple]:
204
+ """
205
+ Build COUNT query with filter.
206
+
207
+ Args:
208
+ filter: Filter expression
209
+
210
+ Returns:
211
+ Tuple of (query, values)
212
+ """
213
+ where = ""
214
+ where_values: tuple = ()
215
+
216
+ if filter:
217
+ where_clause, where_values = self.filter_parser.parse(filter)
218
+ where = f"WHERE {where_clause}"
219
+
220
+ stmt = f"SELECT COUNT(*) as count FROM {self.table} {where}"
221
+
222
+ return stmt, where_values
223
+
224
+ def build_exists(
225
+ self: "BuilderProtocol",
226
+ filter: FilterExpression | None = None,
227
+ ) -> tuple[str, tuple]:
228
+ """
229
+ Build EXISTS query with filter.
230
+
231
+ Args:
232
+ filter: Filter expression
233
+
234
+ Returns:
235
+ Tuple of (query, values)
236
+ """
237
+ where = ""
238
+ where_values: tuple = ()
239
+
240
+ if filter:
241
+ where_clause, where_values = self.filter_parser.parse(filter)
242
+ where = f"WHERE {where_clause}"
243
+
244
+ stmt = f"SELECT 1 FROM {self.table} {where} LIMIT 1"
245
+
246
+ return stmt, where_values