dotorm
Advanced tools
@@ -26,4 +26,4 @@ """ | ||
| One2one, | ||
| AttachmentMany2one, | ||
| AttachmentOne2many, | ||
| PolymorphicMany2one, | ||
| PolymorphicOne2many, | ||
| ) | ||
@@ -50,3 +50,3 @@ | ||
| __version__ = "2.0.9" | ||
| __version__ = "2.1.0" | ||
@@ -74,4 +74,4 @@ __all__ = [ | ||
| "One2one", | ||
| "AttachmentMany2one", | ||
| "AttachmentOne2many", | ||
| "PolymorphicMany2one", | ||
| "PolymorphicOne2many", | ||
| # Model | ||
@@ -78,0 +78,0 @@ "DotModel", |
@@ -157,3 +157,3 @@ """CRUD operations mixin.""" | ||
| if fields is None: | ||
| fields = ["id"] | ||
| fields = store_fields | ||
@@ -160,0 +160,0 @@ # поставить защиту, хотя по идее защита есть в ОРМ |
| """Many2many query builder.""" | ||
| from typing import TYPE_CHECKING, Literal | ||
| from typing import TYPE_CHECKING, Literal, Type | ||
| if TYPE_CHECKING: | ||
| from ..protocol import BuilderProtocol | ||
| from ...orm.model import DotModel | ||
| from ...model import DotModel | ||
@@ -18,3 +19,3 @@ | ||
| id: int, | ||
| relation_table: "DotModel", | ||
| relation_table: Type["DotModel"], | ||
| many2many_table: str, | ||
@@ -62,3 +63,3 @@ column1: str, | ||
| ids: list[int], | ||
| relation_table: "DotModel", | ||
| relation_table: Type["DotModel"], | ||
| many2many_table: str, | ||
@@ -65,0 +66,0 @@ column1: str, |
@@ -9,3 +9,3 @@ """Relations query builder.""" | ||
| from ..request_builder import RequestBuilder | ||
| from ...fields import AttachmentMany2one, Field, Many2many, Many2one, One2many | ||
| from ...fields import PolymorphicMany2one, Field, Many2many, Many2one, One2many | ||
@@ -22,2 +22,3 @@ | ||
| records: list | None = None, | ||
| fields_nested: dict[str, list[str]] | None = None, | ||
| ) -> list[RequestBuilder]: | ||
@@ -38,8 +39,12 @@ """ | ||
| for name, field in fields_relation: | ||
| # TODO: взможно ошибка от дублирвоания полей | ||
| # Default fields for relations | ||
| fields = ["id"] | ||
| if field.relation_table and field.relation_table.get_fields().get( | ||
| "name" | ||
| ): | ||
| fields.append("name") | ||
| # добавить вложенные поля от пользователя | ||
| fields = [] | ||
| if fields_nested: | ||
| custom_fields = fields_nested.get(name) | ||
| if custom_fields: | ||
| fields += custom_fields | ||
| if field.relation_table: | ||
| fields = field.relation_table.get_store_fields() | ||
@@ -77,3 +82,3 @@ req: RequestBuilder | None = None | ||
| elif isinstance(field, (Many2one, AttachmentMany2one)): | ||
| elif isinstance(field, (Many2one, PolymorphicMany2one)): | ||
| ids_m2o: list[int] = [ | ||
@@ -80,0 +85,0 @@ getattr(record, name) |
| """Protocol defining what mixins expect from the base class.""" | ||
| from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable | ||
| from typing import TYPE_CHECKING, Literal, Protocol, Type, runtime_checkable | ||
@@ -42,3 +42,3 @@ from ..components.filter_parser import FilterExpression | ||
| ids: list[int], | ||
| relation_table: "DotModel", | ||
| relation_table: Type["DotModel"], | ||
| many2many_table: str, | ||
@@ -54,3 +54,3 @@ column1: str, | ||
| id: int, | ||
| relation_table: "DotModel", | ||
| relation_table: Type["DotModel"], | ||
| many2many_table: str, | ||
@@ -57,0 +57,0 @@ column1: str, |
@@ -9,4 +9,4 @@ """Request builder for relation queries.""" | ||
| from ..fields import ( | ||
| AttachmentMany2one, | ||
| AttachmentOne2many, | ||
| PolymorphicMany2one, | ||
| PolymorphicOne2many, | ||
| Field, | ||
@@ -30,3 +30,3 @@ Many2many, | ||
| FormRelationField = ( | ||
| Many2many | One2many | Many2one | AttachmentMany2one | AttachmentOne2many | ||
| Many2many | One2many | Many2one | PolymorphicMany2one | PolymorphicOne2many | ||
| ) | ||
@@ -56,4 +56,4 @@ | ||
| Many2one: "prepare_list_ids", | ||
| AttachmentMany2one: "prepare_list_ids", | ||
| AttachmentOne2many: "prepare_list_ids", | ||
| PolymorphicMany2one: "prepare_list_ids", | ||
| PolymorphicOne2many: "prepare_list_ids", | ||
| } | ||
@@ -85,33 +85,2 @@ | ||
| @dataclass(slots=True) | ||
| class RequestBuilderForm(RequestBuilder): | ||
| """ | ||
| Container for form relation query request. | ||
| Extends RequestBuilder with form-specific prepare functions. | ||
| """ | ||
| # Override mapping for form context | ||
| _PREPARE_FUNCS: ClassVar[dict[type, str]] = { | ||
| Many2many: "prepare_form_ids", | ||
| One2many: "prepare_form_ids", | ||
| Many2one: "prepare_form_ids", | ||
| AttachmentMany2one: "prepare_form_ids", | ||
| AttachmentOne2many: "prepare_form_ids", | ||
| } | ||
| @property | ||
| def function_prepare(self) -> Callable: | ||
| """ | ||
| Returns appropriate prepare function for form context. | ||
| Form context uses prepare_form_ids/prepare_form_id methods. | ||
| """ | ||
| for field_type, method_name in self._PREPARE_FUNCS.items(): | ||
| if isinstance(self.field, field_type): | ||
| return getattr(self.field.relation_table, method_name) | ||
| return getattr(self.field.relation_table, "prepare_form_id") | ||
| def create_request_builder( | ||
@@ -123,7 +92,5 @@ stmt: str | None, | ||
| fields: list[str] | None = None, | ||
| *, | ||
| form_mode: bool = False, | ||
| ) -> RequestBuilder: | ||
| """ | ||
| Factory function for creating appropriate RequestBuilder. | ||
| Factory function for creating RequestBuilder. | ||
@@ -136,6 +103,5 @@ Args: | ||
| fields: Fields to select (default: ["id", "name"]) | ||
| form_mode: If True, creates RequestBuilderForm | ||
| Returns: | ||
| RequestBuilder or RequestBuilderForm instance | ||
| RequestBuilder instance | ||
| """ | ||
@@ -145,4 +111,3 @@ if fields is None: | ||
| cls = RequestBuilderForm if form_mode else RequestBuilder | ||
| return cls( | ||
| return RequestBuilder( | ||
| stmt=stmt, | ||
@@ -149,0 +114,0 @@ value=value, |
@@ -146,5 +146,5 @@ """ | ||
| # Поэтому добавляем OR IS NULL | ||
| if op == "!=": | ||
| clause = f"({field} IS NULL OR {field} != %s)" | ||
| return clause, (value,) | ||
| # if op == "!=": | ||
| # clause = f"({field} IS NULL OR {field} != %s)" | ||
| # return clause, (value,) | ||
| clause = f"{field} {op} %s" | ||
@@ -151,0 +151,0 @@ return clause, (value,) |
+22
-16
@@ -75,3 +75,3 @@ """ORM field definitions.""" | ||
| relation_table_field: str | None = None | ||
| _relation_table: "DotModel | None" = None | ||
| _relation_table: Type["DotModel"] | None = None | ||
@@ -170,3 +170,3 @@ def __init__(self, **kwargs: Any) -> None: | ||
| @property | ||
| def relation_table(self) -> "DotModel | None": | ||
| def relation_table(self): | ||
| # если модель задана через лямбда функцию | ||
@@ -440,5 +440,7 @@ if ( | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| def __init__(self, relation_table: T, **kwargs: Any) -> None: | ||
| def __init__( | ||
| self, relation_table: Type["DotModel"], **kwargs: Any | ||
| ) -> None: | ||
| self._relation_table = relation_table | ||
@@ -448,3 +450,3 @@ super().__init__(**kwargs) | ||
| class AttachmentMany2one[T: "DotModel"](Field[T]): | ||
| class PolymorphicMany2one[T: "DotModel"](Field[T]): | ||
| """Many-to-one attachment field.""" | ||
@@ -455,5 +457,7 @@ | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| def __init__(self, relation_table: T, **kwargs: Any) -> None: | ||
| def __init__( | ||
| self, relation_table: Type["DotModel"], **kwargs: Any | ||
| ) -> None: | ||
| self._relation_table = relation_table | ||
@@ -463,3 +467,3 @@ super().__init__(**kwargs) | ||
| class AttachmentOne2many[T: "DotModel"](Field[list[T]]): | ||
| class PolymorphicOne2many[T: "DotModel"](Field[list[T]]): | ||
| """One-to-many attachment field.""" | ||
@@ -470,3 +474,3 @@ | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| relation_table_field: str | ||
@@ -476,3 +480,3 @@ | ||
| self, | ||
| relation_table: T, | ||
| relation_table: Type["DotModel"], | ||
| relation_table_field: str, | ||
@@ -560,8 +564,10 @@ **kwargs: Any, | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| many2many_table: str | ||
| column1: str | ||
| column2: str | ||
| def __init__( | ||
| self, | ||
| relation_table: T, | ||
| relation_table: Type["DotModel"], | ||
| many2many_table: str, | ||
@@ -599,3 +605,3 @@ column1: str, | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| relation_table_field: str | ||
@@ -605,3 +611,3 @@ | ||
| self, | ||
| relation_table: T, | ||
| relation_table: Type["DotModel"], | ||
| relation_table_field: str, | ||
@@ -621,7 +627,7 @@ **kwargs: Any, | ||
| relation = True | ||
| relation_table: "DotModel" | ||
| relation_table: Type["DotModel"] | ||
| def __init__( | ||
| self, | ||
| relation_table: T, | ||
| relation_table: Type["DotModel"], | ||
| relation_table_field: str, | ||
@@ -628,0 +634,0 @@ **kwargs: Any, |
+24
-20
@@ -45,3 +45,3 @@ """DotModel - main ORM model class.""" | ||
| from .fields import ( | ||
| AttachmentOne2many, | ||
| PolymorphicOne2many, | ||
| Field, | ||
@@ -53,3 +53,3 @@ JSONField, | ||
| One2one, | ||
| AttachmentMany2one, | ||
| PolymorphicMany2one, | ||
| ) | ||
@@ -264,8 +264,2 @@ | ||
| @classmethod | ||
| def prepare_form_ids(cls, rows: list[dict]): | ||
| """Deserialize from list of dicts to list of objects.""" | ||
| records = [cls.prepare_form_id([r]) for r in rows] | ||
| return records | ||
| @classmethod | ||
| def prepare_form_id(cls, r: list): | ||
@@ -382,3 +376,3 @@ """Deserialize from dict to object.""" | ||
| if isinstance( | ||
| field, (Many2many, One2many, AttachmentOne2many, One2one) | ||
| field, (Many2many, One2many, PolymorphicOne2many, One2one) | ||
| ) | ||
@@ -393,3 +387,3 @@ ] | ||
| for name, field in cls.get_fields().items() | ||
| if isinstance(field, (AttachmentMany2one, AttachmentOne2many)) | ||
| if isinstance(field, (PolymorphicMany2one, PolymorphicOne2many)) | ||
| ] | ||
@@ -419,3 +413,3 @@ | ||
| if field.store | ||
| and not isinstance(field, (Many2one, AttachmentMany2one)) | ||
| and not isinstance(field, (Many2one, PolymorphicMany2one)) | ||
| ] | ||
@@ -670,3 +664,3 @@ | ||
| elif isinstance( | ||
| field_class, (Many2many, One2many, AttachmentOne2many) | ||
| field_class, (Many2many, One2many, PolymorphicOne2many) | ||
| ): | ||
@@ -686,11 +680,21 @@ if mode == JsonMode.LIST: | ||
| elif mode == JsonMode.FORM: | ||
| # При FORM (get) field это dict с data/fields/total | ||
| fields_json[field_name] = { | ||
| "data": [ | ||
| # При FORM (get) field может быть: | ||
| # list объектов (get с fields_nested) | ||
| # dict с data/fields/total (legacy) | ||
| if isinstance(field, dict): | ||
| fields_json[field_name] = { | ||
| "data": [ | ||
| rec.json(mode=JsonMode.NESTED_LIST) | ||
| for rec in field["data"] | ||
| ], | ||
| "fields": field["fields"], | ||
| "total": field["total"], | ||
| } | ||
| elif isinstance(field, list): | ||
| fields_json[field_name] = [ | ||
| rec.json(mode=JsonMode.NESTED_LIST) | ||
| for rec in field["data"] | ||
| ], | ||
| "fields": field["fields"], | ||
| "total": field["total"], | ||
| } | ||
| for rec in field | ||
| ] | ||
| else: | ||
| fields_json[field_name] = field | ||
@@ -697,0 +701,0 @@ # Сериализуем JSONField в строку при записи в БД |
@@ -13,3 +13,3 @@ """DDL Mixin - provides table creation functionality.""" | ||
| from ...fields import AttachmentMany2one, Field, Many2many, Many2one | ||
| from ...fields import PolymorphicMany2one, Field, Many2many, Many2one | ||
@@ -135,3 +135,3 @@ | ||
| if (field.store and not field.relation) or isinstance( | ||
| field, (Many2one, AttachmentMany2one) | ||
| field, (Many2one, PolymorphicMany2one) | ||
| ): | ||
@@ -138,0 +138,0 @@ # Создаём строку с определением поля и добавляем её в список custom_fields. |
| """Many2many ORM operations mixin.""" | ||
| import asyncio | ||
| from typing import TYPE_CHECKING, Literal, Self | ||
| from typing import TYPE_CHECKING, Literal | ||
@@ -13,3 +12,3 @@ if TYPE_CHECKING: | ||
| from ...fields import AttachmentMany2one, Field, Many2many, Many2one, One2many | ||
| from ...fields import PolymorphicMany2one, Field, Many2many, Many2one, One2many | ||
| from ...decorators import hybridmethod | ||
@@ -116,10 +115,13 @@ from ..utils import execute_maybe_parallel | ||
| async def _records_list_get_relation( | ||
| cls, session, fields_relation, records | ||
| cls, | ||
| session, | ||
| fields_relation, | ||
| records, | ||
| fields_nested: dict[str, list[str]] | None = None, | ||
| ): | ||
| """Load relations for a list of records (batch).""" | ||
| # Use dialect from class | ||
| dialect = cls._dialect | ||
| request_list = cls._builder.build_search_relation( | ||
| fields_relation, records | ||
| fields_relation, records, fields_nested | ||
| ) | ||
@@ -143,36 +145,46 @@ execute_list = [ | ||
| if isinstance(req.field, (Many2one, AttachmentMany2one)): | ||
| if isinstance(req.field, (Many2one, PolymorphicMany2one)): | ||
| # Сначала инициализируем все записи None | ||
| for rec in records: | ||
| rec_field_raw = getattr(rec, req.field_name) | ||
| if isinstance(rec_field_raw, Field): | ||
| setattr(rec, req.field_name, None) | ||
| # Теперь маппим найденные результаты | ||
| for rec in records: | ||
| rec_field_raw = getattr(rec, req.field_name) | ||
| for res_model in result: | ||
| if rec_field_raw == res_model.id: | ||
| setattr(rec, req.field_name, res_model) | ||
| break | ||
| if isinstance(req.field, One2many): | ||
| # Сначала инициализируем все записи пустым списком | ||
| for rec in records: | ||
| for res_model in result: | ||
| res_field_id = getattr( | ||
| res_model, req.field.relation_table_field | ||
| ) | ||
| old_value = getattr(rec, req.field_name) | ||
| if isinstance(old_value, Field): | ||
| setattr(rec, req.field_name, []) | ||
| # Теперь добавляем найденные результаты | ||
| for res_model in result: | ||
| res_field_id = getattr( | ||
| res_model, req.field.relation_table_field | ||
| ) | ||
| for rec in records: | ||
| if rec.id == res_field_id: | ||
| old_value = getattr(rec, req.field_name) | ||
| if isinstance(old_value, Field): | ||
| old_value = [] | ||
| # иначе добавляем ид в список | ||
| old_value.append(res_model) | ||
| setattr(rec, req.field_name, old_value) | ||
| getattr(rec, req.field_name).append(res_model) | ||
| break | ||
| if isinstance(req.field, Many2many): | ||
| # Сначала инициализируем все записи пустым списком | ||
| for rec in records: | ||
| for res_model in result: | ||
| old_value = getattr(rec, req.field_name) | ||
| if isinstance(old_value, Field): | ||
| setattr(rec, req.field_name, []) | ||
| # Теперь добавляем найденные результаты | ||
| for res_model in result: | ||
| for rec in records: | ||
| if rec.id == res_model.m2m_id: | ||
| old_value = getattr(rec, req.field_name) | ||
| # если еще не задано то пустой список | ||
| if isinstance(old_value, Field): | ||
| old_value = [] | ||
| # иначе добавляем ид в список | ||
| old_value.append(res_model) | ||
| setattr(rec, req.field_name, old_value) | ||
| getattr(rec, req.field_name).append(res_model) | ||
| break | ||
| # Удаляем служебный атрибут m2m_id | ||
| for res_model in result: | ||
| # удалить атрибут m2m_id | ||
| del res_model.__dict__["m2m_id"] |
+140
-24
@@ -5,2 +5,4 @@ """Primary ORM operations mixin.""" | ||
| from ...fields import Field | ||
| from ...access import Operation | ||
@@ -62,33 +64,91 @@ from ...components.dialect import POSTGRES | ||
| self, | ||
| payload: "_M | None" = None, | ||
| fields=None, | ||
| payload: "_M", | ||
| fields: list[str] | None = None, | ||
| session=None, | ||
| ): | ||
| """ | ||
| Обновить запись. | ||
| Автоматически обрабатывает и store поля (SQL UPDATE), | ||
| и relation поля (O2M/M2M: created/deleted/selected/unselected, | ||
| attachments: PolymorphicMany2one). | ||
| После обновления БД store-поля из payload синхронизируются в self. | ||
| Args: | ||
| payload: Данные для обновления (экземпляр модели). | ||
| fields: Список полей для обновления. | ||
| Если None — обновляются все заданные поля из payload. | ||
| session: DB сессия | ||
| Example: | ||
| # Store поля | ||
| await record.update(User(name="New", email="new@test.com")) | ||
| # Store + relations | ||
| await record.update(User(name="New", role_ids={"selected": [1, 2]})) | ||
| # Конкретные поля | ||
| await record.update(payload, fields=["name", "email"]) | ||
| """ | ||
| await self._check_access(Operation.UPDATE, record_ids=[self.id]) | ||
| session = self._get_db_session(session) | ||
| if payload is None: | ||
| payload = self | ||
| # Автоопределение полей если не указаны | ||
| if fields is None: | ||
| fields = [ | ||
| name | ||
| for name, field_class in payload.get_fields().items() | ||
| if not isinstance(getattr(payload, name), Field) | ||
| and name != "id" | ||
| ] | ||
| if not fields: | ||
| fields = [] | ||
| return | ||
| if fields: | ||
| payload_dict = payload.json( | ||
| include=set(fields), | ||
| exclude_none=True, | ||
| only_store=True, | ||
| mode=JsonMode.UPDATE, | ||
| ) | ||
| else: | ||
| payload_dict = payload.json( | ||
| exclude=payload.get_none_update_fields_set(), | ||
| exclude_none=True, | ||
| exclude_unset=True, | ||
| only_store=True, | ||
| mode=JsonMode.UPDATE, | ||
| ) | ||
| await self._update_relations(payload, fields, session) | ||
| stmt, values = self._builder.build_update(payload_dict, self.id) | ||
| return await session.execute(stmt, values) | ||
| # Синхронизировать self с payload после успешного обновления | ||
| if payload is not self: | ||
| self._sync_after_update(payload, fields) | ||
| def _sync_after_update(self, payload: "_M", fields: list[str]): | ||
| """ | ||
| Синхронизировать self с payload после успешного update. | ||
| Копируем только store-поля (скаляры, M2O FK) из payload в self. | ||
| Relation-поля (O2M, M2M) не синхронизируются — в payload они | ||
| в формате команд {created/deleted/selected/unselected}, | ||
| а на self — список объектов. Если нужны актуальные relations | ||
| после update — следует перечитать запись из БД. | ||
| Аналогично другим ORM: | ||
| - SQLAlchemy: expire + lazy reload при обращении (доп. SELECT) | ||
| - Django: self уже мутирован до save(), M2M — отдельные операции | ||
| - Tortoise: self уже мутирован до save(), M2M — отдельные операции | ||
| """ | ||
| store_fields = set(self.get_store_fields()) | ||
| for name in fields: | ||
| if name in store_fields: | ||
| setattr(self, name, getattr(payload, name)) | ||
| async def _update_store( | ||
| self, | ||
| payload: "_M", | ||
| fields: list[str], | ||
| session, | ||
| ): | ||
| """Прямой SQL UPDATE для store полей. Без access check и relations.""" | ||
| payload_dict = payload.json( | ||
| include=set(fields), | ||
| exclude_unset=True, | ||
| exclude_none=True, | ||
| only_store=True, | ||
| mode=JsonMode.UPDATE, | ||
| ) | ||
| if payload_dict: | ||
| stmt, values = self._builder.build_update(payload_dict, self.id) | ||
| return await session.execute(stmt, values) | ||
| @hybridmethod | ||
@@ -188,3 +248,42 @@ async def update_bulk( | ||
| @hybridmethod | ||
| async def get(self, id, fields: list[str] = [], session=None) -> Self: | ||
| async def get( | ||
| self, | ||
| id, | ||
| fields: list[str] = [], | ||
| fields_nested: dict[str, list[str]] | None = None, | ||
| session=None, | ||
| ) -> Self: | ||
| """ | ||
| Получить запись по ID. | ||
| Args: | ||
| id: ID записи | ||
| fields: Список полей для загрузки (store + relation). | ||
| fields_nested: Словарь вложенных полей для relation. | ||
| Если передан — relation поля из fields загружаются: | ||
| M2O → объект модели или None | ||
| O2M → список объектов [] | ||
| M2M → список объектов [] | ||
| Если не передан — только store поля (M2O = integer FK). | ||
| Пример: {"user_id": ["id", "name"], "tag_ids": ["id", "name"]} | ||
| session: DB сессия | ||
| Returns: | ||
| Экземпляр модели | ||
| Raises: | ||
| ValueError: Если запись не найдена | ||
| Example: | ||
| # Только store поля | ||
| chat = await Chat.get(5) | ||
| chat.user_id # → 42 (int) | ||
| # С relations | ||
| chat = await Chat.get(5, | ||
| fields=["id", "name", "user_id", "message_ids"], | ||
| fields_nested={"user_id": ["id", "name"]} | ||
| ) | ||
| chat.user_id # → User(id=42, name="John") | ||
| """ | ||
| cls = self.__class__ | ||
@@ -196,3 +295,13 @@ | ||
| stmt, values = cls._builder.build_get(id, fields) | ||
| # Фильтруем fields — оставляем только store поля для SQL | ||
| store_fields = cls.get_store_fields() | ||
| fields_store = ( | ||
| [f for f in fields if f in store_fields] if fields else [] | ||
| ) | ||
| if not fields_store: | ||
| fields_store = list(store_fields) | ||
| if "id" not in fields_store: | ||
| fields_store.append("id") | ||
| stmt, values = cls._builder.build_get(id, fields_store) | ||
| record = await session.execute( | ||
@@ -205,2 +314,9 @@ stmt, values, prepare=cls.prepare_form_id | ||
| assert isinstance(record, cls) | ||
| # Загрузка relations если передан fields_nested | ||
| if fields_nested is not None and fields: | ||
| await cls._get_load_relations( | ||
| record, fields, fields_nested, session | ||
| ) | ||
| return record | ||
@@ -207,0 +323,0 @@ |
+192
-226
@@ -8,8 +8,6 @@ """Relations ORM operations mixin.""" | ||
| from ...access import Operation | ||
| from ...builder.request_builder import ( | ||
| RequestBuilderForm, | ||
| ) | ||
| from ...fields import ( | ||
| AttachmentMany2one, | ||
| AttachmentOne2many, | ||
| PolymorphicMany2one, | ||
| PolymorphicOne2many, | ||
| Field, | ||
| Many2many, | ||
@@ -42,4 +40,4 @@ Many2one, | ||
| - exists - have one record or not | ||
| - get_with_relations - get single record with relations | ||
| - update_with_relations - update record with relations | ||
| - _get_load_relations - load relations for single record (used by get()) | ||
| - _update_relations - update record with relations (used by update()) | ||
@@ -62,3 +60,4 @@ Expects DotModel to provide: | ||
| self, | ||
| fields: list[str] = ["id"], | ||
| fields: list[str] | None = None, | ||
| fields_nested: dict[str, list[str]] | None = None, | ||
| start: int | None = None, | ||
@@ -73,4 +72,51 @@ end: int | None = None, | ||
| ) -> list[Self]: | ||
| """ | ||
| Поиск записей с поддержкой фильтрации, пагинации и загрузки relations. | ||
| Выполняет оптимизированные batch-запросы для relation полей: | ||
| один запрос на тип relation для всех найденных записей. | ||
| Args: | ||
| fields: Список полей для загрузки (store + relation). | ||
| По умолчанию ["id"]. | ||
| start: Начальный индекс для пагинации (OFFSET) | ||
| end: Конечный индекс (не используется напрямую, см. limit) | ||
| limit: Максимальное количество записей. По умолчанию 1000. | ||
| order: Направление сортировки "DESC" или "ASC" | ||
| sort: Поле для сортировки. По умолчанию "id". | ||
| filter: Фильтр в формате FilterExpression. | ||
| Например: [("active", "=", True), ("name", "ilike", "%test%")] | ||
| raw: Если True - возвращает сырые данные без преобразования в модели | ||
| session: DB сессия (опционально) | ||
| Returns: | ||
| Список экземпляров модели с загруженными данными. | ||
| Relation поля инициализируются: | ||
| - Many2one → модель или None | ||
| - One2many → список моделей или [] | ||
| - Many2many → список моделей или [] | ||
| Example: | ||
| # Найти активных пользователей с их ролями | ||
| users = await User.search( | ||
| fields=["id", "name", "email", "role_ids"], | ||
| filter=[("active", "=", True)], | ||
| limit=50, | ||
| order="ASC", | ||
| sort="name" | ||
| ) | ||
| for user in users: | ||
| if user.role_ids: # Корректно работает ([] если пусто) | ||
| print(f"{user.name}: {len(user.role_ids)} roles") | ||
| Note: | ||
| Relations загружаются batch-запросами (один запрос на тип relation | ||
| для всех найденных записей). Для одной записи используйте | ||
| get(id, fields, fields_nested). | ||
| """ | ||
| cls = self.__class__ | ||
| if fields is None: | ||
| fields = self.get_store_fields() | ||
| # Access check + apply domain filter | ||
@@ -81,5 +127,2 @@ filter = await cls._check_access(Operation.READ, filter=filter) | ||
| # Use dialect from class | ||
| dialect = cls._dialect | ||
| stmt, values = cls._builder.build_search( | ||
@@ -101,3 +144,3 @@ fields, start, end, limit, order, sort, filter | ||
| await cls._records_list_get_relation( | ||
| session, fields_relation, records | ||
| session, fields_relation, records, fields_nested | ||
| ) | ||
@@ -160,36 +203,28 @@ | ||
| @classmethod | ||
| async def get_with_relations( | ||
| async def _get_load_relations( | ||
| cls, | ||
| id, | ||
| fields=None, | ||
| fields_info={}, | ||
| session=None, | ||
| ) -> Self: | ||
| """Get record with relations loaded.""" | ||
| if not fields: | ||
| fields = [] | ||
| session = cls._get_db_session(session) | ||
| record, | ||
| fields: list[str], | ||
| fields_nested: dict[str, list[str]], | ||
| session, | ||
| ): | ||
| """ | ||
| Загрузка relation полей для одной записи. | ||
| dialect = cls._dialect | ||
| Используется из get() когда передан fields_nested. | ||
| Для каждого relation поля выполняет отдельный запрос. | ||
| # защита, оставить только те поля, которые действительно хранятся в базе | ||
| fields_store = [ | ||
| name for name in cls.get_store_fields() if name in fields | ||
| ] | ||
| # если вдруг они не заданы, или таких нет, взять все | ||
| if not fields_store: | ||
| fields_store = [name for name in cls.get_store_fields()] | ||
| if "id" not in fields_store: | ||
| fields_store.append("id") | ||
| Формат результата единообразен с search(): | ||
| M2O → объект модели или None | ||
| O2M → список объектов | ||
| M2M → список объектов | ||
| stmt, values = cls._builder.build_get(id, fields_store) | ||
| record_raw: list[Any] = await session.execute(stmt, values) | ||
| if not record_raw: | ||
| raise ValueError("Record not found") | ||
| record = cls(**record_raw[0]) | ||
| # защита, оставить только те поля, которые являются отношениями (m2m, o2m, m2o) | ||
| # добавлена информаци о вложенных полях | ||
| Args: | ||
| record: Экземпляр модели с загруженными store полями | ||
| fields: Список запрошенных полей (store + relation) | ||
| fields_nested: Словарь вложенных полей для relations | ||
| session: DB сессия | ||
| """ | ||
| fields_relation = [ | ||
| (name, field, fields_info.get(name)) | ||
| (name, field) | ||
| for name, field in cls.get_relation_fields() | ||
@@ -199,187 +234,124 @@ if name in fields | ||
| # если есть хоть одна запись и вообще нужно читать поля связей | ||
| if record and fields_relation: | ||
| request_list = [] | ||
| execute_list = [] | ||
| if not fields_relation: | ||
| return | ||
| # добавить запрос на o2m | ||
| for name, field, fields_nested in fields_relation: | ||
| relation_table = field.relation_table | ||
| relation_table_field = field.relation_table_field | ||
| execute_list = [] | ||
| request_meta = [] # (field_name, field_type, relation) | ||
| if not fields_nested and relation_table: | ||
| fields_select = ["id"] | ||
| if relation_table.get_fields().get("name"): | ||
| fields_select.append("name") | ||
| if isinstance(field, AttachmentMany2one): | ||
| fields_select = ( | ||
| relation_table.get_store_fields_omit_m2o() | ||
| ) | ||
| else: | ||
| fields_select = fields_nested | ||
| for name, field in fields_relation: | ||
| relation_table = field.relation_table | ||
| relation_table_field = field.relation_table_field | ||
| if ( | ||
| isinstance(field, (Many2one, AttachmentMany2one)) | ||
| and relation_table | ||
| ): | ||
| m2o_id = getattr(record, name) | ||
| stmt, val = relation_table._builder.build_get( | ||
| m2o_id, fields=fields_select | ||
| ) | ||
| req = RequestBuilderForm( | ||
| stmt=stmt, | ||
| value=val, | ||
| field_name=name, | ||
| field=field, | ||
| # Определяем какие поля вложенной модели загружать | ||
| nested = fields_nested.get(name) | ||
| if nested: | ||
| fields_select = nested | ||
| elif relation_table: | ||
| fields_select = ["id"] | ||
| if relation_table.get_fields().get("name"): | ||
| fields_select.append("name") | ||
| if isinstance(field, PolymorphicMany2one): | ||
| fields_select = relation_table.get_store_fields_omit_m2o() | ||
| else: | ||
| continue | ||
| if ( | ||
| isinstance(field, (Many2one, PolymorphicMany2one)) | ||
| and relation_table | ||
| ): | ||
| m2o_id = getattr(record, name) | ||
| if m2o_id is None or isinstance(m2o_id, Field): | ||
| setattr(record, name, None) | ||
| continue | ||
| execute_list.append( | ||
| relation_table.search( | ||
| fields=fields_select, | ||
| filter=[("id", "=", m2o_id)], | ||
| limit=1, | ||
| ) | ||
| request_list.append(req) | ||
| execute_list.append( | ||
| session.execute( | ||
| req.stmt, | ||
| req.value, | ||
| prepare=req.function_prepare, | ||
| cursor=req.function_cursor, | ||
| ) | ||
| ) | ||
| # если m2m или o2m необходимо посчитать длину, для пагинации | ||
| if isinstance(field, Many2many): | ||
| params = { | ||
| "id": record.id, | ||
| "comodel": relation_table, | ||
| "relation": field.many2many_table, | ||
| "column1": field.column1, | ||
| "column2": field.column2, | ||
| "fields": fields_select, | ||
| "order": "desc", | ||
| "start": 0, | ||
| "end": 40, | ||
| "sort": "id", | ||
| "limit": 40, | ||
| } | ||
| # records | ||
| execute_list.append(cls.get_many2many(**params)) | ||
| params["fields"] = ["id"] | ||
| params["start"] = None | ||
| params["end"] = None | ||
| params["limit"] = None | ||
| # len | ||
| execute_list.append(cls.get_many2many(**params)) | ||
| req = RequestBuilderForm( | ||
| stmt=None, | ||
| value=None, | ||
| field_name=name, | ||
| field=field, | ||
| ) | ||
| request_meta.append((name, "m2o")) | ||
| elif isinstance(field, Many2many) and relation_table: | ||
| execute_list.append( | ||
| cls.get_many2many( | ||
| id=record.id, | ||
| comodel=relation_table, | ||
| relation=field.many2many_table, | ||
| column1=field.column1, | ||
| column2=field.column2, | ||
| fields=fields_select, | ||
| limit=None, | ||
| ) | ||
| request_list.append(req) | ||
| ) | ||
| request_meta.append((name, "m2m")) | ||
| if isinstance(field, One2many) and relation_table: | ||
| params = { | ||
| "start": 0, | ||
| "end": 40, | ||
| "limit": 40, | ||
| "fields": fields_select, | ||
| "filter": [(relation_table_field, "=", record.id)], | ||
| } | ||
| execute_list.append(relation_table.search(**params)) | ||
| params["fields"] = ["id"] | ||
| params["start"] = None | ||
| params["end"] = None | ||
| params["limit"] = 1000 | ||
| execute_list.append(relation_table.search(**params)) | ||
| req = RequestBuilderForm( | ||
| stmt=None, | ||
| value=None, | ||
| field_name=name, | ||
| field=field, | ||
| elif ( | ||
| isinstance(field, One2many) | ||
| and relation_table | ||
| and relation_table_field | ||
| ): | ||
| execute_list.append( | ||
| relation_table.search( | ||
| fields=fields_select, | ||
| filter=[(relation_table_field, "=", record.id)], | ||
| limit=1000, | ||
| ) | ||
| request_list.append(req) | ||
| ) | ||
| request_meta.append((name, "o2m")) | ||
| if isinstance(field, AttachmentOne2many) and relation_table: | ||
| params = { | ||
| "start": 0, | ||
| "end": 40, | ||
| "limit": 40, | ||
| "fields": relation_table.get_store_fields_omit_m2o(), | ||
| "filter": [ | ||
| elif isinstance(field, PolymorphicOne2many) and relation_table: | ||
| execute_list.append( | ||
| relation_table.search( | ||
| fields=relation_table.get_store_fields_omit_m2o(), | ||
| filter=[ | ||
| ("res_id", "=", record.id), | ||
| ("res_model", "=", record.__table__), | ||
| ], | ||
| } | ||
| execute_list.append(relation_table.search(**params)) | ||
| params["fields"] = ["id"] | ||
| params["start"] = None | ||
| params["end"] = None | ||
| params["limit"] = 1000 | ||
| execute_list.append(relation_table.search(**params)) | ||
| req = RequestBuilderForm( | ||
| stmt=None, | ||
| value=None, | ||
| field_name=name, | ||
| field=field, | ||
| fields=relation_table.get_store_fields_omit_m2o(), | ||
| limit=1000, | ||
| ) | ||
| request_list.append(req) | ||
| ) | ||
| request_meta.append((name, "o2m")) | ||
| if isinstance(field, One2one) and relation_table: | ||
| params = { | ||
| "limit": 1, | ||
| "fields": fields_select, | ||
| "filter": [(relation_table_field, "=", record.id)], | ||
| } | ||
| execute_list.append(relation_table.search(**params)) | ||
| req = RequestBuilderForm( | ||
| stmt=None, | ||
| value=None, | ||
| field_name=name, | ||
| field=field, | ||
| elif ( | ||
| isinstance(field, One2one) | ||
| and relation_table | ||
| and relation_table_field | ||
| ): | ||
| execute_list.append( | ||
| relation_table.search( | ||
| fields=fields_select, | ||
| filter=[(relation_table_field, "=", record.id)], | ||
| limit=1, | ||
| ) | ||
| request_list.append(req) | ||
| ) | ||
| request_meta.append((name, "m2o")) | ||
| # выполняем последовательно в транзакции, параллельно вне транзакции | ||
| results = await execute_maybe_parallel(execute_list) | ||
| if not execute_list: | ||
| return | ||
| # добавляем атрибуты к исходному объекту, | ||
| # получая удобное обращение через дот-нотацию | ||
| i = 0 | ||
| for request_builder in request_list: | ||
| result = results[i] | ||
| results = await execute_maybe_parallel(execute_list) | ||
| if isinstance( | ||
| request_builder.field, | ||
| (Many2one, AttachmentMany2one, One2one), | ||
| ): | ||
| # m2o нужно распаковать так как он тоже в списке | ||
| # если пустой список, то установить None | ||
| result = result[0] if result else None | ||
| for i, (name, rel_type) in enumerate(request_meta): | ||
| result = results[i] | ||
| if rel_type == "m2o": | ||
| setattr(record, name, result[0] if result else None) | ||
| else: | ||
| # o2m, m2m → список | ||
| setattr(record, name, result if result else []) | ||
| if isinstance( | ||
| request_builder.field, | ||
| (Many2many, One2many, AttachmentOne2many), | ||
| ): | ||
| # если m2m или o2m необбзодимо взять два результатата | ||
| # так как один из них это число всех строк таблицы | ||
| # для пагинации | ||
| fields_info = request_builder.field.relation_table.get_fields_info_list( | ||
| request_builder.fields | ||
| ) | ||
| result = { | ||
| "data": result, | ||
| "fields": fields_info, | ||
| "total": len(results[i + 1]), | ||
| } | ||
| i += 1 | ||
| async def _update_relations( | ||
| self, payload: _M, update_fields: list[str], session=None | ||
| ): | ||
| """ | ||
| Обновить запись с поддержкой relation полей (M2M, O2M, attachments). | ||
| setattr(record, request_builder.field_name, result) | ||
| i += 1 | ||
| Вызывается из update() когда fields содержит relation поля. | ||
| Обрабатывает: store поля (через update) + attachments + O2M/M2M. | ||
| return record | ||
| async def update_with_relations( | ||
| self, payload: _M, fields=[], session=None | ||
| ): | ||
| """Update record with relations.""" | ||
| Args: | ||
| payload: Экземпляр модели с новыми значениями полей | ||
| update_fields: Список полей для обновления | ||
| session: DB сессия | ||
| """ | ||
| session = self._get_db_session(session) | ||
@@ -391,3 +363,3 @@ | ||
| for name, field in self.get_relation_fields_attachment() | ||
| if name in fields | ||
| if name in update_fields | ||
| ] | ||
@@ -397,3 +369,3 @@ | ||
| for name, field in fields_attachments: | ||
| if isinstance(field, AttachmentMany2one): | ||
| if isinstance(field, PolymorphicMany2one): | ||
| field_obj = getattr(payload, name) | ||
@@ -412,9 +384,9 @@ if field_obj and field.relation_table: | ||
| # Update stored fields | ||
| # Update store fields | ||
| fields_store = [ | ||
| name for name in self.get_store_fields() if name in fields | ||
| name for name in self.get_store_fields() if name in update_fields | ||
| ] | ||
| # Обновление сущности в базе без связей | ||
| if fields_store: | ||
| record_raw = await self.update(payload, fields, session) | ||
| await self._update_store(payload, fields_store, session) | ||
@@ -426,3 +398,3 @@ # защита, оставить только те поля, которые являются отношениями (m2m, o2m) | ||
| for name, field in self.get_relation_fields_m2m_o2m() | ||
| if name in fields | ||
| if name in update_fields | ||
| ] | ||
@@ -432,3 +404,2 @@ | ||
| request_list = [] | ||
| field_list = [] | ||
@@ -448,10 +419,9 @@ for name, field in fields_relation: | ||
| if isinstance(field, (One2many, AttachmentOne2many)): | ||
| field_list.append(field) | ||
| if isinstance(field, (One2many, PolymorphicOne2many)): | ||
| # заменить в связанных полях виртуальный ид на вновь созданный | ||
| for obj in field_obj["created"]: | ||
| for obj in field_obj.get("created", []): | ||
| for k, v in obj.items(): | ||
| f = getattr(field.relation_table, k) | ||
| if ( | ||
| isinstance(f, (Many2one, AttachmentMany2one)) | ||
| isinstance(f, (Many2one, PolymorphicMany2one)) | ||
| and v == "VirtualId" | ||
@@ -463,10 +433,10 @@ ): | ||
| field.relation_table(**obj) | ||
| for obj in field_obj["created"] | ||
| for obj in field_obj.get("created", []) | ||
| ] | ||
| if isinstance(field, AttachmentOne2many): | ||
| if isinstance(field, PolymorphicOne2many): | ||
| for obj in data_created: | ||
| obj.res_id = self.id | ||
| if field_obj["created"]: | ||
| if field_obj.get("created", []): | ||
| request_list.append( | ||
@@ -483,10 +453,8 @@ field.relation_table.create_bulk(data_created) | ||
| if isinstance(field, Many2many): | ||
| field_list.append(field) | ||
| # Replace virtual ID | ||
| for obj in field_obj["created"]: | ||
| for obj in field_obj.get("created", []): | ||
| for k, v in obj.items(): | ||
| f = getattr(field.relation_table, k) | ||
| if ( | ||
| isinstance(f, (Many2one, AttachmentMany2one)) | ||
| isinstance(f, (Many2one, PolymorphicMany2one)) | ||
| and v == "VirtualId" | ||
@@ -498,6 +466,6 @@ ): | ||
| field.relation_table(**obj) | ||
| for obj in field_obj["created"] | ||
| for obj in field_obj.get("created", []) | ||
| ] | ||
| if field_obj["created"]: | ||
| if field_obj.get("created", []): | ||
| created_ids = await field.relation_table.create_bulk( | ||
@@ -527,5 +495,3 @@ data_created | ||
| # выполняем последовательно | ||
| results = await execute_maybe_parallel(request_list) | ||
| return record_raw | ||
| for coro in request_list: | ||
| await coro |
@@ -98,3 +98,3 @@ """Protocols defining what ORM mixins expect from the model class.""" | ||
| self, | ||
| payload: Self | None = None, | ||
| payload: Self, | ||
| fields: Any = None, | ||
@@ -144,6 +144,31 @@ session: Any = None, | ||
| records: list[Any], | ||
| fields_nested: dict[str, list[str]] | None = None, | ||
| ) -> None: ... | ||
| # From OrmRelationsMixin | ||
| @classmethod | ||
| async def _get_load_relations( | ||
| cls, | ||
| record: Any, | ||
| fields: list[str], | ||
| fields_nested: dict[str, list[str]], | ||
| session: Any, | ||
| ) -> None: ... | ||
| async def _update_relations( | ||
| self, | ||
| payload: Any, | ||
| update_fields: list[str], | ||
| session: Any, | ||
| ) -> None: ... | ||
| async def _update_store( | ||
| self, | ||
| payload: Any, | ||
| fields: list[str], | ||
| session: Any, | ||
| ) -> Any: ... | ||
| # From DDLMixin | ||
| @staticmethod | ||
| def format_default_value(value: Any) -> str: ... |
+1
-1
| Metadata-Version: 2.4 | ||
| Name: dotorm | ||
| Version: 2.0.9 | ||
| Version: 2.0.10 | ||
| Summary: Async Python ORM for PostgreSQL, MySQL and ClickHouse with dot-notation access | ||
@@ -5,0 +5,0 @@ Project-URL: Homepage, https://github.com/shurshilov/dotorm |
+1
-1
@@ -7,3 +7,3 @@ [build-system] | ||
| name = "dotorm" | ||
| version = "2.0.9" | ||
| version = "2.0.10" | ||
| description = "Async Python ORM for PostgreSQL, MySQL and ClickHouse with dot-notation access" | ||
@@ -10,0 +10,0 @@ readme = "README.md" |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
321746
1.44%4982
1.63%