dotorm
Advanced tools
@@ -49,3 +49,3 @@ """ | ||
| __version__ = "2.0.8" | ||
| __version__ = "2.0.9" | ||
@@ -52,0 +52,0 @@ __all__ = [ |
@@ -9,3 +9,3 @@ """Relations query builder.""" | ||
| from ..request_builder import RequestBuilder | ||
| from ...fields import Field, Many2many, Many2one, One2many | ||
| from ...fields import AttachmentMany2one, Field, Many2many, Many2one, One2many | ||
@@ -75,5 +75,7 @@ | ||
| elif isinstance(field, Many2one): | ||
| elif isinstance(field, (Many2one, AttachmentMany2one)): | ||
| ids_m2o: list[int] = [ | ||
| getattr(record, name) for record in records | ||
| getattr(record, name) | ||
| for record in records | ||
| if getattr(record, name) is not None # Фильтруем None | ||
| ] | ||
@@ -84,2 +86,5 @@ # оставляем только уникальные ид, так как в m2o несколько записей | ||
| # Если нет ни одного ID — пропускаем | ||
| if not ids_m2o: | ||
| continue | ||
| stmt, val = field.relation_table._builder.build_search( | ||
@@ -86,0 +91,0 @@ fields=fields, |
@@ -54,2 +54,4 @@ """Request builder for relation queries.""" | ||
| Many2one: "prepare_list_ids", | ||
| AttachmentMany2one: "prepare_list_ids", | ||
| AttachmentOne2many: "prepare_list_ids", | ||
| } | ||
@@ -56,0 +58,0 @@ |
@@ -37,6 +37,21 @@ """ | ||
| # Рекурсивный тип для фильтров | ||
| # FilterExpression - список элементов, где каждый элемент это: | ||
| # - FilterTriplet: ("field", "=", value) - условие | ||
| # - tuple[Literal["not"], FilterExpression]: ("not", [...]) - отрицание | ||
| # - Literal["and", "or"]: логический оператор между условиями | ||
| # - FilterExpression: [...] - вложенная группа (рекурсия) | ||
| # | ||
| # Примеры: | ||
| # [("a", "=", 1), ("b", "=", 2)] # a=1 AND b=2 | ||
| # [("a", "=", 1), "or", ("b", "=", 2)] # a=1 OR b=2 | ||
| # [("a", "=", 1), [("b", "=", 2), "or", ("c", "=", 3)]] # a=1 AND (b=2 OR c=3) | ||
| # [("not", [("a", "=", 1)])] # NOT (a=1) | ||
| FilterExpression = list[ | ||
| FilterTriplet | ||
| | tuple[Literal["not"], "FilterExpression"] | ||
| | list[Union["FilterExpression", Literal["and", "or"]]], | ||
| Union[ | ||
| FilterTriplet, | ||
| tuple[Literal["not"], "FilterExpression"], | ||
| Literal["and", "or"], | ||
| "FilterExpression", | ||
| ] | ||
| ] | ||
@@ -129,2 +144,8 @@ | ||
| ) | ||
| # != с значением должен включать NULL строки | ||
| # В SQL: NULL != 1 возвращает NULL (не TRUE) | ||
| # Поэтому добавляем OR IS NULL | ||
| if op == "!=": | ||
| clause = f"({field} IS NULL OR {field} != %s)" | ||
| return clause, (value,) | ||
| clause = f"{field} {op} %s" | ||
@@ -131,0 +152,0 @@ return clause, (value,) |
+29
-13
@@ -6,3 +6,3 @@ """ORM field definitions.""" | ||
| import logging | ||
| from typing import TYPE_CHECKING, Any, Callable, Type | ||
| from typing import TYPE_CHECKING, Any, Callable, Type, Literal | ||
@@ -16,3 +16,8 @@ if TYPE_CHECKING: | ||
| # Допустимые значения для ondelete (общие для PostgreSQL и MySQL InnoDB) | ||
| # SET DEFAULT не поддерживается MySQL InnoDB, поэтому исключён | ||
| OnDeleteAction = Literal["restrict", "no action", "cascade", "set null"] | ||
| VALID_ONDELETE_ACTIONS = ("restrict", "no action", "cascade", "set null") | ||
| class Field[FieldType]: | ||
@@ -35,2 +40,7 @@ """ | ||
| options - List options for selection field | ||
| ondelete - Foreign key ON DELETE action: | ||
| "restrict" - prevent deletion if referenced | ||
| "no action" - same as restrict (deferred in PostgreSQL) | ||
| "cascade" - delete child rows | ||
| "set null" - set foreign key to NULL | ||
@@ -66,7 +76,4 @@ relation - Is this a relation field? | ||
| compute: Callable | None = None | ||
| # compute_deps: Set[str] | ||
| # is_computed: bool = False | ||
| relation: bool = False | ||
| relation_table_field: str | None = None | ||
| # наверное перенести в класс relation | ||
| _relation_table: "DotModel | None" = None | ||
@@ -87,9 +94,23 @@ | ||
| # self.compute_deps: Set[str] = kwargs.pop("compute_deps", set()) | ||
| self.indexable = kwargs.pop("indexable", self.indexable) | ||
| self.store = kwargs.pop("store", self.store) | ||
| self.ondelete = ( | ||
| "set null" if kwargs.pop("null", self.null) else "restrict" | ||
| ) | ||
| # ondelete - явное указание действия при удалении родительской записи | ||
| # Если не указано явно, определяется автоматически на основе null | ||
| explicit_ondelete = kwargs.pop("ondelete", None) | ||
| if explicit_ondelete is not None: | ||
| # Валидация допустимых значений | ||
| if explicit_ondelete.lower() not in VALID_ONDELETE_ACTIONS: | ||
| raise OrmConfigurationFieldException( | ||
| f"Invalid ondelete value: '{explicit_ondelete}'. " | ||
| f"Must be one of: {', '.join(VALID_ONDELETE_ACTIONS)}" | ||
| ) | ||
| self.ondelete = explicit_ondelete.lower() | ||
| else: | ||
| # Автоматическое определение на основе null | ||
| # null=True → set null (безопасно удаляет связь) | ||
| # null=False → restrict (защищает от удаления) | ||
| is_nullable = kwargs.get("null", self.null) | ||
| self.ondelete = "set null" if is_nullable else "restrict" | ||
| for name, value in kwargs.items(): | ||
@@ -100,6 +121,2 @@ setattr(self, name, value) | ||
| # обман тайп чекера. | ||
| # TODO: В идеале, сделать так чтобы тип поля менялся если это инстанс или если это класс. | ||
| # 1. Возможно это необходимо сделать в классе скорей всего модели | ||
| # 2. Или перейти на pep-0593 (Integer = Annotated[int, Integer(primary_key=True)]) | ||
| # но тогда в классе не будет типа Field и мы получим такую же ситуаци но в классе | ||
| def __new__(cls, *args: Any, **kwargs: Any) -> FieldType: | ||
@@ -153,3 +170,2 @@ return super().__new__(cls) | ||
| if self.index: | ||
| # self.index = False | ||
| raise OrmConfigurationFieldException( | ||
@@ -156,0 +172,0 @@ f"{self.__class__.__name__} can't be both index=True and unique=True. Index will be ignored." |
@@ -24,4 +24,5 @@ try: | ||
| fields_relation = [] | ||
| # fields = [] | ||
| for field_name, field in cls.__dict__.items(): | ||
| # Используем get_all_fields() чтобы получить поля включая добавленные через @extend | ||
| for field_name, field in cls.get_all_fields().items(): | ||
| if isinstance(field, DotField): | ||
@@ -34,3 +35,8 @@ if not isinstance(field, (Many2many, One2many)): | ||
| # то это поле будет содержать просто список своих полей | ||
| allowed_fields = list(field.relation_table.get_fields()) | ||
| allowed_fields = list(field.relation_table.get_all_fields()) | ||
| # TODO: по идее должно быть так | ||
| # relation_table = field.relation_table | ||
| # if callable(relation_table): | ||
| # relation_table = relation_table() | ||
| # allowed_fields = list(relation_table.get_all_fields()) | ||
| params = {field_name: (list[Literal[*allowed_fields]], ...)} | ||
@@ -50,18 +56,2 @@ SchemaGetFieldRelationInput = create_model( | ||
| # from pydantic import BaseModel, create_model | ||
| # from typing import Any, Dict, Type | ||
| # def create_pydantic_model_from_class(cls: Type) -> Type[BaseModel]: | ||
| # annotations: dict[str, TypeAlias] = getattr(cls, '__annotations__', {}) | ||
| # fields: dict[str, tuple] = {} | ||
| # for field_name, field_type in annotations.items(): | ||
| # # default = getattr(cls, field_name, ...) | ||
| # # fields[field_name] = (field_type, default) | ||
| # fields[field_name] = (field_type, None) | ||
| # model = create_model(cls.__name__ + 'Schema', **fields) | ||
| # return model | ||
| from typing import ( | ||
@@ -78,14 +68,2 @@ Annotated, | ||
| # def parse_string_union_type(text: str): | ||
| # """ | ||
| # Парсит строку вида 'Uom | None' или 'Role | None | Other' | ||
| # Возвращает (is_union: bool, list of parts) | ||
| # """ | ||
| # if "|" not in text: | ||
| # return False, [text.strip()] | ||
| # parts = [p.strip() for p in text.split("|")] | ||
| # return True, parts | ||
| def replace_custom_types(py_type, class_map: dict[str, Type[BaseModel]]): | ||
@@ -101,15 +79,2 @@ """ | ||
| if isinstance(py_type, str): | ||
| # # 1) проверяем на union-строку | ||
| # is_union, parts = parse_string_union_type(py_type) | ||
| # if is_union: | ||
| # converted = [] | ||
| # for part in parts: | ||
| # if part == "None": | ||
| # converted.append(type(None)) | ||
| # else: | ||
| # converted.append(ForwardRef(f"Schema{part}")) | ||
| # return Union[tuple(converted)] | ||
| # 2) обычная строка: "Uom" | ||
@@ -172,34 +137,2 @@ return ForwardRef(f"Schema{py_type}") | ||
| # def convert_field_type( | ||
| # py_type, field_value, class_map: dict[str, Type[BaseModel]] | ||
| # ): | ||
| # """ | ||
| # Оборачиваем тип в Annotated, если поле кастомное. | ||
| # - Сохраняет None для одиночных моделей (Role | None) | ||
| # - Сохраняет поведение для списков (list[Rule]) | ||
| # - Минимальные изменения | ||
| # """ | ||
| # final_type = replace_custom_types(py_type, class_map) | ||
| # if field_value is not None: | ||
| # # Определяем origin, чтобы проверить, что это не список | ||
| # origin = get_origin(final_type) | ||
| # # Если это одиночная модель (не список) и поле допускает None — добавляем Optional | ||
| # if ( | ||
| # origin not in (list, List) | ||
| # and get_origin(py_type) is UnionType | ||
| # and type(None) in get_args(py_type) | ||
| # ): | ||
| # # role_id: Role | None | ||
| # # return Annotated[final_type | None, field_value.__class__] | ||
| # return Optional[Annotated[final_type, field_value.__class__]] | ||
| # # Иначе обычное поведение (списки и одиночные обязательные поля) | ||
| # return Annotated[final_type, field_value.__class__] | ||
| # return final_type | ||
| def generate_pydantic_models( | ||
@@ -225,8 +158,32 @@ classes: list[type], prefix="Schema" | ||
| cls_name = cls.__name__ | ||
| annotations = getattr(cls, "__annotations__", {}) | ||
| # Используем get_all_fields() для получения всех полей включая @extend | ||
| all_fields = cls.get_all_fields() | ||
| # Собираем аннотации из всех классов в MRO + добавленные через @extend | ||
| annotations = {} | ||
| for klass in reversed(cls.__mro__): | ||
| if klass is object: | ||
| continue | ||
| annotations.update(getattr(klass, "__annotations__", {})) | ||
| # Добавляем аннотации для полей из @extend которые могут не иметь __annotations__ | ||
| for field_name, field_obj in all_fields.items(): | ||
| if field_name not in annotations: | ||
| # Получаем тип из Field объекта | ||
| if hasattr(field_obj, "python_type"): | ||
| annotations[field_name] = field_obj.python_type | ||
| else: | ||
| # Fallback - используем Any | ||
| annotations[field_name] = Any | ||
| model_fields = {} | ||
| for name, py_type in annotations.items(): | ||
| field_value = getattr(cls, name, None) | ||
| # Проверяем что это реально поле модели | ||
| if name not in all_fields: | ||
| continue | ||
| field_value = all_fields.get(name) | ||
| # Разрешаем ForwardRef или вложенные типы | ||
@@ -259,4 +216,2 @@ final_type = convert_field_type(py_type, field_value, known_models) | ||
| model_fields[name] = (final_type, default) | ||
| else: | ||
| raise ValueError("Found not DotField object") | ||
@@ -272,5 +227,2 @@ # Обновляем модель | ||
| # known_models[cls_name].model_fields.update(model_fields) | ||
| # known_models[cls_name].model_rebuild(force=True) | ||
| # Обновляем forward refs | ||
@@ -282,4 +234,3 @@ _types_namespace = { | ||
| model.model_rebuild(force=True, _types_namespace=_types_namespace) | ||
| # model.update_forward_refs(**known_models) | ||
| return known_models |
+13
-10
@@ -297,5 +297,13 @@ """DotModel - main ORM model class.""" | ||
| def get_fields(cls) -> dict[str, Field]: | ||
| """Возвращает все поля модели, включая унаследованные из миксинов. | ||
| Это алиас для get_all_fields() для обратной совместимости. | ||
| """ | ||
| return cls.get_all_fields() | ||
| @classmethod | ||
| def get_own_fields(cls) -> dict[str, Field]: | ||
| """Возвращает только собственные поля класса (без унаследованных). | ||
| Для получения всех полей включая унаследованные используйте get_all_fields(). | ||
| Для получения всех полей включая унаследованные используйте get_fields(). | ||
| """ | ||
@@ -406,3 +414,4 @@ return { | ||
| for name, field in cls.get_fields().items() | ||
| if field.store and not isinstance(field, Many2one) | ||
| if field.store | ||
| and not isinstance(field, (Many2one, AttachmentMany2one)) | ||
| ] | ||
@@ -669,10 +678,4 @@ | ||
| elif mode == JsonMode.NESTED_LIST: | ||
| # Вложенная сериализация из FORM: field это dict с data | ||
| fields_json[field_name] = [ | ||
| { | ||
| "id": rec.id, | ||
| "name": rec.name or str(rec.id), | ||
| } | ||
| for rec in field["data"] | ||
| ] | ||
| # Вложенная сериализация оставляем как есть | ||
| fields_json[field_name] = field | ||
| elif mode == JsonMode.FORM: | ||
@@ -679,0 +682,0 @@ # При FORM (get) field это dict с data/fields/total |
@@ -13,3 +13,3 @@ """Many2many ORM operations mixin.""" | ||
| from ...fields import Field, Many2many, Many2one, One2many | ||
| from ...fields import AttachmentMany2one, Field, Many2many, Many2one, One2many | ||
| from ...decorators import hybridmethod | ||
@@ -142,3 +142,3 @@ from ..utils import execute_maybe_parallel | ||
| if isinstance(req.field, Many2one): | ||
| if isinstance(req.field, (Many2one, AttachmentMany2one)): | ||
| for rec in records: | ||
@@ -145,0 +145,0 @@ rec_field_raw = getattr(rec, req.field_name) |
@@ -398,3 +398,3 @@ """Relations ORM operations mixin.""" | ||
| attachment_id = await field.relation_table.create( | ||
| attachment_payload, session | ||
| payload=attachment_payload | ||
| ) | ||
@@ -401,0 +401,0 @@ setattr(payload, name, attachment_id) |
+1
-1
| Metadata-Version: 2.4 | ||
| Name: dotorm | ||
| Version: 2.0.8 | ||
| Version: 2.0.9 | ||
| 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.8" | ||
| version = "2.0.9" | ||
| 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.
317194
0.38%4902
0.14%