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/orm/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ORM mixins and model."""
2
+
3
+ from .mixins import (
4
+ OrmRelationsMixin,
5
+ OrmMany2manyMixin,
6
+ OrmPrimaryMixin,
7
+ DDLMixin,
8
+ )
9
+
10
+ __all__ = [
11
+ "DDLMixin",
12
+ "OrmPrimaryMixin",
13
+ "OrmMany2manyMixin",
14
+ "OrmRelationsMixin",
15
+ ]
@@ -0,0 +1,13 @@
1
+ """ORM mixins and model."""
2
+
3
+ from .ddl import DDLMixin
4
+ from .primary import OrmPrimaryMixin
5
+ from .many2many import OrmMany2manyMixin
6
+ from .relations import OrmRelationsMixin
7
+
8
+ __all__ = [
9
+ "DDLMixin",
10
+ "OrmPrimaryMixin",
11
+ "OrmMany2manyMixin",
12
+ "OrmRelationsMixin",
13
+ ]
@@ -0,0 +1,67 @@
1
+ """Access control mixin for DotModel."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...access import (
6
+ get_access_checker,
7
+ get_access_session,
8
+ AccessDenied,
9
+ Operation,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from ..protocol import DotModelProtocol
14
+
15
+ _Base = DotModelProtocol
16
+ else:
17
+ _Base = object
18
+
19
+
20
+ class AccessMixin(_Base):
21
+ """
22
+ Mixin добавляющий проверку доступа в CRUD операции.
23
+
24
+ Если AccessSession не установлена — проверки пропускаются.
25
+ SystemSession даёт полный доступ.
26
+ """
27
+
28
+ @classmethod
29
+ async def _check_access(
30
+ cls,
31
+ operation: Operation,
32
+ record_ids: list[int] | None = None,
33
+ filter: list | None = None,
34
+ ) -> list | None:
35
+ """
36
+ Единый метод проверки доступа.
37
+
38
+ Args:
39
+ operation: Operation.READ / CREATE / UPDATE / DELETE
40
+ record_ids: ID записей (для get/update/delete)
41
+ filter: пользовательский фильтр (для search)
42
+
43
+ Returns:
44
+ Модифицированный filter с domain (для search)
45
+
46
+ Raises:
47
+ AccessDenied: если доступ запрещён
48
+ """
49
+ session = get_access_session()
50
+ if session is None:
51
+ return filter
52
+
53
+ checker = get_access_checker()
54
+
55
+ has_access, domain = await checker.check_access(
56
+ session, cls.__table__, operation, record_ids
57
+ )
58
+
59
+ if not has_access:
60
+ raise AccessDenied(
61
+ f"No {operation.value} access to {cls.__table__}"
62
+ )
63
+
64
+ if domain:
65
+ return filter + domain if filter else domain
66
+
67
+ return filter
@@ -0,0 +1,250 @@
1
+ """DDL Mixin - provides table creation functionality."""
2
+
3
+ import datetime
4
+ from typing import TYPE_CHECKING, ClassVar
5
+
6
+ if TYPE_CHECKING:
7
+ from ..protocol import DotModelProtocol
8
+
9
+ _Base = DotModelProtocol
10
+ else:
11
+ _Base = object
12
+
13
+ from ...fields import AttachmentMany2one, Field, Many2many, Many2one
14
+
15
+
16
+ class DDLMixin(_Base):
17
+ """
18
+ Mixin providing DDL (Data Definition Language) operations.
19
+
20
+ Provides:
21
+ - __create_table__ - creates table based on model fields
22
+ - format_default_value - formats default values for SQL
23
+ - cache decorator for simple TTL caching
24
+
25
+ Expects DotModel to provide:
26
+ - _get_db_session()
27
+ - __table__
28
+ - get_fields()
29
+ """
30
+
31
+ _CACHE_DATA: ClassVar[dict] = {}
32
+ _CACHE_LAST_TIME: ClassVar[dict] = {}
33
+
34
+ @staticmethod
35
+ def cache(name, ttl=30):
36
+ """Реализация простого кеша на TTL секунд, таблиц которые редко меняются,
37
+ и делать запрос в БД не целесообразно каждый раз, можно сохранить результат.
38
+ При использовании более одного воркера необходимо использовать redis.
39
+
40
+ Arguments:
41
+ name -- name cache store data
42
+ ttl -- seconds cache store
43
+ """
44
+
45
+ def decorator(func):
46
+ async def wrapper(self, *args):
47
+ # если данные есть в кеше
48
+ if self._CACHE_DATA.get(name):
49
+ time_diff = (
50
+ datetime.datetime.now() - self._CACHE_LAST_TIME[name]
51
+ )
52
+ # проверить актуальные ли они
53
+ if time_diff.seconds < ttl:
54
+ # если актуальные вернуть их
55
+ return self._CACHE_DATA[name]
56
+
57
+ # если данных нет или они не актуальные сделать запрос в БД и запомнить
58
+ self._CACHE_DATA[name] = await func(self, *args)
59
+ # также сохранить дату и время запроса, для последующей проверки
60
+ self._CACHE_LAST_TIME[name] = datetime.datetime.now()
61
+ return self._CACHE_DATA[name]
62
+
63
+ return wrapper
64
+
65
+ return decorator
66
+
67
+ @staticmethod
68
+ def format_default_value(value):
69
+ """
70
+ PostgreSQL не поддерживает параметры в DDL, см. официальную документацию:
71
+ Prepared statements are supported only for DML commands
72
+ (SELECT, INSERT, UPDATE, DELETE), not for DDL like CREATE TABLE.
73
+ Поэтому вынуждены делать подстановку вручную в DDL —
74
+ но делать это нужно аккуратно и безопасно.
75
+ """
76
+ if isinstance(value, bool):
77
+ return "TRUE" if value else "FALSE"
78
+
79
+ elif isinstance(value, int):
80
+ return str(value) # int, long
81
+
82
+ elif isinstance(value, float):
83
+ # Строгий контроль, чтобы исключить NaN и Inf (они могут вызвать ошибки в SQL)
84
+ if not (value == value and abs(value) != float("inf")):
85
+ raise ValueError("Invalid float for DEFAULT value")
86
+ return str(value)
87
+
88
+ elif isinstance(value, str):
89
+ # Явно запрещаем строки с опасными SQL символами
90
+ if ";" in value or "--" in value:
91
+ raise ValueError(
92
+ "Potentially unsafe characters in default string"
93
+ )
94
+ # SQL-экранирование одинарных кавычек
95
+ escaped = value.replace("'", "''")
96
+ return f"'{escaped}'"
97
+
98
+ else:
99
+ raise TypeError(
100
+ f"Unsupported type for SQL DEFAULT: {type(value).__name__}"
101
+ )
102
+
103
+ @classmethod
104
+ async def __create_table__(cls, session=None):
105
+ """Метод для создания таблицы в базе данных, основанной на атрибутах класса.
106
+
107
+ Если __auto_create__ = False, пропускает создание таблицы.
108
+ Это полезно для связующих таблиц many2many, которые создаются
109
+ автоматически при создании основной модели.
110
+
111
+ Returns:
112
+ list[tuple[str, str]]: Список кортежей (fk_name, fk_sql) для создания FK
113
+ """
114
+ # Проверяем флаг __auto_create__
115
+ if not cls.__auto_create__:
116
+ return []
117
+
118
+ session = cls._get_db_session(session)
119
+
120
+ # описание поля для создания в бд со всеми аттрибутами
121
+ fields_created_declaration: list[str] = []
122
+ # только текстовые названия полей
123
+ fields_created: list = []
124
+ # готовый запрос на добавления FK: список кортежей (fk_name, fk_sql)
125
+ many2one_fields_fk: list[tuple[str, str]] = []
126
+ many2many_fields_fk: list[tuple[str, str]] = []
127
+ # запросы на создание индексов
128
+ index_statements: list[str] = []
129
+
130
+ # Проходимся по атрибутам класса и извлекаем информацию о полях.
131
+ for field_name, field in cls.get_fields().items():
132
+ if isinstance(field, Field):
133
+ if (field.store and not field.relation) or isinstance(
134
+ field, (Many2one, AttachmentMany2one)
135
+ ):
136
+ # Создаём строку с определением поля и добавляем её в список custom_fields.
137
+ field_declaration = [f'"{field_name}" {field.sql_type}']
138
+
139
+ # SERIAL уже подразумевает NOT NULL, а PRIMARY KEY включает в себя UNIQUE.
140
+ # Поэтому достаточно просто id SERIAL PRIMARY KEY.
141
+ if field.unique:
142
+ field_declaration.append("UNIQUE")
143
+ if not field.null:
144
+ field_declaration.append("NOT NULL")
145
+ if field.primary_key:
146
+ field_declaration.append("PRIMARY KEY")
147
+ if field.default is not None:
148
+ if isinstance(field.default, (bool, int, str)):
149
+ field_declaration.append(
150
+ f"DEFAULT {cls.format_default_value(field.default)}"
151
+ )
152
+
153
+ if isinstance(field, Many2one):
154
+ # FK с именованным CONSTRAINT
155
+ fk_name = f"fk_{cls.__table__}_{field_name}"
156
+ fk_sql = (
157
+ f'ALTER TABLE IF EXISTS "{cls.__table__}" '
158
+ f'ADD CONSTRAINT "{fk_name}" '
159
+ f'FOREIGN KEY ("{field_name}") '
160
+ f'REFERENCES "{field.relation_table.__table__}" (id) '
161
+ f"ON DELETE {field.ondelete}"
162
+ )
163
+ many2one_fields_fk.append((fk_name, fk_sql))
164
+
165
+ # создание индекса для поля с index=True
166
+ if (
167
+ field.index
168
+ and not field.primary_key
169
+ and not field.unique
170
+ ):
171
+ index_name = f"idx_{cls.__table__}_{field_name}"
172
+ index_statements.append(
173
+ f'CREATE INDEX IF NOT EXISTS "{index_name}" ON "{cls.__table__}" ("{field_name}")'
174
+ )
175
+
176
+ field_declaration_str = " ".join(field_declaration)
177
+ fields_created_declaration.append(field_declaration_str)
178
+ fields_created.append([field_name, field_declaration_str])
179
+
180
+ # создаем промежуточную таблицу для many2many
181
+ if field.relation and isinstance(field, Many2many):
182
+ column1 = f'"{field.column1}" INTEGER NOT NULL'
183
+ column2 = f'"{field.column2}" INTEGER NOT NULL'
184
+ create_table_sql = f"""\
185
+ CREATE TABLE IF NOT EXISTS "{field.many2many_table}" (\
186
+ {', '.join([column1, column2])}\
187
+ );
188
+ """
189
+
190
+ # FK с именованными CONSTRAINT
191
+ fk_name1 = f"fk_{field.many2many_table}_{field.column2}"
192
+ fk_sql1 = (
193
+ f'ALTER TABLE IF EXISTS "{field.many2many_table}" '
194
+ f'ADD CONSTRAINT "{fk_name1}" '
195
+ f'FOREIGN KEY ("{field.column2}") '
196
+ f'REFERENCES "{cls.__table__}" (id) '
197
+ f"ON DELETE {field.ondelete}"
198
+ )
199
+
200
+ fk_name2 = f"fk_{field.many2many_table}_{field.column1}"
201
+ fk_sql2 = (
202
+ f'ALTER TABLE IF EXISTS "{field.many2many_table}" '
203
+ f'ADD CONSTRAINT "{fk_name2}" '
204
+ f'FOREIGN KEY ("{field.column1}") '
205
+ f'REFERENCES "{field.relation_table.__table__}" (id) '
206
+ f"ON DELETE {field.ondelete}"
207
+ )
208
+
209
+ many2many_fields_fk.append((fk_name1, fk_sql1))
210
+ many2many_fields_fk.append((fk_name2, fk_sql2))
211
+ await session.execute(create_table_sql)
212
+
213
+ # создание составного индекса для m2m таблицы
214
+ m2m_index_name = f"idx_{field.many2many_table}_{field.column1}_{field.column2}"
215
+ index_statements.append(
216
+ f'CREATE INDEX IF NOT EXISTS "{m2m_index_name}" ON "{field.many2many_table}" ("{field.column1}", "{field.column2}")'
217
+ )
218
+
219
+ # Создаём SQL-запрос для создания таблицы с определёнными полями.
220
+ create_table_sql = f"""\
221
+ CREATE TABLE IF NOT EXISTS "{cls.__table__}" (\
222
+ {', '.join(fields_created_declaration)}\
223
+ );"""
224
+
225
+ # Выполняем SQL-запрос.
226
+ await session.execute(create_table_sql)
227
+
228
+ # ОПТИМИЗАЦИЯ: получаем все колонки таблицы ОДНИМ запросом
229
+ existing_columns_sql = f"""
230
+ SELECT column_name
231
+ FROM information_schema.columns
232
+ WHERE table_name = '{cls.__table__}'
233
+ """
234
+ existing_columns_result = await session.execute(existing_columns_sql)
235
+ existing_columns = {
236
+ row["column_name"] for row in existing_columns_result
237
+ }
238
+
239
+ # Добавляем только отсутствующие колонки
240
+ for field_name, field_declaration in fields_created:
241
+ if field_name not in existing_columns:
242
+ await session.execute(
243
+ f'ALTER TABLE "{cls.__table__}" ADD COLUMN {field_declaration};'
244
+ )
245
+
246
+ # создаём индексы
247
+ for index_stmt in index_statements:
248
+ await session.execute(index_stmt)
249
+
250
+ return many2one_fields_fk + many2many_fields_fk
@@ -0,0 +1,175 @@
1
+ """Many2many ORM operations mixin."""
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Literal, Self
5
+
6
+ if TYPE_CHECKING:
7
+ from ..protocol import DotModelProtocol
8
+
9
+ _Base = DotModelProtocol
10
+ else:
11
+ _Base = object
12
+
13
+ from ...fields import Field, Many2many, Many2one, One2many
14
+ from ...decorators import hybridmethod
15
+ from ..utils import execute_maybe_parallel
16
+
17
+
18
+ class OrmMany2manyMixin(_Base):
19
+ """
20
+ Mixin providing ORM operations for many-to-many relations.
21
+
22
+ Provides:
23
+ - get_many2many - fetch M2M related records
24
+ - link_many2many - create M2M links
25
+ - unlink_many2many - remove M2M links
26
+ - _records_list_get_relation - batch load relations
27
+
28
+ Expects DotModel to provide:
29
+ - _get_db_session()
30
+ - _builder
31
+ - _dialect
32
+ - get_relation_fields()
33
+ - prepare_list_ids()
34
+ """
35
+
36
+ @classmethod
37
+ async def get_many2many(
38
+ cls,
39
+ id,
40
+ comodel,
41
+ relation,
42
+ column1,
43
+ column2,
44
+ fields=None,
45
+ order: Literal["desc", "asc"] = "desc",
46
+ start: int | None = None,
47
+ end: int | None = None,
48
+ sort: str = "id",
49
+ limit: int | None = 10,
50
+ session=None,
51
+ ):
52
+ if not fields:
53
+ fields = []
54
+ session = cls._get_db_session(session)
55
+ # защита, оставить только те поля, которые действительно хранятся в базе
56
+ fields_store = [
57
+ name for name in comodel.get_store_fields() if name in fields
58
+ ]
59
+ if not fields_store:
60
+ fields_store = comodel.get_store_fields()
61
+ stmt, values = cls._builder.build_get_many2many(
62
+ id,
63
+ comodel,
64
+ relation,
65
+ column1,
66
+ column2,
67
+ fields_store,
68
+ order,
69
+ start,
70
+ end,
71
+ sort,
72
+ limit,
73
+ )
74
+ records = await session.execute(
75
+ stmt, values, prepare=comodel.prepare_list_ids
76
+ )
77
+
78
+ # если есть хоть одна запись и вообще нужно читать поля связей
79
+ fields_relation = [
80
+ (name, field)
81
+ for name, field in comodel.get_relation_fields()
82
+ if name in fields
83
+ ]
84
+ if records and fields_relation:
85
+ await cls._records_list_get_relation(
86
+ session, fields_relation, records
87
+ )
88
+ return records
89
+
90
+ @hybridmethod
91
+ async def link_many2many(
92
+ self, field: Many2many, values: list, session=None
93
+ ):
94
+ """Link records in M2M relation."""
95
+ cls = self.__class__
96
+ session = cls._get_db_session(session)
97
+ query_placeholders = ", ".join(["%s"] * len(values[0]))
98
+ stmt = f"""INSERT INTO {field.many2many_table}
99
+ ({field.column2}, {field.column1})
100
+ VALUES
101
+ ({query_placeholders})
102
+ """
103
+ return await session.execute(stmt, [values], cursor="executemany")
104
+
105
+ @classmethod
106
+ async def unlink_many2many(cls, field: Many2many, ids: list, session=None):
107
+ """Unlink records from M2M relation."""
108
+ session = cls._get_db_session(session)
109
+ args: str = ",".join(["%s"] * len(ids))
110
+ stmt = f"DELETE FROM {field.many2many_table} WHERE {field.column1} in ({args})"
111
+ return await session.execute(stmt, ids)
112
+
113
+ @classmethod
114
+ async def _records_list_get_relation(
115
+ cls, session, fields_relation, records
116
+ ):
117
+ """Load relations for a list of records (batch)."""
118
+ # Use dialect from class
119
+ dialect = cls._dialect
120
+
121
+ request_list = cls._builder.build_search_relation(
122
+ fields_relation, records
123
+ )
124
+ execute_list = [
125
+ session.execute(
126
+ req.stmt,
127
+ req.value,
128
+ prepare=req.function_prepare,
129
+ cursor=req.function_cursor,
130
+ )
131
+ for req in request_list
132
+ ]
133
+ # выполняем последовательно в транзакции, параллельно вне транзакции
134
+ results = await execute_maybe_parallel(execute_list)
135
+
136
+ # маппинг (полученных оптимизированных запросов) полей связей
137
+ # на конкретные записи (полученные при чтении store на предыдущем шаге)
138
+ for index, result in enumerate(results):
139
+ req = request_list[index]
140
+
141
+ if isinstance(req.field, Many2one):
142
+ for rec in records:
143
+ rec_field_raw = getattr(rec, req.field_name)
144
+ for res_model in result:
145
+ if rec_field_raw == res_model.id:
146
+ setattr(rec, req.field_name, res_model)
147
+
148
+ if isinstance(req.field, One2many):
149
+ for rec in records:
150
+ for res_model in result:
151
+ res_field_id = getattr(
152
+ res_model, req.field.relation_table_field
153
+ )
154
+ if rec.id == res_field_id:
155
+ old_value = getattr(rec, req.field_name)
156
+ if isinstance(old_value, Field):
157
+ old_value = []
158
+ # иначе добавляем ид в список
159
+ old_value.append(res_model)
160
+ setattr(rec, req.field_name, old_value)
161
+
162
+ if isinstance(req.field, Many2many):
163
+ for rec in records:
164
+ for res_model in result:
165
+ if rec.id == res_model.m2m_id:
166
+ old_value = getattr(rec, req.field_name)
167
+ # если еще не задано то пустой список
168
+ if isinstance(old_value, Field):
169
+ old_value = []
170
+ # иначе добавляем ид в список
171
+ old_value.append(res_model)
172
+ setattr(rec, req.field_name, old_value)
173
+ for res_model in result:
174
+ # удалить атрибут m2m_id
175
+ del res_model.__dict__["m2m_id"]