From 15e12ef55b73cd33f9cc194cfa4b8d4b6d91bcc5 Mon Sep 17 00:00:00 2001 From: collerek Date: Fri, 16 Apr 2021 16:27:07 +0200 Subject: [PATCH] allow customization of through model relation names --- docs/relations/many-to-many.md | 67 ++++++++++++++- docs/releases.md | 54 ++++++++++++ ormar/fields/base.py | 6 ++ ormar/fields/foreign_key.py | 18 ++-- ormar/fields/many_to_many.py | 5 ++ ormar/models/helpers/relations.py | 2 + ...ustomizing_through_model_relation_names.py | 84 +++++++++++++++++++ 7 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 tests/test_relations/test_customizing_through_model_relation_names.py diff --git a/docs/relations/many-to-many.md b/docs/relations/many-to-many.md index 414f0df..be48cdb 100644 --- a/docs/relations/many-to-many.md +++ b/docs/relations/many-to-many.md @@ -161,7 +161,72 @@ The default naming convention is: it would be `PostCategory` * 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` - + +### 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 The through field is auto added to the reverse side of the relation. diff --git a/docs/releases.md b/docs/releases.md index 667f170..f2328cc 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -24,6 +24,60 @@ 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 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 diff --git a/ormar/fields/base.py b/ormar/fields/base.py index a86a500..c435ac6 100644 --- a/ormar/fields/base.py +++ b/ormar/fields/base.py @@ -53,6 +53,12 @@ class BaseField(FieldInfo): "is_relation", None ) # ForeignKeyField + subclasses 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_field: bool = kwargs.pop("skip_field", False) diff --git a/ormar/fields/foreign_key.py b/ormar/fields/foreign_key.py index fe7c812..feba37c 100644 --- a/ormar/fields/foreign_key.py +++ b/ormar/fields/foreign_key.py @@ -318,29 +318,23 @@ class ForeignKeyField(BaseField): """ 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. - :param reverse: flag to grab name without accessing related field - :type reverse: bool :return: name of the field :rtype: str """ - self_rel_prefix = "from_" if not reverse else "to_" - prefix = self_rel_prefix if self.self_reference else "" - return f"{prefix}{self.to.get_name()}" + prefix = "from_" if self.self_reference else "" + return self.through_reverse_relation_name or 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. - :param reverse: flag to grab name without accessing related field - :type reverse: bool :return: name of the field :rtype: str """ - self_rel_prefix = "to_" if not reverse else "from_" - prefix = self_rel_prefix if self.self_reference else "" - return f"{prefix}{self.owner.get_name()}" + prefix = "to_" if self.self_reference else "" + return self.through_relation_name or f"{prefix}{self.owner.get_name()}" def evaluate_forward_ref(self, globalns: Any, localns: Any) -> None: """ diff --git a/ormar/fields/many_to_many.py b/ormar/fields/many_to_many.py index a70f623..5f98fd9 100644 --- a/ormar/fields/many_to_many.py +++ b/ormar/fields/many_to_many.py @@ -122,6 +122,9 @@ def ManyToMany( skip_reverse = kwargs.pop("skip_reverse", 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: forbid_through_relations(cast(Type["Model"], through)) @@ -158,6 +161,8 @@ def ManyToMany( related_orders_by=related_orders_by, skip_reverse=skip_reverse, skip_field=skip_field, + through_relation_name=through_relation_name, + through_reverse_relation_name=through_reverse_relation_name, ) Field = type("ManyToMany", (ManyToManyField, BaseField), {}) diff --git a/ormar/models/helpers/relations.py b/ormar/models/helpers/relations.py index 39e74e3..558c0b3 100644 --- a/ormar/models/helpers/relations.py +++ b/ormar/models/helpers/relations.py @@ -112,6 +112,8 @@ def register_reverse_model_fields(model_field: "ForeignKeyField") -> None: self_reference_primary=model_field.self_reference_primary, orders_by=model_field.related_orders_by, 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 model_field = cast("ManyToManyField", model_field) diff --git a/tests/test_relations/test_customizing_through_model_relation_names.py b/tests/test_relations/test_customizing_through_model_relation_names.py new file mode 100644 index 0000000..ff99065 --- /dev/null +++ b/tests/test_relations/test_customizing_through_model_relation_names.py @@ -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"