fix bug with infinite relation auto extraction, finish initial relations docs

This commit is contained in:
collerek
2020-08-14 14:35:42 +02:00
parent 6b0cfdbfd3
commit 002f27f21e
4 changed files with 143 additions and 30 deletions

BIN
.coverage

Binary file not shown.

View File

@ -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. 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 ### Reverse Relation
At the same time the reverse relationship is registered automatically on parent model (target of `ForeignKey`). 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 ## 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, 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: Imagine that you have models as following:
@ -56,12 +64,15 @@ classes = await SchoolClass.objects.select_related(
["teachers__category", "students__category"]).all() ["teachers__category", "students__category"]).all()
``` ```
!!!tip
To query a chain of models use double underscores between the relation names (`ForeignKeys` or reverse `ForeignKeys`)
!!!note !!!note
To select related models use `select_related` method from `Model` `QuerySet`. To select related models use `select_related` method from `Model` `QuerySet`.
Note that you use relation (`ForeignKey`) names and not the table names. 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. 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 # will produce: True
``` ```
### Table aliases
You can even preview the alias used for any relation by passing two tables names. You can even preview the alias used for any relation by passing two tables names.
```python ```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. 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. 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

View File

@ -20,12 +20,12 @@ class JoinParameters(NamedTuple):
class Query: class Query:
def __init__( def __init__(
self, self,
model_cls: Type["Model"], model_cls: Type["Model"],
filter_clauses: List, filter_clauses: List,
select_related: List, select_related: List,
limit_count: int, limit_count: int,
offset: int, offset: int,
) -> None: ) -> None:
self.query_offset = offset self.query_offset = offset
@ -38,6 +38,7 @@ class Query:
self.auto_related = [] self.auto_related = []
self.used_aliases = [] self.used_aliases = []
self.already_checked = []
self.select_from = None self.select_from = None
self.columns = None self.columns = None
@ -50,11 +51,11 @@ class Query:
for key in self.model_cls.__model_fields__: for key in self.model_cls.__model_fields__:
if ( if (
not self.model_cls.__model_fields__[key].nullable not self.model_cls.__model_fields__[key].nullable
and isinstance( and isinstance(
self.model_cls.__model_fields__[key], orm.fields.ForeignKey, self.model_cls.__model_fields__[key], orm.fields.ForeignKey,
) )
and key not in self._select_related and key not in self._select_related
): ):
self._select_related = [key] + self._select_related self._select_related = [key] + self._select_related
@ -96,32 +97,32 @@ class Query:
@staticmethod @staticmethod
def _field_is_a_foreign_key_and_no_circular_reference( 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: ) -> bool:
return isinstance(field, ForeignKey) and field_name not in rel_part return isinstance(field, ForeignKey) and field_name not in rel_part
def _field_qualifies_to_deeper_search( 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: ) -> bool:
prev_part_of_related = "__".join(rel_part.split("__")[:-1]) prev_part_of_related = "__".join(rel_part.split("__")[:-1])
partial_match = any( partial_match = any(
[x.startswith(prev_part_of_related) for x in self._select_related] [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 ( return (
(field.virtual and parent_virtual) (field.virtual and parent_virtual)
or (partial_match and not already_checked) or (partial_match and not already_checked)
) or not nested ) or not nested
def on_clause( 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: ) -> text:
left_part = f"{alias}_{to_clause}" left_part = f"{alias}_{to_clause}"
right_part = f"{previous_alias + '_' if previous_alias else ''}{from_clause}" right_part = f"{previous_alias + '_' if previous_alias else ''}{from_clause}"
return text(f"{left_part}={right_part}") return text(f"{left_part}={right_part}")
def _build_join_parameters( def _build_join_parameters(
self, part: str, join_params: JoinParameters self, part: str, join_params: JoinParameters
) -> JoinParameters: ) -> JoinParameters:
model_cls = join_params.model_cls.__model_fields__[part].to model_cls = join_params.model_cls.__model_fields__[part].to
to_table = model_cls.__table__.name to_table = model_cls.__table__.name
@ -164,15 +165,15 @@ class Query:
return JoinParameters(prev_model, previous_alias, from_table, model_cls) return JoinParameters(prev_model, previous_alias, from_table, model_cls)
def _extract_auto_required_relations( def _extract_auto_required_relations(
self, self,
prev_model: Type["Model"], prev_model: Type["Model"],
rel_part: str = "", rel_part: str = "",
nested: bool = False, nested: bool = False,
parent_virtual: bool = False, parent_virtual: bool = False,
) -> None: ) -> None:
for field_name, field in prev_model.__model_fields__.items(): for field_name, field in prev_model.__model_fields__.items():
if self._field_is_a_foreign_key_and_no_circular_reference( 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 rel_part = field_name if not rel_part else rel_part + "__" + field_name
if not field.nullable: if not field.nullable:
@ -180,7 +181,7 @@ class Query:
self.auto_related.append("__".join(rel_part.split("__")[:-1])) self.auto_related.append("__".join(rel_part.split("__")[:-1]))
rel_part = "" rel_part = ""
elif self._field_qualifies_to_deeper_search( elif self._field_qualifies_to_deeper_search(
field, parent_virtual, nested, rel_part field, parent_virtual, nested, rel_part
): ):
self._extract_auto_required_relations( self._extract_auto_required_relations(
prev_model=field.to, prev_model=field.to,
@ -189,6 +190,7 @@ class Query:
parent_virtual=field.virtual, parent_virtual=field.virtual,
) )
else: else:
self.already_checked.append(rel_part)
rel_part = "" rel_part = ""
def _include_auto_related_models(self) -> None: def _include_auto_related_models(self) -> None:
@ -200,7 +202,7 @@ class Query:
self._select_related = new_joins + self.auto_related self._select_related = new_joins + self.auto_related
def _apply_expression_modifiers( def _apply_expression_modifiers(
self, expr: sqlalchemy.sql.select self, expr: sqlalchemy.sql.select
) -> sqlalchemy.sql.select: ) -> sqlalchemy.sql.select:
if self.filter_clauses: if self.filter_clauses:
if len(self.filter_clauses) == 1: if len(self.filter_clauses) == 1:
@ -225,3 +227,4 @@ class Query:
self.order_bys = None self.order_bys = None
self.auto_related = [] self.auto_related = []
self.used_aliases = [] self.used_aliases = []
self.already_checked = []

View File

@ -120,7 +120,7 @@ async def test_right_tables_join():
async def test_multiple_reverse_related_objects(): async def test_multiple_reverse_related_objects():
async with database: async with database:
classes = await SchoolClass.objects.select_related( classes = await SchoolClass.objects.select_related(
["teachers__category", "students"] ["teachers__category", "students__category"]
).all() ).all()
assert classes[0].name == "Math" assert classes[0].name == "Math"
assert classes[0].students[1].name == "Jack" assert classes[0].students[1].name == "Jack"