diff --git a/docs/api/query-set/join.md b/docs/api/query-set/join.md index fcf5b88..7a08704 100644 --- a/docs/api/query-set/join.md +++ b/docs/api/query-set/join.md @@ -150,11 +150,11 @@ Process order_by causes for non m2m relations. - `fields (Optional[Union[Set, Dict]])`: fields to include - `exclude_fields (Optional[Union[Set, Dict]])`: fields to exclude - + #### \_switch\_many\_to\_many\_order\_columns ```python - | _switch_many_to_many_order_columns(part: str, new_part: str) -> None + | _replace_many_to_many_order_by_columns(part: str, new_part: str) -> None ``` Substitutes the name of the relation with actual model name in m2m order bys. diff --git a/docs/models/index.md b/docs/models/index.md index 40ddb63..0bee4ea 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -76,7 +76,7 @@ Since it can be a function you can set `default=datetime.datetime.now` and get c response with `include`/`exclude` and `response_model_include`/`response_model_exclude` accordingly. ```python -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> class User(ormar.Model): class Meta: tablename: str = "users2" @@ -93,14 +93,14 @@ class User(ormar.Model): pydantic_only=True, default=datetime.datetime.now ) -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> app =FastAPI() @app.post("/users/") async def create_user(user: User): return await user.save() -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> def test_excluding_fields_in_endpoints(): client = TestClient(app) @@ -127,7 +127,7 @@ def test_excluding_fields_in_endpoints(): assert response.json().get("timestamp") == str(timestamp).replace(" ", "T") -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> ``` #### Property fields @@ -190,7 +190,7 @@ in the response from `fastapi` and `dict()` and `json()` methods. You cannot pas ``` ```python -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> def gen_pass(): # note: NOT production ready choices = string.ascii_letters + string.digits + "!@#$%^&*()" return "".join(random.choice(choices) for _ in range(20)) @@ -215,7 +215,7 @@ class RandomModel(ormar.Model): def full_name(self) -> str: return " ".join([self.first_name, self.last_name]) -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> app =FastAPI() # explicitly exclude property_field in this endpoint @@ -223,7 +223,7 @@ app =FastAPI() async def create_user(user: RandomModel): return await user.save() -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> def test_excluding_property_field_in_endpoints2(): client = TestClient(app) @@ -241,7 +241,7 @@ def test_excluding_property_field_in_endpoints2(): # despite being decorated with property_field if you explictly exclude it it will be gone assert response.json().get("full_name") is None -# <==part of code removed for clarity==> +# <==related of code removed for clarity==> ``` #### Fields names vs Column names diff --git a/ormar/models/helpers/models.py b/ormar/models/helpers/models.py index 20df795..6cf1f12 100644 --- a/ormar/models/helpers/models.py +++ b/ormar/models/helpers/models.py @@ -1,4 +1,5 @@ -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Type +import itertools +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Type import ormar # noqa: I100 from ormar.fields.foreign_key import ForeignKeyField @@ -109,3 +110,32 @@ def validate_related_names_in_relations( # noqa CCR001 f"\nTip: provide different related_name for FK and/or M2M fields" ) previous_related_names.append(field.related_name) + + +def group_related_list(list_: List) -> Dict: + """ + Translates the list of related strings into a dictionary. + That way nested models are grouped to traverse them in a right order + and to avoid repetition. + + Sample: ["people__houses", "people__cars__models", "people__cars__colors"] + will become: + {'people': {'houses': [], 'cars': ['models', 'colors']}} + + :param list_: list of related models used in select related + :type list_: List[str] + :return: list converted to dictionary to avoid repetition and group nested models + :rtype: Dict[str, List] + """ + test_dict: Dict[str, Any] = dict() + grouped = itertools.groupby(list_, key=lambda x: x.split("__")[0]) + for key, group in grouped: + group_list = list(group) + new = [ + "__".join(x.split("__")[1:]) for x in group_list if len(x.split("__")) > 1 + ] + if any("__" in x for x in new): + test_dict[key] = group_related_list(new) + else: + test_dict[key] = new + return test_dict diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 20a00d6..030b05b 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -221,7 +221,7 @@ def update_attrs_and_fields( :param attrs: new namespace for class being constructed :type attrs: Dict - :param new_attrs: part of the namespace extracted from parent class + :param new_attrs: related of the namespace extracted from parent class :type new_attrs: Dict :param model_fields: ormar fields in defined in current class :type model_fields: Dict[str, BaseField] diff --git a/ormar/models/model.py b/ormar/models/model.py index 35eaab1..c81a14b 100644 --- a/ormar/models/model.py +++ b/ormar/models/model.py @@ -1,4 +1,3 @@ -import itertools from typing import ( Any, Dict, @@ -18,38 +17,9 @@ import ormar.queryset # noqa I100 from ormar.exceptions import ModelPersistenceError, NoMatch from ormar.fields.many_to_many import ManyToManyField from ormar.models import NewBaseModel # noqa I100 +from ormar.models.helpers.models import group_related_list from ormar.models.metaclass import ModelMeta - -def group_related_list(list_: List) -> Dict: - """ - Translates the list of related strings into a dictionary. - That way nested models are grouped to traverse them in a right order - and to avoid repetition. - - Sample: ["people__houses", "people__cars__models", "people__cars__colors"] - will become: - {'people': {'houses': [], 'cars': ['models', 'colors']}} - - :param list_: list of related models used in select related - :type list_: List[str] - :return: list converted to dictionary to avoid repetition and group nested models - :rtype: Dict[str, List] - """ - test_dict: Dict[str, Any] = dict() - grouped = itertools.groupby(list_, key=lambda x: x.split("__")[0]) - for key, group in grouped: - group_list = list(group) - new = [ - "__".join(x.split("__")[1:]) for x in group_list if len(x.split("__")) > 1 - ] - if any("__" in x for x in new): - test_dict[key] = group_related_list(new) - else: - test_dict[key] = new - return test_dict - - if TYPE_CHECKING: # pragma nocover from ormar import QuerySet @@ -73,9 +43,11 @@ class Model(NewBaseModel): select_related: List = None, related_models: Any = None, previous_model: Type[T] = None, + source_model: Type[T] = None, related_name: str = None, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None, + current_relation_str: str = None, ) -> Optional[T]: """ Model method to convert raw sql row from database into ormar.Model instance. @@ -112,7 +84,10 @@ class Model(NewBaseModel): item: Dict[str, Any] = {} select_related = select_related or [] related_models = related_models or [] + table_prefix = "" + if select_related: + source_model = cls related_models = group_related_list(select_related) rel_name2 = related_name @@ -135,11 +110,15 @@ class Model(NewBaseModel): previous_model = through_field.through # type: ignore if previous_model and rel_name2: - table_prefix = cls.Meta.alias_manager.resolve_relation_alias( - previous_model, rel_name2 - ) - else: - table_prefix = "" + # TODO finish duplicated nested relation or remove this + if current_relation_str and "__" in current_relation_str and source_model: + table_prefix = cls.Meta.alias_manager.resolve_relation_alias( + from_model=source_model, relation_name=current_relation_str + ) + if not table_prefix: + table_prefix = cls.Meta.alias_manager.resolve_relation_alias( + from_model=previous_model, relation_name=rel_name2 + ) item = cls.populate_nested_models_from_row( item=item, @@ -147,6 +126,8 @@ class Model(NewBaseModel): related_models=related_models, fields=fields, exclude_fields=exclude_fields, + current_relation_str=current_relation_str, + source_model=source_model, ) item = cls.extract_prefixed_table_columns( item=item, @@ -163,8 +144,6 @@ class Model(NewBaseModel): ) instance = cls(**item) instance.set_save_status(True) - else: - instance = None return instance @classmethod @@ -175,6 +154,8 @@ class Model(NewBaseModel): related_models: Any, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None, + current_relation_str: str = None, + source_model: Type[T] = None, ) -> dict: """ Traverses structure of related models and populates the nested models @@ -202,35 +183,31 @@ class Model(NewBaseModel): and values are database values :rtype: Dict """ + for related in related_models: + relation_str = ( + "__".join([current_relation_str, related]) + if current_relation_str + else related + ) + fields = cls.get_included(fields, related) + exclude_fields = cls.get_excluded(exclude_fields, related) + model_cls = cls.Meta.model_fields[related].to + + remainder = None if isinstance(related_models, dict) and related_models[related]: - first_part, remainder = related, related_models[related] - model_cls = cls.Meta.model_fields[first_part].to - - fields = cls.get_included(fields, first_part) - exclude_fields = cls.get_excluded(exclude_fields, first_part) - - child = model_cls.from_row( - row, - related_models=remainder, - previous_model=cls, - related_name=related, - fields=fields, - exclude_fields=exclude_fields, - ) - item[model_cls.get_column_name_from_alias(first_part)] = child - else: - model_cls = cls.Meta.model_fields[related].to - fields = cls.get_included(fields, related) - exclude_fields = cls.get_excluded(exclude_fields, related) - child = model_cls.from_row( - row, - previous_model=cls, - related_name=related, - fields=fields, - exclude_fields=exclude_fields, - ) - item[model_cls.get_column_name_from_alias(related)] = child + remainder = related_models[related] + child = model_cls.from_row( + row, + related_models=remainder, + previous_model=cls, + related_name=related, + fields=fields, + exclude_fields=exclude_fields, + current_relation_str=relation_str, + source_model=source_model, + ) + item[model_cls.get_column_name_from_alias(related)] = child return item @@ -251,7 +228,7 @@ class Model(NewBaseModel): All joined tables have prefixes to allow duplicate column names, as well as duplicated joins to the same table from multiple different tables. - Extracted fields populates the item dict later used to construct a Model. + Extracted fields populates the related dict later used to construct a Model. Used in Model.from_row and PrefetchQuery._populate_rows methods. diff --git a/ormar/queryset/clause.py b/ormar/queryset/clause.py index 4746db4..514fdf1 100644 --- a/ormar/queryset/clause.py +++ b/ormar/queryset/clause.py @@ -194,7 +194,9 @@ class QueryClause: previous_model = through_field.through part2 = through_field.default_target_field_name() # type: ignore manager = model_cls.Meta.alias_manager - table_prefix = manager.resolve_relation_alias(previous_model, part2) + table_prefix = manager.resolve_relation_alias( + from_model=previous_model, relation_name=part2 + ) model_cls = model_cls.Meta.model_fields[part].to previous_model = model_cls return select_related, table_prefix, model_cls diff --git a/ormar/queryset/join.py b/ormar/queryset/join.py index 2b2205f..302289d 100644 --- a/ormar/queryset/join.py +++ b/ormar/queryset/join.py @@ -1,8 +1,8 @@ from collections import OrderedDict from typing import ( + Any, Dict, List, - NamedTuple, Optional, Set, TYPE_CHECKING, @@ -14,24 +14,13 @@ from typing import ( import sqlalchemy from sqlalchemy import text -from ormar.fields import ManyToManyField # noqa I100 +from ormar.fields import BaseField, ManyToManyField # noqa I100 from ormar.relations import AliasManager if TYPE_CHECKING: # pragma no cover from ormar import Model -class JoinParameters(NamedTuple): - """ - Named tuple that holds set of parameters passed during join construction. - """ - - prev_model: Type["Model"] - previous_alias: str - from_table: str - model_cls: Type["Model"] - - class SqlJoin: def __init__( # noqa: CFQ002 self, @@ -42,7 +31,12 @@ class SqlJoin: exclude_fields: Optional[Union[Set, Dict]], order_columns: Optional[List], sorted_orders: OrderedDict, + main_model: Type["Model"], + related_models: Any = None, + own_alias: str = "", ) -> None: + self.own_alias = own_alias + self.related_models = related_models or [] self.used_aliases = used_aliases self.select_from = select_from self.columns = columns @@ -50,18 +44,17 @@ class SqlJoin: self.exclude_fields = exclude_fields self.order_columns = order_columns self.sorted_orders = sorted_orders + self.main_model = main_model - @staticmethod - def alias_manager(model_cls: Type["Model"]) -> AliasManager: + @property + def alias_manager(self) -> AliasManager: """ - Shortcut for ormars model AliasManager stored on Meta. + Shortcut for ormar's model AliasManager stored on Meta. - :param model_cls: ormar Model class - :type model_cls: Type[Model] :return: alias manager from model's Meta :rtype: AliasManager """ - return model_cls.Meta.alias_manager + return self.main_model.Meta.alias_manager @staticmethod def on_clause( @@ -86,33 +79,32 @@ class SqlJoin: right_part = f"{previous_alias + '_' if previous_alias else ''}{from_clause}" return text(f"{left_part}={right_part}") - @staticmethod - def update_inclusions( - model_cls: Type["Model"], - fields: Optional[Union[Set, Dict]], - exclude_fields: Optional[Union[Set, Dict]], - nested_name: str, - ) -> Tuple[Optional[Union[Dict, Set]], Optional[Union[Dict, Set]]]: - """ - Extract nested fields and exclude_fields if applicable. - - :param model_cls: ormar model class - :type model_cls: Type["Model"] - :param fields: fields to include - :type fields: Optional[Union[Set, Dict]] - :param exclude_fields: fields to exclude - :type exclude_fields: Optional[Union[Set, Dict]] - :param nested_name: name of the nested field - :type nested_name: str - :return: updated exclude and include fields from nested objects - :rtype: Tuple[Optional[Union[Dict, Set]], Optional[Union[Dict, Set]]] - """ - fields = model_cls.get_included(fields, nested_name) - exclude_fields = model_cls.get_excluded(exclude_fields, nested_name) - return fields, exclude_fields + def process_deeper_join( + self, related_name: str, model_cls: Type["Model"], remainder: Any, alias: str, + ) -> None: + sql_join = SqlJoin( + used_aliases=self.used_aliases, + select_from=self.select_from, + columns=self.columns, + fields=self.main_model.get_excluded(self.fields, related_name), + exclude_fields=self.main_model.get_excluded( + self.exclude_fields, related_name + ), + order_columns=self.order_columns, + sorted_orders=self.sorted_orders, + main_model=model_cls, + related_models=remainder, + own_alias=alias, + ) + ( + self.used_aliases, + self.select_from, + self.columns, + self.sorted_orders, + ) = sql_join.build_join(related_name) def build_join( # noqa: CCR001 - self, item: str, join_parameters: JoinParameters + self, related: str ) -> Tuple[List, sqlalchemy.sql.select, List, OrderedDict]: """ Main external access point for building a join. @@ -120,59 +112,61 @@ class SqlJoin: handles switching to through models for m2m relations, returns updated lists of used_aliases and sort_orders. - :param item: string with join definition - :type item: str - :param join_parameters: parameters from previous/ current join - :type join_parameters: JoinParameters + :param related: string with join definition + :type related: str :return: list of used aliases, select from, list of aliased columns, sort orders :rtype: Tuple[List[str], Join, List[TextClause], collections.OrderedDict] """ - fields = self.fields - exclude_fields = self.exclude_fields + target_field = self.main_model.Meta.model_fields[related] + prev_model = self.main_model + # TODO: Finish refactoring here + if issubclass(target_field, ManyToManyField): + new_part = self.process_m2m_related_name_change( + target_field=target_field, related=related + ) + self._replace_many_to_many_order_by_columns(related, new_part) - for index, part in enumerate(item.split("__")): - if issubclass( - join_parameters.model_cls.Meta.model_fields[part], ManyToManyField + model_cls = target_field.through + alias = self.alias_manager.resolve_relation_alias( + from_model=prev_model, relation_name=related + ) + if alias not in self.used_aliases: + self._process_join( + model_cls=model_cls, + related=related, + alias=alias, + target_field=target_field, + ) + related = new_part + self.own_alias = alias + prev_model = model_cls + target_field = target_field.through.Meta.model_fields[related] + + model_cls = target_field.to + alias = model_cls.Meta.alias_manager.resolve_relation_alias( + from_model=prev_model, relation_name=related + ) + if alias not in self.used_aliases: + self._process_join( + model_cls=model_cls, + prev_model=prev_model, + related=related, + alias=alias, + target_field=target_field, + ) + + for related_name in self.related_models: + remainder = None + if ( + isinstance(self.related_models, dict) + and self.related_models[related_name] ): - _fields = join_parameters.model_cls.Meta.model_fields - target_field = _fields[part] - if ( - target_field.self_reference - and part == target_field.self_reference_primary - ): - new_part = target_field.default_source_field_name() # type: ignore - else: - new_part = target_field.default_target_field_name() # type: ignore - self._switch_many_to_many_order_columns(part, new_part) - if index > 0: # nested joins - fields, exclude_fields = SqlJoin.update_inclusions( - model_cls=join_parameters.model_cls, - fields=fields, - exclude_fields=exclude_fields, - nested_name=part, - ) - - join_parameters = self._build_join_parameters( - part=part, - join_params=join_parameters, - is_multi=True, - fields=fields, - exclude_fields=exclude_fields, - ) - part = new_part - - if index > 0: # nested joins - fields, exclude_fields = SqlJoin.update_inclusions( - model_cls=join_parameters.model_cls, - fields=fields, - exclude_fields=exclude_fields, - nested_name=part, - ) - join_parameters = self._build_join_parameters( - part=part, - join_params=join_parameters, - fields=fields, - exclude_fields=exclude_fields, + remainder = self.related_models[related_name] + self.process_deeper_join( + related_name=related_name, + model_cls=model_cls, + remainder=remainder, + alias=alias, ) return ( @@ -182,65 +176,44 @@ class SqlJoin: self.sorted_orders, ) - def _build_join_parameters( - self, - part: str, - join_params: JoinParameters, - fields: Optional[Union[Set, Dict]], - exclude_fields: Optional[Union[Set, Dict]], - is_multi: bool = False, - ) -> JoinParameters: + @staticmethod + def process_m2m_related_name_change( + target_field: Type[ManyToManyField], related: str, reverse: bool = False + ) -> str: """ - Updates used_aliases to not join multiple times to the same table. - Updates join parameters with new values. + Extracts relation name to link join through the Through model declared on + relation field. - :param part: part of the join str definition - :type part: str - :param join_params: parameters from previous/ current join - :type join_params: JoinParameters - :param fields: fields to include - :type fields: Optional[Union[Set, Dict]] - :param exclude_fields: fields to exclude - :type exclude_fields: Optional[Union[Set, Dict]] - :param is_multi: flag if the relation is m2m - :type is_multi: bool - :return: updated join parameters - :rtype: ormar.queryset.join.JoinParameters + Changes the same names in order_by queries if they are present. + + :param reverse: flag if it's on_clause lookup - use reverse fields + :type reverse: bool + :param target_field: relation field + :type target_field: Type[ManyToManyField] + :param related: name of the relation + :type related: str + :return: new relation name switched to through model field + :rtype: str """ - if is_multi: - model_cls = join_params.model_cls.Meta.model_fields[part].through - else: - model_cls = join_params.model_cls.Meta.model_fields[part].to - to_table = model_cls.Meta.table.name - - alias = model_cls.Meta.alias_manager.resolve_relation_alias( - join_params.prev_model, part + is_primary_self_ref = ( + target_field.self_reference + and related == target_field.self_reference_primary ) - if alias not in self.used_aliases: - self._process_join( - join_params=join_params, - is_multi=is_multi, - model_cls=model_cls, - part=part, - alias=alias, - fields=fields, - exclude_fields=exclude_fields, - ) - - previous_alias = alias - from_table = to_table - prev_model = model_cls - return JoinParameters(prev_model, previous_alias, from_table, model_cls) + if (is_primary_self_ref and not reverse) or ( + not is_primary_self_ref and reverse + ): + new_part = target_field.default_source_field_name() # type: ignore + else: + new_part = target_field.default_target_field_name() # type: ignore + return new_part def _process_join( # noqa: CFQ002 self, - join_params: JoinParameters, - is_multi: bool, model_cls: Type["Model"], - part: str, + related: str, alias: str, - fields: Optional[Union[Set, Dict]], - exclude_fields: Optional[Union[Set, Dict]], + target_field: Type[BaseField], + prev_model: Type["Model"] = None, ) -> None: """ Resolves to and from column names and table names. @@ -255,63 +228,53 @@ class SqlJoin: Process order_by causes for non m2m relations. - :param join_params: parameters from previous/ current join - :type join_params: JoinParameters - :param is_multi: flag if it's m2m relation - :type is_multi: bool :param model_cls: :type model_cls: ormar.models.metaclass.ModelMetaclass - :param part: name of the field used in join - :type part: str + :param related: name of the field used in join + :type related: str :param alias: alias of the current join :type alias: str - :param fields: fields to include - :type fields: Optional[Union[Set, Dict]] - :param exclude_fields: fields to exclude - :type exclude_fields: Optional[Union[Set, Dict]] """ to_table = model_cls.Meta.table.name - to_key, from_key = self.get_to_and_from_keys( - join_params, is_multi, model_cls, part - ) + to_key, from_key = self.get_to_and_from_keys(related, target_field) + + prev_model = prev_model or self.main_model on_clause = self.on_clause( - previous_alias=join_params.previous_alias, + previous_alias=self.own_alias, alias=alias, - from_clause=f"{join_params.from_table}.{from_key}", + from_clause=f"{prev_model.Meta.tablename}.{from_key}", to_clause=f"{to_table}.{to_key}", ) - target_table = self.alias_manager(model_cls).prefixed_table_name( - alias, to_table - ) + target_table = self.alias_manager.prefixed_table_name(alias, to_table) self.select_from = sqlalchemy.sql.outerjoin( self.select_from, target_table, on_clause ) pkname_alias = model_cls.get_column_alias(model_cls.Meta.pkname) - if not is_multi: + if not issubclass(target_field, ManyToManyField): self.get_order_bys( alias=alias, to_table=to_table, pkname_alias=pkname_alias, - part=part, + part=related, model_cls=model_cls, ) self_related_fields = model_cls.own_table_columns( model=model_cls, - fields=fields, - exclude_fields=exclude_fields, + fields=self.fields, + exclude_fields=self.exclude_fields, use_alias=True, ) self.columns.extend( - self.alias_manager(model_cls).prefixed_columns( + self.alias_manager.prefixed_columns( alias, model_cls.Meta.table, self_related_fields ) ) self.used_aliases.append(alias) - def _switch_many_to_many_order_columns(self, part: str, new_part: str) -> None: + def _replace_many_to_many_order_by_columns(self, part: str, new_part: str) -> None: """ Substitutes the name of the relation with actual model name in m2m order bys. @@ -325,7 +288,7 @@ class SqlJoin: x.split("__") for x in self.order_columns if "__" in x ] for condition in split_order_columns: - if condition[-2] == part or condition[-2][1:] == part: + if self._check_if_condition_apply(condition, part): condition[-2] = condition[-2].replace(part, new_part) self.order_columns = [x for x in self.order_columns if "__" not in x] + [ "__".join(x) for x in split_order_columns @@ -413,51 +376,34 @@ class SqlJoin: order = text(f"{alias}_{to_table}.{pkname_alias}") self.sorted_orders[f"{alias}.{pkname_alias}"] = order - @staticmethod def get_to_and_from_keys( - join_params: JoinParameters, - is_multi: bool, - model_cls: Type["Model"], - part: str, + self, related: str, target_field: Type[BaseField] ) -> Tuple[str, str]: """ Based on the relation type, name of the relation and previous models and parts stored in JoinParameters it resolves the current to and from keys, which are - different for ManyToMany relation, ForeignKey and reverse part of relations. + different for ManyToMany relation, ForeignKey and reverse related of relations. - :param join_params: parameters from previous/ current join - :type join_params: JoinParameters - :param is_multi: flag if the relation is of m2m type - :type is_multi: bool - :param model_cls: ormar model class - :type model_cls: Type[Model] - :param part: name of the current relation join - :type part: str + :param target_field: relation field + :type target_field: Type[ForeignKeyField] + :param related: name of the current relation join + :type related: str :return: to key and from key :rtype: Tuple[str, str] """ - if is_multi: - target_field = join_params.model_cls.Meta.model_fields[part] - if ( - target_field.self_reference - and part == target_field.self_reference_primary - ): - to_key = target_field.default_target_field_name() # type: ignore - else: - to_key = target_field.default_source_field_name() # type: ignore - from_key = join_params.prev_model.get_column_alias( - join_params.prev_model.Meta.pkname + if issubclass(target_field, ManyToManyField): + to_key = self.process_m2m_related_name_change( + target_field=target_field, related=related, reverse=True ) + from_key = self.main_model.get_column_alias(self.main_model.Meta.pkname) - elif join_params.prev_model.Meta.model_fields[part].virtual: - to_field = join_params.prev_model.Meta.model_fields[part].get_related_name() - to_key = model_cls.get_column_alias(to_field) - from_key = join_params.prev_model.get_column_alias( - join_params.prev_model.Meta.pkname - ) + elif target_field.virtual: + to_field = target_field.get_related_name() + to_key = target_field.to.get_column_alias(to_field) + from_key = self.main_model.get_column_alias(self.main_model.Meta.pkname) else: - to_key = model_cls.get_column_alias(model_cls.Meta.pkname) - from_key = join_params.prev_model.get_column_alias(part) + to_key = target_field.to.get_column_alias(target_field.to.Meta.pkname) + from_key = self.main_model.get_column_alias(related) return to_key, from_key diff --git a/ormar/queryset/prefetch_query.py b/ormar/queryset/prefetch_query.py index dda3baa..96de967 100644 --- a/ormar/queryset/prefetch_query.py +++ b/ormar/queryset/prefetch_query.py @@ -526,7 +526,7 @@ class PrefetchQuery: query_target = target_field.through select_related = [target_name] table_prefix = target_field.to.Meta.alias_manager.resolve_relation_alias( - query_target, target_name + from_model=query_target, relation_name=target_name ) self.already_extracted.setdefault(target_name, {})["prefix"] = table_prefix @@ -551,14 +551,14 @@ class PrefetchQuery: @staticmethod def _get_select_related_if_apply(related: str, select_dict: Dict) -> Dict: """ - Extract nested part of select_related dictionary to extract models nested + Extract nested related of select_related dictionary to extract models nested deeper on related model and already loaded in select related query. :param related: name of the relation :type related: str :param select_dict: dictionary of select related models in main query :type select_dict: Dict - :return: dictionary with nested part of select related + :return: dictionary with nested related of select related :rtype: Dict """ return ( diff --git a/ormar/queryset/query.py b/ormar/queryset/query.py index 64b9ede..733a1b0 100644 --- a/ormar/queryset/query.py +++ b/ormar/queryset/query.py @@ -6,8 +6,9 @@ import sqlalchemy from sqlalchemy import text import ormar # noqa I100 +from ormar.models.helpers.models import group_related_list from ormar.queryset import FilterQuery, LimitQuery, OffsetQuery, OrderQuery -from ormar.queryset.join import JoinParameters, SqlJoin +from ormar.queryset.join import SqlJoin if TYPE_CHECKING: # pragma no cover from ormar import Model @@ -140,14 +141,16 @@ class Query: else: self.select_from = self.table + # TODO: Refactor to convert to nested dict like in from_row in model self._select_related.sort(key=lambda item: (item, -len(item))) + related_models = group_related_list(self._select_related) - for item in self._select_related: - join_parameters = JoinParameters( - self.model_cls, "", self.table.name, self.model_cls - ) - fields = self.model_cls.get_included(self.fields, item) - exclude_fields = self.model_cls.get_excluded(self.exclude_fields, item) + for related in related_models: + fields = self.model_cls.get_included(self.fields, related) + exclude_fields = self.model_cls.get_excluded(self.exclude_fields, related) + remainder = None + if isinstance(related_models, dict) and related_models[related]: + remainder = related_models[related] sql_join = SqlJoin( used_aliases=self.used_aliases, select_from=self.select_from, @@ -156,6 +159,8 @@ class Query: exclude_fields=exclude_fields, order_columns=self.order_columns, sorted_orders=self.sorted_orders, + main_model=self.model_cls, + related_models=remainder, ) ( @@ -163,14 +168,14 @@ class Query: self.select_from, self.columns, self.sorted_orders, - ) = sql_join.build_join(item, join_parameters) + ) = sql_join.build_join(related) expr = sqlalchemy.sql.select(self.columns) expr = expr.select_from(self.select_from) expr = self._apply_expression_modifiers(expr) - # print(expr.compile(compile_kwargs={"literal_binds": True})) + # print("\n", expr.compile(compile_kwargs={"literal_binds": True})) self._reset_query_parameters() return expr diff --git a/ormar/relations/alias_manager.py b/ormar/relations/alias_manager.py index 7f7fd7c..0484007 100644 --- a/ormar/relations/alias_manager.py +++ b/ormar/relations/alias_manager.py @@ -113,6 +113,19 @@ class AliasManager: if child_key not in self._aliases_new: self._aliases_new[child_key] = get_table_alias() + def add_alias(self, alias_key: str) -> str: + """ + Adds alias to the dictionary of aliases under given key. + + :param alias_key: key of relation to generate alias for + :type alias_key: str + :return: generated alias + :rtype: str + """ + alias = get_table_alias() + self._aliases_new[alias_key] = alias + return alias + def resolve_relation_alias( self, from_model: Type["Model"], relation_name: str ) -> str: diff --git a/ormar/relations/relation_proxy.py b/ormar/relations/relation_proxy.py index 1012155..518fc71 100644 --- a/ormar/relations/relation_proxy.py +++ b/ormar/relations/relation_proxy.py @@ -127,7 +127,7 @@ class RelationProxy(list): self, item: "Model", keep_reversed: bool = True ) -> None: """ - Removes the item from relation with parent. + Removes the related from relation with parent. Through models are automatically deleted for m2m relations. diff --git a/tests/test_forward_refs.py b/tests/test_forward_refs.py index be355cf..f002ef1 100644 --- a/tests/test_forward_refs.py +++ b/tests/test_forward_refs.py @@ -190,7 +190,7 @@ async def test_m2m_self_forwardref_relation(cleanup): # await steve.friends.add(billy) billy_check = await Child.objects.select_related( - ["friends", "favourite_game", "least_favourite_game",] + ["friends", "favourite_game", "least_favourite_game"] ).get(name="Billy") assert len(billy_check.friends) == 2 assert billy_check.friends[0].name == "Kate" @@ -200,5 +200,6 @@ async def test_m2m_self_forwardref_relation(cleanup): kate_check = await Child.objects.select_related(["also_friends",]).get( name="Kate" ) + assert len(kate_check.also_friends) == 1 assert kate_check.also_friends[0].name == "Billy" diff --git a/tests/test_order_by.py b/tests/test_order_by.py index 07d5526..02639ca 100644 --- a/tests/test_order_by.py +++ b/tests/test_order_by.py @@ -280,7 +280,7 @@ async def test_sort_order_on_many_to_many(): assert users[1].cars[3].name == "Buggy" users = ( - await User.objects.select_related(["cars", "cars__factory"]) + await User.objects.select_related(["cars__factory"]) .order_by(["-cars__factory__name", "cars__name"]) .all() ) diff --git a/tests/test_selecting_subset_of_columns.py b/tests/test_selecting_subset_of_columns.py index e3221dd..a2d57db 100644 --- a/tests/test_selecting_subset_of_columns.py +++ b/tests/test_selecting_subset_of_columns.py @@ -116,9 +116,7 @@ async def test_selecting_subset(): ) all_cars = ( - await Car.objects.select_related( - ["manufacturer", "manufacturer__hq", "manufacturer__hq__nicks"] - ) + await Car.objects.select_related(["manufacturer__hq__nicks"]) .fields( [ "id", @@ -132,9 +130,7 @@ async def test_selecting_subset(): ) all_cars2 = ( - await Car.objects.select_related( - ["manufacturer", "manufacturer__hq", "manufacturer__hq__nicks"] - ) + await Car.objects.select_related(["manufacturer__hq__nicks"]) .fields( { "id": ..., @@ -149,9 +145,7 @@ async def test_selecting_subset(): ) all_cars3 = ( - await Car.objects.select_related( - ["manufacturer", "manufacturer__hq", "manufacturer__hq__nicks"] - ) + await Car.objects.select_related(["manufacturer__hq__nicks"]) .fields( { "id": ...,