diff --git a/.coverage b/.coverage index 3afbc70..d661345 100644 Binary files a/.coverage and b/.coverage differ diff --git a/docs/relations.md b/docs/relations.md index 2fbe479..c671252 100644 --- a/docs/relations.md +++ b/docs/relations.md @@ -19,6 +19,9 @@ It automatically creates an sql foreign key constraint on a underlying table as Of course it's handled for you so you don't have to delve deep into this but you can. +!!!tip + Note how by default the relation is optional, you can require the related `Model` by setting `nullable=False` on the `ForeignKey` field. + ### Reverse Relation At the same time the reverse relationship is registered automatically on parent model (target of `ForeignKey`). @@ -40,8 +43,13 @@ But you can overwrite this name by providing `related_name` parameter like below ## Relationship Manager +!!!tip + This section is more technical so you might want to skip it if you are not interested in implementation details. + +### Need for a manager? + Since orm uses Sqlalchemy core under the hood to prepare the queries, -the orm needs a way to uniquely identify each relationship between to tables to construct working queries. +the orm needs a way to uniquely identify each relationship between the tables to construct working queries. Imagine that you have models as following: @@ -56,12 +64,15 @@ classes = await SchoolClass.objects.select_related( ["teachers__category", "students__category"]).all() ``` +!!!tip + To query a chain of models use double underscores between the relation names (`ForeignKeys` or reverse `ForeignKeys`) + !!!note To select related models use `select_related` method from `Model` `QuerySet`. Note that you use relation (`ForeignKey`) names and not the table names. -Since you join two times to the same table it won't work by default -> you would need to use aliases for category tables and columns. +Since you join two times to the same table (categories) it won't work by default -> you would need to use aliases for category tables and columns. But don't worry - orm can handle situations like this, as it uses the Relationship Manager which has it's aliases defined for all relationships. @@ -78,6 +89,8 @@ print(Teacher._orm_relationship_manager == Student._orm_relationship_manager) # will produce: True ``` +### Table aliases + You can even preview the alias used for any relation by passing two tables names. ```python @@ -94,3 +107,100 @@ print(Teacher._orm_relationship_manager.resolve_relation_join( The order that you pass the names matters -> as those are 2 different relationships depending on join order. As aliases are produced randomly you can be presented with different results. + +### Query automatic construction + +Orm is using those aliases during queries to both construct a meaningful and valid sql, +as well as later use it to extract proper columns for proper nested models. + +Running a previously mentioned query to select school classes and related teachers and students: + +```Python +classes = await SchoolClass.objects.select_related( +["teachers__category", "students__category"]).all() +``` + +Will result in a query like this (run under the hood): + +```sql +SELECT schoolclasses.id, + schoolclasses.name, + schoolclasses.department, + NZc8e2_students.id as NZc8e2_id, + NZc8e2_students.name as NZc8e2_name, + NZc8e2_students.schoolclass as NZc8e2_schoolclass, + NZc8e2_students.category as NZc8e2_category, + MYfe53_categories.id as MYfe53_id, + MYfe53_categories.name as MYfe53_name, + WA49a3_teachers.id as WA49a3_id, + WA49a3_teachers.name as WA49a3_name, + WA49a3_teachers.schoolclass as WA49a3_schoolclass, + WA49a3_teachers.category as WA49a3_category, + WZa13b_categories.id as WZa13b_id, + WZa13b_categories.name as WZa13b_name +FROM schoolclasses + LEFT OUTER JOIN students NZc8e2_students ON NZc8e2_students.schoolclass = schoolclasses.id + LEFT OUTER JOIN categories MYfe53_categories ON MYfe53_categories.id = NZc8e2_students.category + LEFT OUTER JOIN teachers WA49a3_teachers ON WA49a3_teachers.schoolclass = schoolclasses.id + LEFT OUTER JOIN categories WZa13b_categories ON WZa13b_categories.id = WA49a3_teachers.category +ORDER BY schoolclasses.id, NZc8e2_students.id, MYfe53_categories.id, WA49a3_teachers.id, WZa13b_categories.id +``` + +!!!note + As mentioned before the aliases are produced dynamically so the actual result might differ. + + Note that aliases are assigned to relations and not the tables, therefore the first table is always without an alias. + +### Returning related Models + +Each object in Relationship Manager is identified by orm_id which you can preview like this + +```python +category = Category(name='Math') +print(category._orm_id) +# will produce: c76046d9410c4582a656bf12a44c892c (sample value) +``` + +Each call to related `Model` is actually coming through the Manager which stores all +the relations in a dictionary and returns related `Models` by relation type (name) and by object _orm_id. + +Since we register both sides of the relation the side registering the relation +is always registering the other side as concrete model, +while the reverse relation is a weakref.proxy to avoid circular references. + +Sounds complicated but in reality it means something like this: + +```python +test_class = await SchoolClass.objects.create(name='Test') +student = await Student.objects.create(name='John', schoolclass=test_class) +# the relation to schoolsclass from student (i.e. when you call student.schoolclass) +# is a concrete one, meaning directy relating the schoolclass `Model` object +# On the other side calling test_class.students will result in a list of wekref.proxy objects +``` + +!!!tip + To learn more about queries and available methods please review [queries][queries] section. + +All relations are kept in lists, meaning that when you access related object the Relationship Manager is +searching itself for related models and get a list of them. + +But since child to parent relation is a many to one type, +the Manager is unpacking the first (and only) related model from a list and you get an actual `Model` instance instead of a list. + +Coming from parent to child relation (one to many) you always get a list of results. + +Translating this into concrete sample, the same as above: + +```python +test_class = await SchoolClass.objects.create(name='Test') +student = await Student.objects.create(name='John', schoolclass=test_class) + +student.schoolclass # return a test_class instance extracted from relationship list +test_class.students # return a list of related wekref.proxy refering related students `Models` + +``` + +!!!tip + You can preview all relations currently registered by accessing Relationship Manager on any class/instance `Student._orm_relationship_manager._relations` + +[queries]: ./queries.md \ No newline at end of file diff --git a/orm/queryset/query.py b/orm/queryset/query.py index 133d77f..75f41f3 100644 --- a/orm/queryset/query.py +++ b/orm/queryset/query.py @@ -20,12 +20,12 @@ class JoinParameters(NamedTuple): class Query: def __init__( - self, - model_cls: Type["Model"], - filter_clauses: List, - select_related: List, - limit_count: int, - offset: int, + self, + model_cls: Type["Model"], + filter_clauses: List, + select_related: List, + limit_count: int, + offset: int, ) -> None: self.query_offset = offset @@ -38,6 +38,7 @@ class Query: self.auto_related = [] self.used_aliases = [] + self.already_checked = [] self.select_from = None self.columns = None @@ -50,11 +51,11 @@ class Query: for key in self.model_cls.__model_fields__: if ( - not self.model_cls.__model_fields__[key].nullable - and isinstance( - self.model_cls.__model_fields__[key], orm.fields.ForeignKey, - ) - and key not in self._select_related + not self.model_cls.__model_fields__[key].nullable + and isinstance( + self.model_cls.__model_fields__[key], orm.fields.ForeignKey, + ) + and key not in self._select_related ): self._select_related = [key] + self._select_related @@ -96,32 +97,32 @@ class Query: @staticmethod def _field_is_a_foreign_key_and_no_circular_reference( - field: BaseField, field_name: str, rel_part: str + field: BaseField, field_name: str, rel_part: str ) -> bool: return isinstance(field, ForeignKey) and field_name not in rel_part def _field_qualifies_to_deeper_search( - self, field: ForeignKey, parent_virtual: bool, nested: bool, rel_part: str + self, field: ForeignKey, parent_virtual: bool, nested: bool, rel_part: str ) -> bool: prev_part_of_related = "__".join(rel_part.split("__")[:-1]) partial_match = any( [x.startswith(prev_part_of_related) for x in self._select_related] ) - already_checked = any([x.startswith(rel_part) for x in self.auto_related]) + already_checked = any([x.startswith(rel_part) for x in (self.auto_related + self.already_checked)]) return ( - (field.virtual and parent_virtual) - or (partial_match and not already_checked) - ) or not nested + (field.virtual and parent_virtual) + or (partial_match and not already_checked) + ) or not nested def on_clause( - self, previous_alias: str, alias: str, from_clause: str, to_clause: str, + self, previous_alias: str, alias: str, from_clause: str, to_clause: str, ) -> text: left_part = f"{alias}_{to_clause}" right_part = f"{previous_alias + '_' if previous_alias else ''}{from_clause}" return text(f"{left_part}={right_part}") def _build_join_parameters( - self, part: str, join_params: JoinParameters + self, part: str, join_params: JoinParameters ) -> JoinParameters: model_cls = join_params.model_cls.__model_fields__[part].to to_table = model_cls.__table__.name @@ -164,15 +165,15 @@ class Query: return JoinParameters(prev_model, previous_alias, from_table, model_cls) def _extract_auto_required_relations( - self, - prev_model: Type["Model"], - rel_part: str = "", - nested: bool = False, - parent_virtual: bool = False, + self, + prev_model: Type["Model"], + rel_part: str = "", + nested: bool = False, + parent_virtual: bool = False, ) -> None: for field_name, field in prev_model.__model_fields__.items(): if self._field_is_a_foreign_key_and_no_circular_reference( - field, field_name, rel_part + field, field_name, rel_part ): rel_part = field_name if not rel_part else rel_part + "__" + field_name if not field.nullable: @@ -180,7 +181,7 @@ class Query: self.auto_related.append("__".join(rel_part.split("__")[:-1])) rel_part = "" elif self._field_qualifies_to_deeper_search( - field, parent_virtual, nested, rel_part + field, parent_virtual, nested, rel_part ): self._extract_auto_required_relations( prev_model=field.to, @@ -189,6 +190,7 @@ class Query: parent_virtual=field.virtual, ) else: + self.already_checked.append(rel_part) rel_part = "" def _include_auto_related_models(self) -> None: @@ -200,7 +202,7 @@ class Query: self._select_related = new_joins + self.auto_related def _apply_expression_modifiers( - self, expr: sqlalchemy.sql.select + self, expr: sqlalchemy.sql.select ) -> sqlalchemy.sql.select: if self.filter_clauses: if len(self.filter_clauses) == 1: @@ -225,3 +227,4 @@ class Query: self.order_bys = None self.auto_related = [] self.used_aliases = [] + self.already_checked = [] diff --git a/tests/test_same_table_joins.py b/tests/test_same_table_joins.py index 4c27446..b1dd02a 100644 --- a/tests/test_same_table_joins.py +++ b/tests/test_same_table_joins.py @@ -120,7 +120,7 @@ async def test_right_tables_join(): async def test_multiple_reverse_related_objects(): async with database: classes = await SchoolClass.objects.select_related( - ["teachers__category", "students"] + ["teachers__category", "students__category"] ).all() assert classes[0].name == "Math" assert classes[0].students[1].name == "Jack"