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
dotorm/orm/__init__.py
ADDED
|
@@ -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
|
dotorm/orm/mixins/ddl.py
ADDED
|
@@ -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"]
|