From 8b554792d8545de5ca64edb82234f928d5396a4f Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 02:08:49 +0200 Subject: [PATCH 1/6] feat(get_pydantic): Add option to have related fields as ID/PositiveInt in the resulting pydantic model This can be useful when using get_pydantic() to create read or create models, as one probably doesn't want to have to specify the whole related model in a request in order to pass validation, see #343. --- ormar/models/mixins/pydantic_mixin.py | 33 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/ormar/models/mixins/pydantic_mixin.py b/ormar/models/mixins/pydantic_mixin.py index 5d186bb09..88a8d8637 100644 --- a/ormar/models/mixins/pydantic_mixin.py +++ b/ormar/models/mixins/pydantic_mixin.py @@ -2,13 +2,13 @@ import string from random import choices from typing import ( + TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, - TYPE_CHECKING, Type, Union, cast, @@ -32,7 +32,11 @@ class PydanticMixin(RelationMixin): @classmethod def get_pydantic( - cls, *, include: Union[Set, Dict] = None, exclude: Union[Set, Dict] = None + cls, + *, + include: Union[Set, Dict] = None, + exclude: Union[Set, Dict] = None, + fk_as_int: Union[Set, Dict] = None, ) -> Type[pydantic.BaseModel]: """ Returns a pydantic model out of ormar model. @@ -49,7 +53,10 @@ def get_pydantic( relation_map = translate_list_to_dict(cls._iterate_related_models()) return cls._convert_ormar_to_pydantic( - include=include, exclude=exclude, relation_map=relation_map + include=include, + exclude=exclude, + fk_as_int=fk_as_int, + relation_map=relation_map, ) @classmethod @@ -58,6 +65,7 @@ def _convert_ormar_to_pydantic( relation_map: Dict[str, Any], include: Union[Set, Dict] = None, exclude: Union[Set, Dict] = None, + fk_as_int: Union[Set, Dict] = None, ) -> Type[pydantic.BaseModel]: if include and isinstance(include, Set): include = translate_list_to_dict(include) @@ -82,6 +90,7 @@ def _convert_ormar_to_pydantic( defaults=defaults, include=include, exclude=exclude, + fk_as_int=fk_as_int, relation_map=relation_map, ) if field is not None: @@ -103,18 +112,22 @@ def _determine_pydantic_field_type( defaults: Dict, include: Union[Set, Dict, None], exclude: Union[Set, Dict, None], + fk_as_int: Union[Set, Dict, None], relation_map: Dict[str, Any], ) -> Any: field = cls.Meta.model_fields[name] target: Any = None if field.is_relation and name in relation_map: # type: ignore - target = field.to._convert_ormar_to_pydantic( - include=cls._skip_ellipsis(include, name), - exclude=cls._skip_ellipsis(exclude, name), - relation_map=cls._skip_ellipsis( - relation_map, name, default_return=dict() - ), - ) + if fk_as_int and name in fk_as_int: + target = pydantic.PositiveInt + else: + target = field.to._convert_ormar_to_pydantic( + include=cls._skip_ellipsis(include, name), + exclude=cls._skip_ellipsis(exclude, name), + relation_map=cls._skip_ellipsis( + relation_map, name, default_return=dict() + ), + ) if field.is_multi or field.virtual: target = List[target] # type: ignore elif not field.is_relation: From d1eede1a07e6b7ed6ba19cf77300f483a911899f Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 02:11:35 +0200 Subject: [PATCH 2/6] test: Add test for fk_as_int option More specific testing would have required new models, which I frankly was too lazy to implement. Also fixed a typo in the file name. --- tests/test_fastapi/test_excludes_with_get_pydantic.py | 2 +- ...ntic_models.py => test_getting_pydantic_models.py} | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) rename tests/test_inheritance_and_pydantic_generation/{test_geting_pydantic_models.py => test_getting_pydantic_models.py} (95%) diff --git a/tests/test_fastapi/test_excludes_with_get_pydantic.py b/tests/test_fastapi/test_excludes_with_get_pydantic.py index b18976aa0..2938d4395 100644 --- a/tests/test_fastapi/test_excludes_with_get_pydantic.py +++ b/tests/test_fastapi/test_excludes_with_get_pydantic.py @@ -4,7 +4,7 @@ from starlette.testclient import TestClient from tests.settings import DATABASE_URL -from tests.test_inheritance_and_pydantic_generation.test_geting_pydantic_models import ( +from tests.test_inheritance_and_pydantic_generation.test_getting_pydantic_models import ( Category, SelfRef, database, diff --git a/tests/test_inheritance_and_pydantic_generation/test_geting_pydantic_models.py b/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py similarity index 95% rename from tests/test_inheritance_and_pydantic_generation/test_geting_pydantic_models.py rename to tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py index 146c87f0b..e7f1336f3 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_geting_pydantic_models.py +++ b/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py @@ -3,7 +3,7 @@ import databases import pydantic import sqlalchemy -from pydantic import ConstrainedStr +from pydantic import ConstrainedStr, PositiveInt from pydantic.typing import ForwardRef import ormar @@ -167,6 +167,15 @@ def test_getting_pydantic_model_exclude_dict(): assert "name" not in PydanticCategory.__fields__ +def test_getting_pydantic_model_fk_as_int(): + PydanticItem = Item.get_pydantic( + include={"category", "name"}, fk_as_int={"category", "name"} + ) + assert len(PydanticItem.__fields__) == 2 + assert PydanticItem.__fields__["category"].type_ == PositiveInt + assert PydanticItem.__fields__["name"].type_ != PositiveInt + + def test_getting_pydantic_model_self_ref(): PydanticSelfRef = SelfRef.get_pydantic() assert len(PydanticSelfRef.__fields__) == 4 From b3cffc27aa0724e78089f8692401b0c45fed3cd6 Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 17:16:13 +0200 Subject: [PATCH 3/6] feat(get_pydantic): Add support for (once) nested fk_as_int This way "fk_as_int={"item__category"} should generate a nested item model in which the related category is not a nested model but an ID/PositiveInt. I tried to allow for infinite nesting in fk_as_int but didn't manage to implement it, in fact idk if it's even possible to do with the current relation algorithm. --- ormar/models/mixins/pydantic_mixin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ormar/models/mixins/pydantic_mixin.py b/ormar/models/mixins/pydantic_mixin.py index 88a8d8637..b68e1f32b 100644 --- a/ormar/models/mixins/pydantic_mixin.py +++ b/ormar/models/mixins/pydantic_mixin.py @@ -71,6 +71,8 @@ def _convert_ormar_to_pydantic( include = translate_list_to_dict(include) if exclude and isinstance(exclude, Set): exclude = translate_list_to_dict(exclude) + if fk_as_int and isinstance(fk_as_int, Set): + fk_as_int = translate_list_to_dict(fk_as_int) fields_dict: Dict[str, Any] = dict() defaults: Dict[str, Any] = dict() fields_to_process = cls._get_not_excluded_fields( @@ -118,12 +120,19 @@ def _determine_pydantic_field_type( field = cls.Meta.model_fields[name] target: Any = None if field.is_relation and name in relation_map: # type: ignore - if fk_as_int and name in fk_as_int: + if fk_as_int and name in fk_as_int and not isinstance(fk_as_int[name], Dict): # type: ignore target = pydantic.PositiveInt + else: + if fk_as_int and name in fk_as_int and isinstance(fk_as_int[name], Dict): # type: ignore + current_level = fk_as_int[name] # type: ignore + fk_as_int.update(current_level) + fk_as_int.pop(name) # type: ignore + target = field.to._convert_ormar_to_pydantic( include=cls._skip_ellipsis(include, name), exclude=cls._skip_ellipsis(exclude, name), + fk_as_int=fk_as_int, # type: ignore relation_map=cls._skip_ellipsis( relation_map, name, default_return=dict() ), From 1538172f08e89aa70d839d2dbbeb7e971629e6ea Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 21:45:17 +0200 Subject: [PATCH 4/6] refactor: Restructure handling of fk_as_int This way there are two separate recursion paths, which might be the better solution. --- ormar/models/mixins/pydantic_mixin.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ormar/models/mixins/pydantic_mixin.py b/ormar/models/mixins/pydantic_mixin.py index b68e1f32b..adb45c0d0 100644 --- a/ormar/models/mixins/pydantic_mixin.py +++ b/ormar/models/mixins/pydantic_mixin.py @@ -120,19 +120,26 @@ def _determine_pydantic_field_type( field = cls.Meta.model_fields[name] target: Any = None if field.is_relation and name in relation_map: # type: ignore - if fk_as_int and name in fk_as_int and not isinstance(fk_as_int[name], Dict): # type: ignore - target = pydantic.PositiveInt + if fk_as_int and name in fk_as_int: + if not isinstance(fk_as_int[name], Dict): # type: ignore + target = pydantic.PositiveInt - else: - if fk_as_int and name in fk_as_int and isinstance(fk_as_int[name], Dict): # type: ignore + else: current_level = fk_as_int[name] # type: ignore fk_as_int.update(current_level) fk_as_int.pop(name) # type: ignore - + target = field.to._convert_ormar_to_pydantic( + include=cls._skip_ellipsis(include, name), + exclude=cls._skip_ellipsis(exclude, name), + fk_as_int=fk_as_int, + relation_map=cls._skip_ellipsis( + relation_map, name, default_return=dict() + ), + ) + else: target = field.to._convert_ormar_to_pydantic( include=cls._skip_ellipsis(include, name), exclude=cls._skip_ellipsis(exclude, name), - fk_as_int=fk_as_int, # type: ignore relation_map=cls._skip_ellipsis( relation_map, name, default_return=dict() ), From b3591abd8d215aa91fe162fe52003a49f2936d6b Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 21:46:22 +0200 Subject: [PATCH 5/6] test: Adapt tests to nested support Caveat: No M2M tests yet --- .../test_getting_pydantic_models.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py b/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py index e7f1336f3..efdf28a92 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py +++ b/tests/test_inheritance_and_pydantic_generation/test_getting_pydantic_models.py @@ -7,6 +7,7 @@ from pydantic.typing import ForwardRef import ormar +from ormar.fields.foreign_key import ForeignKey from tests.settings import DATABASE_URL metadata = sqlalchemy.MetaData() @@ -47,6 +48,24 @@ class Meta(BaseMeta): category: Optional[Category] = ormar.ForeignKey(Category, nullable=True) +class OrderPosition(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + item: Optional[Item] = ormar.ForeignKey(Item, skip_reverse=True) + + +class Order(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + position: Optional[OrderPosition] = ForeignKey(OrderPosition) + + class MutualA(ormar.Model): class Meta(BaseMeta): tablename = "mutual_a" @@ -176,6 +195,16 @@ def test_getting_pydantic_model_fk_as_int(): assert PydanticItem.__fields__["name"].type_ != PositiveInt +def test_getting_pydantic_model_nested_fk_as_int(): + PydanticOrder = Order.get_pydantic( + include={"name", "position"}, fk_as_int={"position__item"} + ) + assert len(PydanticOrder.__fields__) == 2 + PydanticPosition = PydanticOrder.__fields__["position"].type_ + assert len(PydanticPosition.__fields__) == 3 + assert PydanticPosition.__fields__["item"].type_ == PositiveInt + + def test_getting_pydantic_model_self_ref(): PydanticSelfRef = SelfRef.get_pydantic() assert len(PydanticSelfRef.__fields__) == 4 From 666a1de797a0a0d91ca2f7baa5479af971e6756f Mon Sep 17 00:00:00 2001 From: jh Date: Sun, 23 Oct 2022 22:18:40 +0200 Subject: [PATCH 6/6] docs: Add documentation for fk_as_int parameter Might also be useful in the fastapi parts of the documentation, but the methods section is prominently linked there. --- docs/models/methods.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/models/methods.md b/docs/models/methods.md index 7cbecea18..3021296a7 100644 --- a/docs/models/methods.md +++ b/docs/models/methods.md @@ -305,7 +305,7 @@ Of course the end result is a string with json representation and not a dictiona ## get_pydantic -`get_pydantic(include: Union[Set, Dict] = None, exclude: Union[Set, Dict] = None)` +`get_pydantic(include: Union[Set, Dict] = None, exclude: Union[Set, Dict] = None, fk_as_int: Union[Set, Dict] = None)` This method allows you to generate `pydantic` models from your ormar models without you needing to retype all the fields. @@ -313,6 +313,15 @@ Note that if you have nested models, it **will generate whole tree of pydantic m Moreover, you can pass `exclude` and/or `include` parameters to keep only the fields that you want to, including in nested models. +If you only want an ID instead of a nested model representation for a related field, add it to the `fk_as_int` parameter. + +!!!Note + It is currently only possible to convert models nested once, meaning they must be directly related to a model that is in turn directly related to the model that `get_pydantic` got called on. + E.g. if you have a `User` model that has an FK to a `Person` model which in turn has an FK to a `Nation` model, you can have the full Person model (but in it the Nation FK instead of the full Nation model) in the resulting pydantic model like this: + ```python + User.get_pdyantic(fk_as_int={"person__nation"}) + ``` + That means that this way you can effortlessly create pydantic models for requests and responses in `fastapi`. !!!Note