diff --git a/docs/releases.md b/docs/releases.md index 2671b3d..5e61041 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,9 +29,10 @@ * you can include and exclude fields on through models * through models are attached only to related models (i.e. if you query from A to B -> only on B) * note that through models are explicitly loaded without relations -> relation is already populated in ManyToMany field. - * note that just like before you cannot declare the relation fields on through model, they will be populated for you by `ormar` + * note that just like before you cannot declare the relation fields on through model, they will be populated for you by `ormar`, + but now if you try to do so `ModelDefinitionError` will be thrown * check the updated ManyToMany relation docs for more information - + # Other * Updated docs and api docs * Refactors and optimisations mainly related to filters, exclusions and order bys diff --git a/ormar/fields/many_to_many.py b/ormar/fields/many_to_many.py index 0e7602e..ec8a6f1 100644 --- a/ormar/fields/many_to_many.py +++ b/ormar/fields/many_to_many.py @@ -3,6 +3,7 @@ from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union, cast from pydantic.typing import ForwardRef, evaluate_forwardref import ormar # noqa: I100 +from ormar import ModelDefinitionError from ormar.fields import BaseField from ormar.fields.foreign_key import ForeignKeyField @@ -17,6 +18,21 @@ if TYPE_CHECKING: # pragma no cover REF_PREFIX = "#/components/schemas/" +def forbid_through_relations(through: Type["Model"]) -> None: + """ + Verifies if the through model does not have relations. + + :param through: through Model to be checked + :type through: Type['Model] + """ + if any(field.is_relation for field in through.Meta.model_fields.values()): + raise ModelDefinitionError( + f"Through Models cannot have explicit relations " + f"defined. Remove the relations from Model " + f"{through.get_name(lower=False)}" + ) + + def populate_m2m_params_based_on_to_model( to: Type["Model"], nullable: bool ) -> Tuple[Any, Any]: @@ -77,6 +93,8 @@ def ManyToMany( nullable = kwargs.pop("nullable", True) owner = kwargs.pop("owner", None) self_reference = kwargs.pop("self_reference", False) + if through is not None and through.__class__ != ForwardRef: + forbid_through_relations(cast(Type["Model"], through)) if to.__class__ == ForwardRef: __type__ = to if not nullable else Optional[to] @@ -189,6 +207,7 @@ class ManyToManyField(ForeignKeyField, ormar.QuerySetProtocol, ormar.RelationPro globalns, localns or None, ) + forbid_through_relations(cls.through) @classmethod def get_relation_name(cls) -> str: diff --git a/tests/test_m2m_through_fields.py b/tests/test_m2m_through_fields.py index 4ed7023..ef9847c 100644 --- a/tests/test_m2m_through_fields.py +++ b/tests/test_m2m_through_fields.py @@ -375,25 +375,3 @@ async def test_excluding_fields_on_through_model() -> Any: for category in post3.categories: assert category.postcategory.param_name is None assert category.postcategory.sort_order is None - - -# TODO: check/ modify following - -# add to fields with class lower name (V) -# forward refs update (V) -# creating while adding to relation (kwargs in add) (V) -# creating in queryset proxy (dict with through name and kwargs) (V) -# loading the data into model instance of though model (V) <- fix fields ane exclude -# accessing from instance (V) <- no both sides only nested one is relevant, fix one side -# filtering in filter (through name normally) (V) < - table prefix from normal relation, -# check if is_through needed, resolved side of relation -# ordering by in order_by (V) -# updating in query (V) -# updating from querysetproxy (V) -# including/excluding in fields? (V) -# make through optional? auto-generated for cases other fields are missing? (V) - -# modifying from instance (both sides?) (X) <= no, the loaded one doesn't have relations -# allowing to change fk fields names in through model? (X) <= separate issue - -# prevent adding relation on through field definition diff --git a/tests/test_through_relations_fail.py b/tests/test_through_relations_fail.py new file mode 100644 index 0000000..472a8a1 --- /dev/null +++ b/tests/test_through_relations_fail.py @@ -0,0 +1,51 @@ +# type: ignore + +import databases +import pytest +import sqlalchemy + +import ormar +from ormar import ModelDefinitionError +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +def test_through_with_relation_fails(): + class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + class Category(ormar.Model): + class Meta(BaseMeta): + tablename = "categories" + + id = ormar.Integer(primary_key=True) + name = ormar.String(max_length=40) + + class Blog(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + + class PostCategory(ormar.Model): + class Meta(BaseMeta): + tablename = "posts_x_categories" + + id: int = ormar.Integer(primary_key=True) + sort_order: int = ormar.Integer(nullable=True) + param_name: str = ormar.String(default="Name", max_length=200) + blog = ormar.ForeignKey(Blog) + + with pytest.raises(ModelDefinitionError): + + class Post(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories = ormar.ManyToMany(Category, through=PostCategory)