fix bug with infinite relation auto extraction, finish initial relations docs
This commit is contained in:
@ -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
|
||||||
@ -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 = []
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user