allow customization of through model relation names

This commit is contained in:
collerek
2021-04-16 16:27:07 +02:00
parent 1c24ade8c8
commit 15e12ef55b
7 changed files with 223 additions and 13 deletions

View File

@ -162,6 +162,71 @@ The default naming convention is:
* for table name it similar but with underscore in between and s in the end of class * for table name it similar but with underscore in between and s in the end of class
lowercase name, in example above would be `posts_categorys` lowercase name, in example above would be `posts_categorys`
### Customizing Through relation names
By default `Through` model relation names default to related model name in lowercase.
So in example like this:
```python
... # course declaration ommited
class Student(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
courses = ormar.ManyToMany(Course)
# will produce default Through model like follows (example simplified)
class StudentCourse(ormar.Model):
class Meta:
database = database
metadata = metadata
tablename = "students_courses"
id: int = ormar.Integer(primary_key=True)
student = ormar.ForeignKey(Student) # default name
course = ormar.ForeignKey(Course) # default name
```
To customize the names of fields/relation in Through model now you can use new parameters to `ManyToMany`:
* `through_relation_name` - name of the field leading to the model in which `ManyToMany` is declared
* `through_reverse_relation_name` - name of the field leading to the model to which `ManyToMany` leads to
Example:
```python
... # course declaration ommited
class Student(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
courses = ormar.ManyToMany(Course,
through_relation_name="student_id",
through_reverse_relation_name="course_id")
# will produce Through model like follows (example simplified)
class StudentCourse(ormar.Model):
class Meta:
database = database
metadata = metadata
tablename = "students_courses"
id: int = ormar.Integer(primary_key=True)
student_id = ormar.ForeignKey(Student) # set by through_relation_name
course_id = ormar.ForeignKey(Course) # set by through_reverse_relation_name
```
!!!note
Note that explicitly declaring relations in Through model is forbidden, so even if you
provide your own custom Through model you cannot change the names there and you need to use
same `through_relation_name` and `through_reverse_relation_name` parameters.
## Through Fields ## Through Fields
The through field is auto added to the reverse side of the relation. The through field is auto added to the reverse side of the relation.

View File

@ -24,6 +24,60 @@
in those cases you don't have to split save into two calls (`save()` and `save_related()`) in those cases you don't have to split save into two calls (`save()` and `save_related()`)
* it supports also `ManyToMany` relations * it supports also `ManyToMany` relations
* it supports also optional `Through` model values for m2m relations * it supports also optional `Through` model values for m2m relations
* Add possibility to customize `Through` model relation field names.
* By default `Through` model relation names default to related model name in lowercase.
So in example like this:
```python
... # course declaration ommited
class Student(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
courses = ormar.ManyToMany(Course)
# will produce default Through model like follows (example simplified)
class StudentCourse(ormar.Model):
class Meta:
database = database
metadata = metadata
tablename = "students_courses"
id: int = ormar.Integer(primary_key=True)
student = ormar.ForeignKey(Student) # default name
course = ormar.ForeignKey(Course) # default name
```
* To customize the names of fields/relation in Through model now you can use new parameters to `ManyToMany`:
* `through_relation_name` - name of the field leading to the model in which `ManyToMany` is declared
* `through_reverse_relation_name` - name of the field leading to the model to which `ManyToMany` leads to
Example:
```python
... # course declaration ommited
class Student(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
courses = ormar.ManyToMany(Course,
through_relation_name="student_id",
through_reverse_relation_name="course_id")
# will produce default Through model like follows (example simplified)
class StudentCourse(ormar.Model):
class Meta:
database = database
metadata = metadata
tablename = "students_courses"
id: int = ormar.Integer(primary_key=True)
student_id = ormar.ForeignKey(Student) # set by through_relation_name
course_id = ormar.ForeignKey(Course) # set by through_reverse_relation_name
```
## 🐛 Fixes ## 🐛 Fixes

View File

@ -53,6 +53,12 @@ class BaseField(FieldInfo):
"is_relation", None "is_relation", None
) # ForeignKeyField + subclasses ) # ForeignKeyField + subclasses
self.is_through: bool = kwargs.pop("is_through", False) # ThroughFields self.is_through: bool = kwargs.pop("is_through", False) # ThroughFields
self.through_relation_name = kwargs.pop("through_relation_name", None)
self.through_reverse_relation_name = kwargs.pop(
"through_reverse_relation_name", None
)
self.skip_reverse: bool = kwargs.pop("skip_reverse", False) self.skip_reverse: bool = kwargs.pop("skip_reverse", False)
self.skip_field: bool = kwargs.pop("skip_field", False) self.skip_field: bool = kwargs.pop("skip_field", False)

View File

