From afa1756b472615cf0424fd409cf6cf200300e3e5 Mon Sep 17 00:00:00 2001 From: collerek Date: Tue, 5 Oct 2021 18:50:02 +0200 Subject: [PATCH 01/11] very initial verson of construct --- ormar/models/newbasemodel.py | 64 +++++++++++++++-- .../test_model_construct.py | 71 +++++++++++++++++++ .../test_model_definition/test_save_status.py | 2 +- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/test_model_definition/test_model_construct.py diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 6b4eccf..ce64fa0 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -14,6 +14,7 @@ from typing import ( TYPE_CHECKING, Tuple, Type, + TypeVar, Union, cast, ) @@ -50,8 +51,11 @@ if TYPE_CHECKING: # pragma no cover from ormar.models import Model from ormar.signals import SignalEmitter + T = TypeVar("T", bound="NewBaseModel") + IntStr = Union[int, str] DictStrAny = Dict[str, Any] + SetStr = Set[str] AbstractSetIntStr = AbstractSet[IntStr] MappingIntStrAny = Mapping[IntStr, Any] @@ -154,7 +158,9 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass # register the columns models after initialization for related in self.extract_related_names().union(self.extract_through_names()): model_fields[related].expand_relationship( - new_kwargs.get(related), self, to_register=True, + new_kwargs.get(related), + self, + to_register=True, ) if hasattr(self, "_init_private_attributes"): @@ -261,7 +267,11 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass k, self._convert_json( k, - model_fields[k].expand_relationship(v, self, to_register=False,) + model_fields[k].expand_relationship( + v, + self, + to_register=False, + ) if k in model_fields else (v if k in pydantic_fields else model_fields[k]), ), @@ -315,7 +325,8 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass self, "_orm", RelationsManager( - related_fields=self.extract_related_fields(), owner=cast("Model", self), + related_fields=self.extract_related_fields(), + owner=cast("Model", self), ), ) @@ -488,7 +499,9 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass @staticmethod def _get_not_excluded_fields( - fields: Union[List, Set], include: Optional[Dict], exclude: Optional[Dict], + fields: Union[List, Set], + include: Optional[Dict], + exclude: Optional[Dict], ) -> List: """ Returns related field names applying on them include and exclude set. @@ -785,6 +798,49 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass data = data["__root__"] return self.__config__.json_dumps(data, default=encoder, **dumps_kwargs) + @classmethod + def construct( + cls: Type["T"], _fields_set: Optional["SetStr"] = None, **values: Any + ) -> "T": + own_values = { + k: v for k, v in values.items() if k not in cls.extract_related_names() + } + m = cls.__new__(cls) + fields_values: Dict[str, Any] = {} + for name, field in cls.__fields__.items(): + if name in own_values: + fields_values[name] = own_values[name] + elif not field.required: + fields_values[name] = field.get_default() + fields_values.update(own_values) + object.__setattr__(m, "__dict__", fields_values) + m._initialize_internal_attributes() + for relation in cls.extract_related_names(): + if relation in values: + relation_field = cls.Meta.model_fields[relation] + if isinstance(values[relation], list): + relation_value = [ + relation_field.to.construct(**x) if isinstance(x, dict) else x + for x in values[relation] + ] + else: + relation_value = [ + relation_field.to.construct(**values[relation]) + if isinstance(values[relation], dict) + else values[relation] + ] + + for child in relation_value: + m._orm.add( + parent=child, + child=m, + field=relation_field, + ) + if _fields_set is None: + _fields_set = set(values.keys()) + object.__setattr__(m, "__fields_set__", _fields_set) + return m + def update_from_dict(self, value_dict: Dict) -> "NewBaseModel": """ Updates self with values of fields passed in the dictionary. diff --git a/tests/test_model_definition/test_model_construct.py b/tests/test_model_definition/test_model_construct.py new file mode 100644 index 0000000..e27b768 --- /dev/null +++ b/tests/test_model_definition/test_model_construct.py @@ -0,0 +1,71 @@ +from typing import List + +import databases +import pytest +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class NickNames(ormar.Model): + class Meta: + tablename = "nicks" + metadata = metadata + database = database + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + is_lame: bool = ormar.Boolean(nullable=True) + + +class NicksHq(ormar.Model): + class Meta: + tablename = "nicks_x_hq" + metadata = metadata + database = database + + +class HQ(ormar.Model): + class Meta: + tablename = "hqs" + metadata = metadata + database = database + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + nicks: List[NickNames] = ormar.ManyToMany(NickNames, through=NicksHq) + + +class Company(ormar.Model): + class Meta: + tablename = "companies" + metadata = metadata + database = database + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="company_name") + founded: int = ormar.Integer(nullable=True) + hq: HQ = ormar.ForeignKey(HQ) + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@pytest.mark.asyncio +async def test_init_and_construct_has_same_effect(): + async with database: + async with database.transaction(force_rollback=True): + hq = await HQ.objects.create(name="Main") + comp = Company(name="Banzai", hq=hq, founded=1988) + comp2 = Company.construct(**dict(name="Banzai", hq=hq, founded=1988)) + assert comp.dict() == comp2.dict() diff --git a/tests/test_model_definition/test_save_status.py b/tests/test_model_definition/test_save_status.py index 93e89ac..9762810 100644 --- a/tests/test_model_definition/test_save_status.py +++ b/tests/test_model_definition/test_save_status.py @@ -63,7 +63,7 @@ def create_test_database(): @pytest.mark.asyncio -async def test_instantation_false_save_true(): +async def test_instantiation_false_save_true(): async with database: async with database.transaction(force_rollback=True): comp = Company(name="Banzai", founded=1988) From 1b1da6c3b106f3baaf25f1b5a1549b81752307cd Mon Sep 17 00:00:00 2001 From: collerek Date: Fri, 8 Oct 2021 15:31:33 +0200 Subject: [PATCH 02/11] add workaround test for validation of fields in generated pydantic --- .../test_validators_in_generated_pydantic.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py diff --git a/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py new file mode 100644 index 0000000..dc533d9 --- /dev/null +++ b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py @@ -0,0 +1,86 @@ +import enum + +import databases +import pydantic +import pytest +import sqlalchemy +from pydantic import ValidationError +from pydantic.class_validators import make_generic_validator + + +import ormar +from tests.settings import DATABASE_URL + +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL) + + +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class EnumExample(str, enum.Enum): + A = "A" + B = "B" + C = "C" + + +class ModelExample(ormar.Model): + class Meta(ormar.ModelMeta): + database = database + metadata = metadata + tablename = "examples" + + id: int = ormar.Integer(primary_key=True) + str_field: str = ormar.String(min_length=5, max_length=10, nullable=False) + enum_field: str = ormar.String( + max_length=1, nullable=False, choices=list(EnumExample) + ) + + @pydantic.validator("str_field") + def validate_str_field(cls, v): + if " " not in v: + raise ValueError("must contain a space") + return v + + +def validate_str_field(cls, v): + if " " not in v: + raise ValueError("must contain a space") + return v + + +def validate_choices(cls, v): + if v not in list(EnumExample): + raise ValueError(f"{v} is not in allowed choices: {list(EnumExample)}") + return v + + +ModelExampleCreate = ModelExample.get_pydantic(exclude={"id"}) +ModelExampleCreate.__fields__["str_field"].validators.append( + make_generic_validator(validate_str_field) +) +ModelExampleCreate.__fields__["enum_field"].validators.append( + make_generic_validator(validate_choices) +) + + +def test_ormar_validator(): + ModelExample(str_field="a aaaaaa", enum_field="A") + with pytest.raises(ValidationError) as e: + ModelExample(str_field="aaaaaaa", enum_field="A") + assert "must contain a space" in str(e) + with pytest.raises(ValidationError) as e: + ModelExample(str_field="a aaaaaaa", enum_field="Z") + assert "not in allowed choices" in str(e) + + +def test_pydantic_validator(): + ModelExampleCreate(str_field="a aaaaaa", enum_field="A") + with pytest.raises(ValidationError) as e: + ModelExampleCreate(str_field="aaaaaaa", enum_field="A") + assert "must contain a space" in str(e) + with pytest.raises(ValidationError) as e: + ModelExampleCreate(str_field="a aaaaaaa", enum_field="Z") + assert "not in allowed choices" in str(e) From 4896a3a982fd441aa19b4b7d71932839ad6c61f7 Mon Sep 17 00:00:00 2001 From: collerek Date: Sat, 9 Oct 2021 17:19:17 +0200 Subject: [PATCH 03/11] add tests for creation from dictionaries and for m2m relations --- ormar/models/newbasemodel.py | 61 +++++++++++-------- .../test_model_construct.py | 18 +++++- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 924308d..ab105cd 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -266,7 +266,6 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass self._convert_json( k, model_fields[k].expand_relationship(v, self, to_register=False) - if k in model_fields else (v if k in pydantic_fields else model_fields[k]), ), @@ -797,7 +796,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass own_values = { k: v for k, v in values.items() if k not in cls.extract_related_names() } - m = cls.__new__(cls) + model = cls.__new__(cls) fields_values: Dict[str, Any] = {} for name, field in cls.__fields__.items(): if name in own_values: @@ -805,33 +804,41 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass elif not field.required: fields_values[name] = field.get_default() fields_values.update(own_values) - object.__setattr__(m, "__dict__", fields_values) - m._initialize_internal_attributes() - for relation in cls.extract_related_names(): - if relation in values: - relation_field = cls.Meta.model_fields[relation] - if isinstance(values[relation], list): - relation_value = [ - relation_field.to.construct(**x) if isinstance(x, dict) else x - for x in values[relation] - ] - else: - relation_value = [ - relation_field.to.construct(**values[relation]) - if isinstance(values[relation], dict) - else values[relation] - ] - - for child in relation_value: - m._orm.add( - parent=child, - child=m, - field=relation_field, - ) + object.__setattr__(model, "__dict__", fields_values) + model._initialize_internal_attributes() + cls._construct_relations(model=model, values=values) if _fields_set is None: _fields_set = set(values.keys()) - object.__setattr__(m, "__fields_set__", _fields_set) - return m + object.__setattr__(model, "__fields_set__", _fields_set) + return model + + @classmethod + def _construct_relations(cls: Type["T"], model: "T", values: Dict): + present_relations = [ + relation for relation in cls.extract_related_names() if relation in values + ] + for relation in present_relations: + value_to_set = values[relation] + if not isinstance(value_to_set, list): + value_to_set = [value_to_set] + relation_field = cls.Meta.model_fields[relation] + relation_value = [ + cls.construct_from_dict_if_required(relation_field, value=x) + for x in value_to_set + ] + + for child in relation_value: + model._orm.add( + parent=child, + child=model, + field=relation_field, + ) + + @staticmethod + def construct_from_dict_if_required(relation_field: "BaseField", value: Any): + return ( + relation_field.to.construct(**value) if isinstance(value, dict) else value + ) def update_from_dict(self, value_dict: Dict) -> "NewBaseModel": """ diff --git a/tests/test_model_definition/test_model_construct.py b/tests/test_model_definition/test_model_construct.py index e27b768..87bd4ba 100644 --- a/tests/test_model_definition/test_model_construct.py +++ b/tests/test_model_definition/test_model_construct.py @@ -19,7 +19,6 @@ class NickNames(ormar.Model): id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100, nullable=False, name="hq_name") - is_lame: bool = ormar.Boolean(nullable=True) class NicksHq(ormar.Model): @@ -69,3 +68,20 @@ async def test_init_and_construct_has_same_effect(): comp = Company(name="Banzai", hq=hq, founded=1988) comp2 = Company.construct(**dict(name="Banzai", hq=hq, founded=1988)) assert comp.dict() == comp2.dict() + + comp3 = Company.construct(**dict(name="Banzai", hq=hq.dict(), founded=1988)) + assert comp.dict() == comp3.dict() + + +@pytest.mark.asyncio +async def test_init_and_construct_has_same_effect_with_m2m(): + async with database: + async with database.transaction(force_rollback=True): + n1 = await NickNames(name="test").save() + n2 = await NickNames(name="test2").save() + hq = HQ(name="Main", nicks=[n1, n2]) + hq2 = HQ.construct(**dict(name="Main", nicks=[n1, n2])) + assert hq.dict() == hq2.dict() + + hq3 = HQ.construct(**dict(name="Main", nicks=[n1.dict(), n2.dict()])) + assert hq.dict() == hq3.dict() From 127df1e9cb4aa6060842a39f459854d059ce18b0 Mon Sep 17 00:00:00 2001 From: collerek Date: Sat, 9 Oct 2021 19:43:21 +0200 Subject: [PATCH 04/11] fix mypy, add pre-commit --- .pre-commit-config.yaml | 17 ++++ ormar/models/newbasemodel.py | 10 ++- poetry.lock | 154 ++++++++++++++++++++++++++++++++++- pyproject.toml | 2 + 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..af7d80e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/psf/black + rev: 21.9b0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + args: [ '--max-line-length=88' ] + files: ormar + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.910 + hooks: + - id: mypy + files: ormar tests + additional_dependencies: [ types-pytz ] diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index ab105cd..5ba449f 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -813,7 +813,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass return model @classmethod - def _construct_relations(cls: Type["T"], model: "T", values: Dict): + def _construct_relations(cls: Type["T"], model: "T", values: Dict) -> None: present_relations = [ relation for relation in cls.extract_related_names() if relation in values ] @@ -830,12 +830,14 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass for child in relation_value: model._orm.add( parent=child, - child=model, - field=relation_field, + child=cast("Model", model), + field=cast("ForeignKeyField", relation_field), ) @staticmethod - def construct_from_dict_if_required(relation_field: "BaseField", value: Any): + def construct_from_dict_if_required( + relation_field: "BaseField", value: Any + ) -> "Model": return ( relation_field.to.construct(**value) if isinstance(value, dict) else value ) diff --git a/poetry.lock b/poetry.lock index 719d38e..ed57147 100644 --- a/poetry.lock +++ b/poetry.lock @@ -125,6 +125,21 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "bandit" version = "1.7.0" @@ -188,6 +203,14 @@ python-versions = "*" [package.dependencies] pycparser = "*" +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + [[package]] name = "charset-normalizer" version = "2.0.6" @@ -364,6 +387,14 @@ wrapt = ">=1.10,<2" [package.extras] dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] +[[package]] +name = "distlib" +version = "0.3.3" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "docspec" version = "1.2.0" @@ -412,6 +443,18 @@ dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,< doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] +[[package]] +name = "filelock" +version = "3.3.0" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] + [[package]] name = "flake8" version = "3.9.2" @@ -595,6 +638,17 @@ python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" [package.extras] docs = ["sphinx"] +[[package]] +name = "identify" +version = "2.3.0" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.extras] +license = ["editdistance-s"] + [[package]] name = "idna" version = "3.2" @@ -634,6 +688,21 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] perf = ["ipython"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +[[package]] +name = "importlib-resources" +version = "5.2.2" +description = "Read resources from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] + [[package]] name = "iniconfig" version = "1.1.1" @@ -786,6 +855,14 @@ category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "nr.fs" version = "1.6.3" @@ -920,6 +997,24 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "2.15.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +importlib-resources = {version = "*", markers = "python_version < \"3.7\""} +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + [[package]] name = "psycopg2-binary" version = "2.9.1" @@ -1380,6 +1475,27 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "virtualenv" +version = "20.8.1" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +"backports.entry-points-selectable" = ">=1.0.4" +distlib = ">=0.3.1,<1" +filelock = ">=3.0.0,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} +platformdirs = ">=2,<3" +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] + [[package]] name = "watchdog" version = "2.1.6" @@ -1434,7 +1550,7 @@ sqlite = [] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "a6fddac57b64af3f1164dbaa7be05a13e3766aeeb783a9de55c6ead91316925f" +content-hash = "ad3c5c3a99e06921f7fb2d747128aff4561f8490dba0d2fdb46acba1fd7aade6" [metadata.files] aiocontextvars = [ @@ -1488,6 +1604,10 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, + {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, +] bandit = [ {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"}, {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, @@ -1547,6 +1667,10 @@ cffi = [ {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] +cfgv = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] charset-normalizer = [ {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"}, {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, @@ -1651,6 +1775,10 @@ deprecated = [ {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, ] +distlib = [ + {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, + {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, +] docspec = [ {file = "docspec-1.2.0-py3-none-any.whl", hash = "sha256:df3d2d014e0a77ac0997c9052102cf2262f12640c87af3782e2310589be4bb4c"}, {file = "docspec-1.2.0.tar.gz", hash = "sha256:5206c061d2c0171add8412028a79b436acc87786cfc582aeda341beda81ae582"}, @@ -1663,6 +1791,10 @@ fastapi = [ {file = "fastapi-0.70.0-py3-none-any.whl", hash = "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c"}, {file = "fastapi-0.70.0.tar.gz", hash = "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced"}, ] +filelock = [ + {file = "filelock-3.3.0-py3-none-any.whl", hash = "sha256:bbc6a0382fe8ec4744ecdf6683a2e07f65eb10ff1aff53fc02a202565446cde0"}, + {file = "filelock-3.3.0.tar.gz", hash = "sha256:8c7eab13dc442dc249e95158bcc12dec724465919bdc9831fdbf0660f03d1785"}, +] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, @@ -1767,6 +1899,10 @@ greenlet = [ {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, ] +identify = [ + {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, + {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, +] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, @@ -1804,6 +1940,10 @@ importlib-metadata = [ {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, ] +importlib-resources = [ + {file = "importlib_resources-5.2.2-py3-none-any.whl", hash = "sha256:2480d8e07d1890056cb53c96e3de44fead9c62f2ba949b0f2e4c4345f4afa977"}, + {file = "importlib_resources-5.2.2.tar.gz", hash = "sha256:a65882a4d0fe5fbf702273456ba2ce74fe44892c25e42e057aca526b702a6d4b"}, +] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, @@ -1931,6 +2071,10 @@ mysqlclient = [ {file = "mysqlclient-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:fc575093cf81b6605bed84653e48b277318b880dc9becf42dd47fa11ffd3e2b6"}, {file = "mysqlclient-2.0.3.tar.gz", hash = "sha256:f6ebea7c008f155baeefe16c56cd3ee6239f7a5a9ae42396c2f1860f08a7c432"}, ] +nodeenv = [ + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, +] "nr.fs" = [ {file = "nr.fs-1.6.3-py2.py3-none-any.whl", hash = "sha256:64108c168ea2e8077fdf5f0c5417459d1a145fe34cb305fe90faeb75b4e8b421"}, {file = "nr.fs-1.6.3.tar.gz", hash = "sha256:788aa0a04c4143f95c5245bc8ccc0c0872e932be533bd37780fbb55afcdf124a"}, @@ -2002,6 +2146,10 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] +pre-commit = [ + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, +] psycopg2-binary = [ {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, @@ -2340,6 +2488,10 @@ urllib3 = [ {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] +virtualenv = [ + {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, + {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, +] watchdog = [ {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"}, {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"}, diff --git a/pyproject.toml b/pyproject.toml index c51b81c..1b71ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,8 @@ dataclasses = { version = ">=0.6.0,<0.8 || >0.8,<1.0.0" } # Performance testing yappi = "^1.3.3" +pre-commit = "^2.15.0" + [tool.poetry.extras] postgresql = ["asyncpg", "psycopg2-binary"] postgres = ["asyncpg", "psycopg2-binary"] From 3a0fba5a60448f90db2c7f1a479bc6631a9cf12d Mon Sep 17 00:00:00 2001 From: collerek Date: Sat, 9 Oct 2021 20:21:22 +0200 Subject: [PATCH 05/11] switch to expand relationship to allow pk values as models --- ormar/models/newbasemodel.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 5ba449f..19f6634 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -823,25 +823,17 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass value_to_set = [value_to_set] relation_field = cls.Meta.model_fields[relation] relation_value = [ - cls.construct_from_dict_if_required(relation_field, value=x) + relation_field.expand_relationship(x, model, to_register=False) for x in value_to_set ] for child in relation_value: model._orm.add( - parent=child, + parent=cast("Model", child), child=cast("Model", model), field=cast("ForeignKeyField", relation_field), ) - @staticmethod - def construct_from_dict_if_required( - relation_field: "BaseField", value: Any - ) -> "Model": - return ( - relation_field.to.construct(**value) if isinstance(value, dict) else value - ) - def update_from_dict(self, value_dict: Dict) -> "NewBaseModel": """ Updates self with values of fields passed in the dictionary. From d992f3dc3bc58e4eefe97f04aba29db05c693502 Mon Sep 17 00:00:00 2001 From: collerek Date: Sat, 9 Oct 2021 20:28:30 +0200 Subject: [PATCH 06/11] add tests to pre-commit black --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af7d80e..ddf33a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: 21.9b0 hooks: - id: black + files: ormar tests - repo: https://github.com/pycqa/flake8 rev: 3.9.2 hooks: @@ -14,4 +15,3 @@ repos: hooks: - id: mypy files: ormar tests - additional_dependencies: [ types-pytz ] From d8f0dc92f0635ca2eab95331b417565adcfc6264 Mon Sep 17 00:00:00 2001 From: collerek Date: Sun, 10 Oct 2021 14:11:25 +0200 Subject: [PATCH 07/11] refactor choices validation from root validator to field validator --- ormar/__init__.py | 2 + ormar/fields/__init__.py | 3 +- ormar/fields/model_fields.py | 48 ++++++- ormar/fields/parsers.py | 46 ++++++- ormar/fields/sqlalchemy_encrypted.py | 2 +- ormar/models/helpers/validation.py | 157 ++++++++-------------- ormar/models/metaclass.py | 1 - ormar/models/mixins/save_mixin.py | 21 ++- ormar/models/newbasemodel.py | 3 +- ormar/queryset/utils.py | 6 +- tests/test_fastapi/test_choices_schema.py | 3 +- 11 files changed, 168 insertions(+), 124 deletions(-) diff --git a/ormar/__init__.py b/ormar/__init__.py index f444fb6..21ab87e 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -61,6 +61,7 @@ from ormar.fields import ( LargeBinary, ManyToMany, ManyToManyField, + SQL_ENCODERS_MAP, SmallInteger, String, Text, @@ -132,6 +133,7 @@ __all__ = [ "or_", "EncryptBackends", "ENCODERS_MAP", + "SQL_ENCODERS_MAP", "DECODERS_MAP", "LargeBinary", "Extra", diff --git a/ormar/fields/__init__.py b/ormar/fields/__init__.py index 9ea9387..e90b5df 100644 --- a/ormar/fields/__init__.py +++ b/ormar/fields/__init__.py @@ -24,7 +24,7 @@ from ormar.fields.model_fields import ( Time, UUID, ) -from ormar.fields.parsers import DECODERS_MAP, ENCODERS_MAP +from ormar.fields.parsers import DECODERS_MAP, ENCODERS_MAP, SQL_ENCODERS_MAP from ormar.fields.sqlalchemy_encrypted import EncryptBackend, EncryptBackends from ormar.fields.through_field import Through, ThroughField @@ -54,6 +54,7 @@ __all__ = [ "EncryptBackend", "DECODERS_MAP", "ENCODERS_MAP", + "SQL_ENCODERS_MAP", "LargeBinary", "UniqueColumns", ] diff --git a/ormar/fields/model_fields.py b/ormar/fields/model_fields.py index 6003620..a404204 100644 --- a/ormar/fields/model_fields.py +++ b/ormar/fields/model_fields.py @@ -1,11 +1,13 @@ import datetime import decimal import uuid -from typing import Any, Optional, TYPE_CHECKING, Union, overload +from enum import Enum +from typing import Any, Optional, Set, TYPE_CHECKING, Type, Union, overload import pydantic import sqlalchemy +import ormar # noqa I101 from ormar import ModelDefinitionError # noqa I101 from ormar.fields import sqlalchemy_uuid from ormar.fields.base import BaseField # noqa I101 @@ -60,6 +62,39 @@ def is_auto_primary_key(primary_key: bool, autoincrement: bool) -> bool: return primary_key and autoincrement +def convert_choices_if_needed( + field_type: "Type", choices: Set, nullable: bool, scale: int = None +) -> Set: + """ + Converts dates to isoformat as fastapi can check this condition in routes + and the fields are not yet parsed. + Converts enums to list of it's values. + Converts uuids to strings. + Converts decimal to float with given scale. + + :param field_type: type o the field + :type field_type: Type + :param nullable: set of choices + :type nullable: Set + :param scale: scale for decimals + :type scale: int + :param scale: scale for decimals + :type scale: int + :return: value, choices list + :rtype: Tuple[Any, Set] + """ + choices = {o.value if isinstance(o, Enum) else o for o in choices} + encoder = ormar.ENCODERS_MAP.get(field_type, lambda x: x) + if field_type == decimal.Decimal: + precision = scale + choices = {encoder(o, precision) for o in choices} + elif encoder: + choices = {encoder(o) for o in choices} + if nullable: + choices.add(None) + return choices + + class ModelFieldFactory: """ Default field factory that construct Field classes and populated their values. @@ -96,6 +131,15 @@ class ModelFieldFactory: else (nullable if sql_nullable is None else sql_nullable) ) + choices = set(kwargs.pop("choices", [])) + if choices: + choices = convert_choices_if_needed( + field_type=cls._type, + choices=choices, + nullable=nullable, + scale=kwargs.get("scale", None), + ) + namespace = dict( __type__=cls._type, __pydantic_type__=overwrite_pydantic_type @@ -114,7 +158,7 @@ class ModelFieldFactory: pydantic_only=pydantic_only, autoincrement=autoincrement, column_type=cls.get_column_type(**kwargs), - choices=set(kwargs.pop("choices", [])), + choices=choices, encrypt_secret=encrypt_secret, encrypt_backend=encrypt_backend, encrypt_custom_backend=encrypt_custom_backend, diff --git a/ormar/fields/parsers.py b/ormar/fields/parsers.py index e0f1a53..e8b7301 100644 --- a/ormar/fields/parsers.py +++ b/ormar/fields/parsers.py @@ -1,6 +1,8 @@ +import base64 import datetime import decimal -from typing import Any +import uuid +from typing import Any, Callable, Dict, Union import pydantic from pydantic.datetime_parse import parse_date, parse_datetime, parse_time @@ -19,21 +21,55 @@ def encode_bool(value: bool) -> str: return "true" if value else "false" +def encode_decimal(value: decimal.Decimal, precision: int = None) -> float: + if precision: + return ( + round(float(value), precision) + if isinstance(value, decimal.Decimal) + else value + ) + return float(value) + + +def encode_bytes(value: Union[str, bytes], represent_as_string: bool = False) -> bytes: + if represent_as_string: + return value if isinstance(value, bytes) else base64.b64decode(value) + return value if isinstance(value, bytes) else value.encode("utf-8") + + def encode_json(value: Any) -> str: - value = json.dumps(value) if not isinstance(value, str) else value + value = json.dumps(value) if not isinstance(value, str) else re_dump_value(value) value = value.decode("utf-8") if isinstance(value, bytes) else value return value -ENCODERS_MAP = { - bool: encode_bool, +def re_dump_value(value: str) -> Union[str, bytes]: + """ + Rw-dumps choices due to different string representation in orjson and json + :param value: string to re-dump + :type value: str + :return: re-dumped choices + :rtype: List[str] + """ + try: + result: Union[str, bytes] = json.dumps(json.loads(value)) + except json.JSONDecodeError: + result = value + return result + + +ENCODERS_MAP: Dict[type, Callable] = { datetime.datetime: lambda x: x.isoformat(), datetime.date: lambda x: x.isoformat(), datetime.time: lambda x: x.isoformat(), pydantic.Json: encode_json, - decimal.Decimal: float, + decimal.Decimal: encode_decimal, + uuid.UUID: str, + bytes: encode_bytes, } +SQL_ENCODERS_MAP: Dict[type, Callable] = {bool: encode_bool, **ENCODERS_MAP} + DECODERS_MAP = { bool: parse_bool, datetime.datetime: parse_datetime, diff --git a/ormar/fields/sqlalchemy_encrypted.py b/ormar/fields/sqlalchemy_encrypted.py index 97769ba..88dc0af 100644 --- a/ormar/fields/sqlalchemy_encrypted.py +++ b/ormar/fields/sqlalchemy_encrypted.py @@ -160,7 +160,7 @@ class EncryptedString(types.TypeDecorator): try: value = self._underlying_type.process_bind_param(value, dialect) except AttributeError: - encoder = ormar.ENCODERS_MAP.get(self.type_, None) + encoder = ormar.SQL_ENCODERS_MAP.get(self.type_, None) if encoder: value = encoder(value) # type: ignore diff --git a/ormar/models/helpers/validation.py b/ormar/models/helpers/validation.py index 11db796..0ed1635 100644 --- a/ormar/models/helpers/validation.py +++ b/ormar/models/helpers/validation.py @@ -1,30 +1,37 @@ import base64 -import datetime import decimal import numbers -import uuid -from enum import Enum -from typing import Any, Dict, List, Set, TYPE_CHECKING, Tuple, Type, Union +from typing import ( + Any, + Callable, + Dict, + List, + Set, + TYPE_CHECKING, + Type, + Union, +) try: import orjson as json except ImportError: # pragma: no cover - import json # type: ignore + import json # type: ignore # noqa: F401 import pydantic -from pydantic.fields import SHAPE_LIST +from pydantic.class_validators import make_generic_validator +from pydantic.fields import ModelField, SHAPE_LIST from pydantic.main import SchemaExtraCallable import ormar # noqa: I100, I202 -from ormar.fields import BaseField from ormar.models.helpers.models import meta_field_not_set from ormar.queryset.utils import translate_list_to_dict if TYPE_CHECKING: # pragma no cover from ormar import Model + from ormar.fields import BaseField -def check_if_field_has_choices(field: BaseField) -> bool: +def check_if_field_has_choices(field: "BaseField") -> bool: """ Checks if given field has choices populated. A if it has one, a validator for this field needs to be attached. @@ -37,110 +44,53 @@ def check_if_field_has_choices(field: BaseField) -> bool: return hasattr(field, "choices") and bool(field.choices) -def convert_choices_if_needed( # noqa: CCR001 - field: "BaseField", value: Any -) -> Tuple[Any, List]: +def convert_value_if_needed(field: "BaseField", value: Any) -> Any: """ Converts dates to isoformat as fastapi can check this condition in routes and the fields are not yet parsed. - Converts enums to list of it's values. - Converts uuids to strings. - Converts decimal to float with given scale. :param field: ormar field to check with choices :type field: BaseField :param value: current values of the model to verify - :type value: Dict - :return: value, choices list - :rtype: Tuple[Any, List] - """ - # TODO use same maps as with EncryptedString - choices = [o.value if isinstance(o, Enum) else o for o in field.choices] - - if field.__type__ in [datetime.datetime, datetime.date, datetime.time]: - value = value.isoformat() if not isinstance(value, str) else value - choices = [o.isoformat() for o in field.choices] - elif field.__type__ == pydantic.Json: - value = ( - json.dumps(value) if not isinstance(value, str) else re_dump_value(value) - ) - value = value.decode("utf-8") if isinstance(value, bytes) else value - choices = [re_dump_value(x) for x in field.choices] - elif field.__type__ == uuid.UUID: - value = str(value) if not isinstance(value, str) else value - choices = [str(o) for o in field.choices] - elif field.__type__ == decimal.Decimal: - precision = field.scale # type: ignore - value = ( - round(float(value), precision) - if isinstance(value, decimal.Decimal) - else value - ) - choices = [round(float(o), precision) for o in choices] - elif field.__type__ == bytes: - if field.represent_as_base64_str: - value = value if isinstance(value, bytes) else base64.b64decode(value) - else: - value = value if isinstance(value, bytes) else value.encode("utf-8") - - return value, choices - - -def re_dump_value(value: str) -> str: - """ - Rw-dumps choices due to different string representation in orjson and json - :param value: string to re-dump - :type value: str - :return: re-dumped choices - :rtype: List[str] - """ - try: - result: Union[str, bytes] = json.dumps(json.loads(value)) - except json.JSONDecodeError: - result = value - return result.decode("utf-8") if isinstance(result, bytes) else result - - -def validate_choices(field: "BaseField", value: Any) -> None: - """ - Validates if given value is in provided choices. - - :raises ValueError: If value is not in choices. - :param field:field to validate - :type field: BaseField - :param value: value of the field :type value: Any + :return: value, choices list + :rtype: Any """ - value, choices = convert_choices_if_needed(field=field, value=value) - if field.nullable: - choices.append(None) - if value is not ormar.Undefined and value not in choices: - raise ValueError( - f"{field.name}: '{value}' " f"not in allowed choices set:" f" {choices}" - ) + encoder = ormar.ENCODERS_MAP.get(field.__type__, lambda x: x) + if field.__type__ == decimal.Decimal: + precision = field.scale # type: ignore + value = encoder(value, precision) + elif encoder: + value = encoder(value) + return value -def choices_validator(cls: Type["Model"], values: Dict[str, Any]) -> Dict[str, Any]: - """ - Validator that is attached to pydantic model pre root validators. - Validator checks if field value is in field.choices list. +def generate_validator(ormar_field: "BaseField") -> Callable: + choices = ormar_field.choices - :raises ValueError: if field value is outside of allowed choices. - :param cls: constructed class - :type cls: Model class - :param values: dictionary of field values (pydantic side) - :type values: Dict[str, Any] - :return: values if pass validation, otherwise exception is raised - :rtype: Dict[str, Any] - """ - for field_name, field in cls.Meta.model_fields.items(): - if check_if_field_has_choices(field): - value = values.get(field_name, ormar.Undefined) - validate_choices(field=field, value=value) - return values + def validate_choices(cls: type, value: Any, field: "ModelField") -> None: + """ + Validates if given value is in provided choices. + + :raises ValueError: If value is not in choices. + :param field:field to validate + :type field: BaseField + :param value: value of the field + :type value: Any + """ + adjusted_value = convert_value_if_needed(field=ormar_field, value=value) + if adjusted_value is not ormar.Undefined and adjusted_value not in choices: + raise ValueError( + f"{field.name}: '{adjusted_value}' " + f"not in allowed choices set:" + f" {choices}" + ) + return value + + return validate_choices def generate_model_example(model: Type["Model"], relation_map: Dict = None) -> Dict: @@ -172,7 +122,7 @@ def generate_model_example(model: Type["Model"], relation_map: Dict = None) -> D def populates_sample_fields_values( - example: Dict[str, Any], name: str, field: BaseField, relation_map: Dict = None + example: Dict[str, Any], name: str, field: "BaseField", relation_map: Dict = None ) -> None: """ Iterates the field and sets fields to sample values @@ -350,15 +300,14 @@ def populate_choices_validators(model: Type["Model"]) -> None: # noqa CCR001 """ fields_with_choices = [] if not meta_field_not_set(model=model, field_name="model_fields"): + if hasattr(model, "_choices_fields"): + return + model._choices_fields = set() for name, field in model.Meta.model_fields.items(): if check_if_field_has_choices(field): fields_with_choices.append(name) - validators = getattr(model, "__pre_root_validators__", []) - if choices_validator not in validators: - validators.append(choices_validator) - model.__pre_root_validators__ = validators - if not model._choices_fields: - model._choices_fields = set() + validator = make_generic_validator(generate_validator(field)) + model.__fields__[name].validators.append(validator) model._choices_fields.add(name) if fields_with_choices: diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 49043cc..2bee725 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -106,7 +106,6 @@ def add_cached_properties(new_model: Type["Model"]) -> None: new_model._through_names = None new_model._related_fields = None new_model._pydantic_fields = {name for name in new_model.__fields__} - new_model._choices_fields = set() new_model._json_fields = set() new_model._bytes_fields = set() diff --git a/ormar/models/mixins/save_mixin.py b/ormar/models/mixins/save_mixin.py index d1769f9..de3923e 100644 --- a/ormar/models/mixins/save_mixin.py +++ b/ormar/models/mixins/save_mixin.py @@ -11,9 +11,10 @@ from typing import ( cast, ) -import ormar +import pydantic + +import ormar # noqa: I100, I202 from ormar.exceptions import ModelPersistenceError -from ormar.models.helpers.validation import validate_choices from ormar.models.mixins import AliasMixin from ormar.models.mixins.relation_mixin import RelationMixin @@ -29,6 +30,7 @@ class SavePrepareMixin(RelationMixin, AliasMixin): if TYPE_CHECKING: # pragma: nocover _choices_fields: Optional[Set] _skip_ellipsis: Callable + __fields__: Dict[str, pydantic.fields.ModelField] @classmethod def prepare_model_to_save(cls, new_kwargs: dict) -> dict: @@ -180,9 +182,18 @@ class SavePrepareMixin(RelationMixin, AliasMixin): if not cls._choices_fields: return new_kwargs - for field_name, field in cls.Meta.model_fields.items(): - if field_name in new_kwargs and field_name in cls._choices_fields: - validate_choices(field=field, value=new_kwargs.get(field_name)) + fields_to_check = [ + field + for field in cls.Meta.model_fields.values() + if field.name in cls._choices_fields and field.name in new_kwargs + ] + for field in fields_to_check: + if new_kwargs[field.name] not in field.choices: + raise ValueError( + f"{field.name}: '{new_kwargs[field.name]}' " + f"not in allowed choices set:" + f" {field.choices}" + ) return new_kwargs @staticmethod diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 19f6634..1fbf109 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -90,7 +90,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass _related_names: Optional[Set] _through_names: Optional[Set] _related_names_hash: str - _choices_fields: Optional[Set] + _choices_fields: Set _pydantic_fields: Set _quick_access_fields: Set _json_fields: Set @@ -928,6 +928,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass :return: dictionary of fields names and values. :rtype: Dict """ + # TODO: Cache this dictionary? self_fields = self._extract_own_model_fields() self_fields = { k: v diff --git a/ormar/queryset/utils.py b/ormar/queryset/utils.py index f99c698..4ebe07c 100644 --- a/ormar/queryset/utils.py +++ b/ormar/queryset/utils.py @@ -29,8 +29,8 @@ def check_node_not_dict_or_not_last_node( :param part: :type part: str - :param parts: - :type parts: List[str] + :param is_last: flag to check if last element + :type is_last: bool :param current_level: current level of the traversed structure :type current_level: Any :return: result of the check @@ -52,7 +52,7 @@ def translate_list_to_dict( # noqa: CCR001 Default required key ise Ellipsis like in pydantic. :param list_to_trans: input list - :type list_to_trans: set + :type list_to_trans: Union[List, Set] :param is_order: flag if change affects order_by clauses are they require special default value with sort order. :type is_order: bool diff --git a/tests/test_fastapi/test_choices_schema.py b/tests/test_fastapi/test_choices_schema.py index 86336c5..da8a167 100644 --- a/tests/test_fastapi/test_choices_schema.py +++ b/tests/test_fastapi/test_choices_schema.py @@ -121,7 +121,8 @@ def test_all_endpoints(): "blob_col": blob.decode("utf-8"), }, ) - + if response.status_code != 200: + print(response.text) assert response.status_code == 200 item = Organisation(**response.json()) assert item.pk is not None From b99df7720fa089334b6771fcf249bf6583e0b62b Mon Sep 17 00:00:00 2001 From: collerek Date: Sun, 10 Oct 2021 14:12:19 +0200 Subject: [PATCH 08/11] remove files option from pre commit config --- .pre-commit-config.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ddf33a4..a6dd943 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,15 +3,12 @@ repos: rev: 21.9b0 hooks: - id: black - files: ormar tests - repo: https://github.com/pycqa/flake8 rev: 3.9.2 hooks: - id: flake8 args: [ '--max-line-length=88' ] - files: ormar - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.910 hooks: - id: mypy - files: ormar tests From f6458be157baa3e6e4d769c924c0a76be5dee9ba Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 11 Oct 2021 12:22:47 +0200 Subject: [PATCH 09/11] fix coverage --- .pre-commit-config.yaml | 14 ++++++++++++++ ormar/fields/model_fields.py | 17 ++++++++++++++--- ormar/models/helpers/validation.py | 3 +++ tests/test_fastapi/test_choices_schema.py | 2 -- .../test_pydantic_fields_order.py | 4 ++-- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6dd943..c57598f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,3 +12,17 @@ repos: rev: v0.910 hooks: - id: mypy + args: [--no-strict-optional, --ignore-missing-imports] + additional_dependencies: [ + types-ujson>=0.1.1, + types-PyMySQL>=1.0.2, + types-ipaddress>=1.0.0, + types-enum34>=1.1.0, + types-cryptography>=3.3.5, + types-orjson>=3.6.0, + types-aiofiles>=0.1.9, + types-pkg-resources>=0.1.3, + types-requests>=2.25.9, + types-toml>=0.10.0, + pydantic>=1.8.2 + ] diff --git a/ormar/fields/model_fields.py b/ormar/fields/model_fields.py index a404204..a819b61 100644 --- a/ormar/fields/model_fields.py +++ b/ormar/fields/model_fields.py @@ -63,7 +63,11 @@ def is_auto_primary_key(primary_key: bool, autoincrement: bool) -> bool: def convert_choices_if_needed( - field_type: "Type", choices: Set, nullable: bool, scale: int = None + field_type: "Type", + choices: Set, + nullable: bool, + scale: int = None, + represent_as_str: bool = False, ) -> Set: """ Converts dates to isoformat as fastapi can check this condition in routes @@ -74,10 +78,14 @@ def convert_choices_if_needed( :param field_type: type o the field :type field_type: Type - :param nullable: set of choices - :type nullable: Set + :param choices: set of choices + :type choices: Set :param scale: scale for decimals :type scale: int + :param nullable: flag if field_nullable + :type nullable: bool + :param represent_as_str: flag for bytes fields + :type represent_as_str: bool :param scale: scale for decimals :type scale: int :return: value, choices list @@ -88,6 +96,8 @@ def convert_choices_if_needed( if field_type == decimal.Decimal: precision = scale choices = {encoder(o, precision) for o in choices} + elif field_type == bytes: + choices = {encoder(o, represent_as_str) for o in choices} elif encoder: choices = {encoder(o) for o in choices} if nullable: @@ -138,6 +148,7 @@ class ModelFieldFactory: choices=choices, nullable=nullable, scale=kwargs.get("scale", None), + represent_as_str=kwargs.get("represent_as_base64_str", False), ) namespace = dict( diff --git a/ormar/models/helpers/validation.py b/ormar/models/helpers/validation.py index 0ed1635..b3e8e15 100644 --- a/ormar/models/helpers/validation.py +++ b/ormar/models/helpers/validation.py @@ -63,6 +63,9 @@ def convert_value_if_needed(field: "BaseField", value: Any) -> Any: if field.__type__ == decimal.Decimal: precision = field.scale # type: ignore value = encoder(value, precision) + elif field.__type__ == bytes: + represent_as_string = field.represent_as_base64_str + value = encoder(value, represent_as_string) elif encoder: value = encoder(value) return value diff --git a/tests/test_fastapi/test_choices_schema.py b/tests/test_fastapi/test_choices_schema.py index da8a167..800c90e 100644 --- a/tests/test_fastapi/test_choices_schema.py +++ b/tests/test_fastapi/test_choices_schema.py @@ -121,8 +121,6 @@ def test_all_endpoints(): "blob_col": blob.decode("utf-8"), }, ) - if response.status_code != 200: - print(response.text) assert response.status_code == 200 item = Organisation(**response.json()) assert item.pk is not None diff --git a/tests/test_inheritance_and_pydantic_generation/test_pydantic_fields_order.py b/tests/test_inheritance_and_pydantic_generation/test_pydantic_fields_order.py index 96d5f98..ffc515e 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_pydantic_fields_order.py +++ b/tests/test_inheritance_and_pydantic_generation/test_pydantic_fields_order.py @@ -14,7 +14,7 @@ class BaseMeta(ormar.ModelMeta): metadata = metadata -class TestModel(ormar.Model): +class NewTestModel(ormar.Model): class Meta: database = database metadata = metadata @@ -37,5 +37,5 @@ def create_test_database(): def test_model_field_order(): - TestCreate = TestModel.get_pydantic(exclude={"a"}) + TestCreate = NewTestModel.get_pydantic(exclude={"a"}) assert list(TestCreate.__fields__.keys()) == ["b", "c", "d", "e", "f"] From 9559c0f7f603c95be6b392155895625dc5d161e2 Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 11 Oct 2021 16:22:50 +0200 Subject: [PATCH 10/11] inherit choices validators and class validators for fields in generated pydantic models --- ormar/models/helpers/pydantic.py | 3 +- ormar/models/helpers/relations.py | 1 + ormar/models/mixins/pydantic_mixin.py | 35 ++++++++++++++++++- .../test_excludes_with_get_pydantic.py | 9 +++-- ...dels.py => test_geting_pydantic_models.py} | 0 .../test_validators_in_generated_pydantic.py | 23 +++--------- 6 files changed, 46 insertions(+), 25 deletions(-) rename tests/test_inheritance_and_pydantic_generation/{test_geting_the_pydantic_models.py => test_geting_pydantic_models.py} (100%) diff --git a/ormar/models/helpers/pydantic.py b/ormar/models/helpers/pydantic.py index 1423176..03ab736 100644 --- a/ormar/models/helpers/pydantic.py +++ b/ormar/models/helpers/pydantic.py @@ -50,9 +50,10 @@ def get_pydantic_field(field_name: str, model: Type["Model"]) -> "ModelField": :return: newly created pydantic field :rtype: pydantic.ModelField """ + type_ = model.Meta.model_fields[field_name].__type__ return ModelField( name=field_name, - type_=model.Meta.model_fields[field_name].__type__, # type: ignore + type_=type_, # type: ignore model_config=model.__config__, required=not model.Meta.model_fields[field_name].nullable, class_validators={}, diff --git a/ormar/models/helpers/relations.py b/ormar/models/helpers/relations.py index 68bf893..c6d1ba4 100644 --- a/ormar/models/helpers/relations.py +++ b/ormar/models/helpers/relations.py @@ -101,6 +101,7 @@ def register_reverse_model_fields(model_field: "ForeignKeyField") -> None: :type model_field: relation Field """ related_name = model_field.get_related_name() + # TODO: Reverse relations does not register pydantic fields? if model_field.is_multi: model_field.to.Meta.model_fields[related_name] = ManyToMany( # type: ignore model_field.owner, diff --git a/ormar/models/mixins/pydantic_mixin.py b/ormar/models/mixins/pydantic_mixin.py index ce63f05..1ecdd89 100644 --- a/ormar/models/mixins/pydantic_mixin.py +++ b/ormar/models/mixins/pydantic_mixin.py @@ -1,3 +1,4 @@ +import copy import string from random import choices from typing import ( @@ -82,7 +83,9 @@ class PydanticMixin(RelationMixin): (pydantic.BaseModel,), {"__annotations__": fields_dict, **defaults}, ) - return cast(Type[pydantic.BaseModel], model) + model = cast(Type[pydantic.BaseModel], model) + cls._copy_field_validators(model=model) + return model @classmethod def _determine_pydantic_field_type( @@ -111,3 +114,33 @@ class PydanticMixin(RelationMixin): if target is not None and field.nullable: target = Optional[target] return target + + @classmethod + def _copy_field_validators(cls, model: Type[pydantic.BaseModel]) -> None: + """ + Copy field validators from ormar model to generated pydantic model. + """ + for field_name, field in model.__fields__.items(): + if ( + field_name not in cls.__fields__ + or cls.Meta.model_fields[field_name].is_relation + ): + continue + validators = cls.__fields__[field_name].validators + already_attached = [ + validator.__wrapped__ for validator in field.validators # type: ignore + ] + validators_to_copy = [ + validator + for validator in validators + if validator.__wrapped__ not in already_attached # type: ignore + ] + field.validators.extend(copy.deepcopy(validators_to_copy)) + class_validators = cls.__fields__[field_name].class_validators + field.class_validators.update(copy.deepcopy(class_validators)) + field.pre_validators = copy.deepcopy( + cls.__fields__[field_name].pre_validators + ) + field.post_validators = copy.deepcopy( + cls.__fields__[field_name].post_validators + ) diff --git a/tests/test_fastapi/test_excludes_with_get_pydantic.py b/tests/test_fastapi/test_excludes_with_get_pydantic.py index d9e9de4..b18976a 100644 --- a/tests/test_fastapi/test_excludes_with_get_pydantic.py +++ b/tests/test_fastapi/test_excludes_with_get_pydantic.py @@ -4,11 +4,8 @@ from fastapi import FastAPI from starlette.testclient import TestClient from tests.settings import DATABASE_URL -from tests.test_inheritance_and_pydantic_generation.test_geting_the_pydantic_models import ( +from tests.test_inheritance_and_pydantic_generation.test_geting_pydantic_models import ( Category, - Item, - MutualA, - MutualB, SelfRef, database, metadata, @@ -53,7 +50,9 @@ app.post("/categories/", response_model=Category)(create_category) response_model=SelfRef.get_pydantic(exclude={"parent", "children__name"}), ) async def create_selfref( - selfref: SelfRef.get_pydantic(exclude={"children__name"}), # type: ignore + selfref: SelfRef.get_pydantic( # type: ignore + exclude={"children__name"} # noqa: F821 + ), ): selfr = SelfRef(**selfref.dict()) await selfr.save() diff --git a/tests/test_inheritance_and_pydantic_generation/test_geting_the_pydantic_models.py b/tests/test_inheritance_and_pydantic_generation/test_geting_pydantic_models.py similarity index 100% rename from tests/test_inheritance_and_pydantic_generation/test_geting_the_pydantic_models.py rename to tests/test_inheritance_and_pydantic_generation/test_geting_pydantic_models.py diff --git a/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py index dc533d9..270d14e 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py +++ b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py @@ -5,7 +5,6 @@ import pydantic import pytest import sqlalchemy from pydantic import ValidationError -from pydantic.class_validators import make_generic_validator import ormar @@ -44,26 +43,14 @@ class ModelExample(ormar.Model): raise ValueError("must contain a space") return v - -def validate_str_field(cls, v): - if " " not in v: - raise ValueError("must contain a space") - return v - - -def validate_choices(cls, v): - if v not in list(EnumExample): - raise ValueError(f"{v} is not in allowed choices: {list(EnumExample)}") - return v + @pydantic.validator("str_field") + def validate_str_field2(cls, v): + if " " not in v: + raise ValueError("must contain a space") + return v ModelExampleCreate = ModelExample.get_pydantic(exclude={"id"}) -ModelExampleCreate.__fields__["str_field"].validators.append( - make_generic_validator(validate_str_field) -) -ModelExampleCreate.__fields__["enum_field"].validators.append( - make_generic_validator(validate_choices) -) def test_ormar_validator(): From 10e2d01a9108c072a29fcd0d2caf654c7c462bd2 Mon Sep 17 00:00:00 2001 From: collerek Date: Tue, 12 Oct 2021 18:52:40 +0200 Subject: [PATCH 11/11] update docs, bump version, update releases --- docs/models/methods.md | 27 ++++++++++++++++++- docs/releases.md | 7 +++++ pyproject.toml | 2 +- .../test_validators_in_generated_pydantic.py | 6 ----- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/models/methods.md b/docs/models/methods.md index 0761a29..7cbecea 100644 --- a/docs/models/methods.md +++ b/docs/models/methods.md @@ -17,6 +17,25 @@ especially `dict()` and `json()` methods that can also accept `exclude`, `includ To read more check [pydantic][pydantic] documentation +## construct + +`construct` is a raw equivalent of `__init__` method used for construction of new instances. + +The difference is that `construct` skips validations, so it should be used when you know that data is correct and can be trusted. +The benefit of using construct is the speed of execution due to skipped validation. + +!!!note + Note that in contrast to `pydantic.construct` method - the `ormar` equivalent will also process the nested related models. + +!!!warning + Bear in mind that due to skipped validation the `construct` method does not perform any conversions, checks etc. + So it's your responsibility to provide tha data that is valid and can be consumed by the database. + + The only two things that construct still performs are: + + * Providing a `default` value for not set fields + * Initialize nested ormar models if you pass a dictionary or a primary key value + ## dict `dict` is a method inherited from `pydantic`, yet `ormar` adds its own parameters and has some nuances when working with default values, @@ -363,10 +382,16 @@ class Category(BaseModel): items: Optional[List[Item]] ``` -Of course you can use also deeply nested structures and ormar will generate it pydantic equivalent you (in a way that exclude loops). +Of course, you can use also deeply nested structures and ormar will generate it pydantic equivalent you (in a way that exclude loops). Note how `Item` model above does not have a reference to `Category` although in ormar the relation is bidirectional (and `ormar.Item` has `categories` field). +!!!warning + Note that the generated pydantic model will inherit all **field** validators from the original `ormar` model, that includes the ormar choices validator as well as validators defined with `pydantic.validator` decorator. + + But, at the same time all root validators present on `ormar` models will **NOT** be copied to the generated pydantic model. Since root validator can operate on all fields and a user can exclude some fields during generation of pydantic model it's not safe to copy those validators. + If required, you need to redefine/ manually copy them to generated pydantic model. + ## load By default when you query a table without prefetching related models, the ormar will still construct diff --git a/docs/releases.md b/docs/releases.md index babf6e6..e2b11bf 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,10 @@ +# 0.10.21 + +## 🐛 Fixes + +* Add `ormar` implementation of `construct` classmethod that allows to build `Model` instances without validating the input to speed up the whole flow, if your data is already validated [#318](https://github.com/collerek/ormar/issues/318) +* Fix for "inheriting" field validators from `ormar` model when newly created pydanic model is generated with `get_pydantic` [#365](https://github.com/collerek/ormar/issues/365) + # 0.10.20 ## ✨ Features diff --git a/pyproject.toml b/pyproject.toml index 1b71ed1..3b8f9d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ormar" -version = "0.10.20" +version = "0.10.21" description = "A simple async ORM with fastapi in mind and pydantic validation." authors = ["Radosław Drążkiewicz "] license = "MIT" diff --git a/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py index 270d14e..d105481 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py +++ b/tests/test_inheritance_and_pydantic_generation/test_validators_in_generated_pydantic.py @@ -43,12 +43,6 @@ class ModelExample(ormar.Model): raise ValueError("must contain a space") return v - @pydantic.validator("str_field") - def validate_str_field2(cls, v): - if " " not in v: - raise ValueError("must contain a space") - return v - ModelExampleCreate = ModelExample.get_pydantic(exclude={"id"})