@ -318,29 +318,23 @@ class ForeignKeyField(BaseField):
""" """
return self.related_name or self.owner.get_name() + "s" return self.related_name or self.owner.get_name() + "s"
def default_target_field_name(self, reverse: bool = False) -> str: def default_target_field_name(self) -> str:
""" """
Returns default target model name on through model. Returns default target model name on through model.
:param reverse: flag to grab name without accessing related field
:type reverse: bool
:return: name of the field :return: name of the field
:rtype: str :rtype: str
""" """
self_rel_prefix = "from_" if not reverse else "to_" prefix = "from_" if self.self_reference else ""
prefix = self_rel_prefix if self.self_reference else "" return self.through_reverse_relation_name or f"{prefix}{self.to.get_name()}"
return f"{prefix}{self.to.get_name()}"
def default_source_field_name(self, reverse: bool = False) -> str: def default_source_field_name(self) -> str:
""" """
Returns default target model name on through model. Returns default target model name on through model.
:param reverse: flag to grab name without accessing related field
:type reverse: bool
:return: name of the field :return: name of the field
:rtype: str :rtype: str
""" """
self_rel_prefix = "to_" if not reverse else "from_" prefix = "to_" if self.self_reference else ""
prefix = self_rel_prefix if self.self_reference else "" return self.through_relation_name or f"{prefix}{self.owner.get_name()}"
return f"{prefix}{self.owner.get_name()}"
def evaluate_forward_ref(self, globalns: Any, localns: Any) -> None: def evaluate_forward_ref(self, globalns: Any, localns: Any) -> None:
""" """

View File

@ -122,6 +122,9 @@ def ManyToMany(
skip_reverse = kwargs.pop("skip_reverse", False) skip_reverse = kwargs.pop("skip_reverse", False)
skip_field = kwargs.pop("skip_field", False) skip_field = kwargs.pop("skip_field", False)
through_relation_name = kwargs.pop("through_relation_name", None)
through_reverse_relation_name = kwargs.pop("through_reverse_relation_name", None)
if through is not None and through.__class__ != ForwardRef: if through is not None and through.__class__ != ForwardRef:
forbid_through_relations(cast(Type["Model"], through)) forbid_through_relations(cast(Type["Model"], through))
@ -158,6 +161,8 @@ def ManyToMany(
related_orders_by=related_orders_by, related_orders_by=related_orders_by,
skip_reverse=skip_reverse, skip_reverse=skip_reverse,
skip_field=skip_field, skip_field=skip_field,
through_relation_name=through_relation_name,
through_reverse_relation_name=through_reverse_relation_name,
) )
Field = type("ManyToMany", (ManyToManyField, BaseField), {}) Field = type("ManyToMany", (ManyToManyField, BaseField), {})

View File

@ -112,6 +112,8 @@ def register_reverse_model_fields(model_field: "ForeignKeyField") -> None:
self_reference_primary=model_field.self_reference_primary, self_reference_primary=model_field.self_reference_primary,
orders_by=model_field.related_orders_by, orders_by=model_field.related_orders_by,
skip_field=model_field.skip_reverse, skip_field=model_field.skip_reverse,
through_relation_name=model_field.through_reverse_relation_name,
through_reverse_relation_name=model_field.through_relation_name,
) )
# register foreign keys on through model # register foreign keys on through model
model_field = cast("ManyToManyField", model_field) model_field = cast("ManyToManyField", model_field)

View File

@ -0,0 +1,84 @@
import databases
import pytest
import sqlalchemy
import ormar
from tests.settings import DATABASE_URL
metadata = sqlalchemy.MetaData()
database = databases.Database(DATABASE_URL, force_rollback=True)
class Course(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
course_name: str = ormar.String(max_length=100)
class Student(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
courses = ormar.ManyToMany(
Course,
through_relation_name="student_id",
through_reverse_relation_name="course_id",
)
# create db and tables
@pytest.fixture(autouse=True, scope="module")
def create_test_database():
engine = sqlalchemy.create_engine(DATABASE_URL)
metadata.create_all(engine)
yield
metadata.drop_all(engine)
def test_tables_columns():
through_meta = Student.Meta.model_fields["courses"].through.Meta
assert "course_id" in through_meta.table.c
assert "student_id" in through_meta.table.c
assert "course_id" in through_meta.model_fields
assert "student_id" in through_meta.model_fields
@pytest.mark.asyncio
async def test_working_with_changed_through_names():
async with database:
async with database.transaction(force_rollback=True):
to_save = {
"course_name": "basic1",
"students": [{"name": "Jack"}, {"name": "Abi"}],
}
await Course(**to_save).save_related(follow=True, save_all=True)
course_check = await Course.objects.select_related("students").get()
assert course_check.course_name == "basic1"
assert course_check.students[0].name == "Jack"
assert course_check.students[1].name == "Abi"
students = await course_check.students.all()
assert len(students) == 2
student = await course_check.students.get(name="Jack")
assert student.name == "Jack"
students = await Student.objects.select_related("courses").all(
courses__course_name="basic1"
)
assert len(students) == 2
course_check = (
await Course.objects.select_related("students")
.order_by("students__name")
.get()
)
assert course_check.students[0].name == "Abi"
assert course_check.students[1].name == "Jack"