From f2b36ede482403a1d7631dca4cf7151898472598 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Sun, 18 Dec 2022 15:35:39 -0500 Subject: [PATCH] remove __allow_unmapped__ requirement from dataclasses Removed the requirement that the ``__allow_unmapped__`` attribute be used on Declarative Dataclass Mapped class when non-``Mapped[]`` annotations are detected; previously, an error message that was intended to support legacy ORM typed mappings would be raised, which additionally did not mention correct patterns to use with Dataclasses specifically. This error message is now no longer raised if :meth:`_orm.registry.mapped_as_dataclass` or :class:`_orm.MappedAsDataclass` is used. Fixes: #8973 Change-Id: I887afcc2da83dd904444bcb97f31e695b9f8b443 --- doc/build/changelog/unreleased_20/8973.rst | 16 ++++ doc/build/orm/dataclasses.rst | 88 ++++++++++++++++++++++ lib/sqlalchemy/orm/decl_base.py | 2 +- test/orm/declarative/test_dc_transforms.py | 52 +++++++------ 4 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/8973.rst diff --git a/doc/build/changelog/unreleased_20/8973.rst b/doc/build/changelog/unreleased_20/8973.rst new file mode 100644 index 0000000000..a2ac32099a --- /dev/null +++ b/doc/build/changelog/unreleased_20/8973.rst @@ -0,0 +1,16 @@ +.. change:: + :tags: usecase, orm + :tickets: 8973 + + Removed the requirement that the ``__allow_unmapped__`` attribute be used + on Declarative Dataclass Mapped class when non-``Mapped[]`` annotations are + detected; previously, an error message that was intended to support legacy + ORM typed mappings would be raised, which additionally did not mention + correct patterns to use with Dataclasses specifically. This error message + is now no longer raised if :meth:`_orm.registry.mapped_as_dataclass` or + :class:`_orm.MappedAsDataclass` is used. + + .. seealso:: + + :ref:`orm_declarative_native_dataclasses_non_mapped_fields` + diff --git a/doc/build/orm/dataclasses.rst b/doc/build/orm/dataclasses.rst index a0ac0e3cb5..0e2e5a9705 100644 --- a/doc/build/orm/dataclasses.rst +++ b/doc/build/orm/dataclasses.rst @@ -381,7 +381,95 @@ of :paramref:`_orm.relationship.default_factory` or :paramref:`_orm.relationship.default` is what determines if the parameter is to be required or optional when rendered into the ``__init__()`` method. +.. _orm_declarative_native_dataclasses_non_mapped_fields: +Using Non-Mapped Dataclass Fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When using Declarative dataclasses, non-mapped fields may be used on the +class as well, which will be part of the dataclass construction process but +will not be mapped. Any field that does not use :class:`.Mapped` will +be ignored by the mapping process. In the example below, the fields +``ctrl_one`` and ``ctrl_two`` will be part of the instance-level state +of the object, but will not be persisted by the ORM:: + + + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + from sqlalchemy.orm import registry + + reg = registry() + + + @reg.mapped_as_dataclass + class Data: + __tablename__ = "data" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + status: Mapped[str] + + ctrl_one: Optional[str] = None + ctrl_two: Optional[str] = None + +Instance of ``Data`` above can be created as:: + + d1 = Data(status="s1", ctrl_one="ctrl1", ctrl_two="ctrl2") + +A more real world example might be to make use of the Dataclasses +``InitVar`` feature in conjunction with the ``__post_init__()`` feature to +receive init-only fields that can be used to compose persisted data. +In the example below, the ``User`` +class is declared using ``id``, ``name`` and ``password_hash`` as mapped features, +but makes use of init-only ``password`` and ``repeat_password`` fields to +represent the user creation process (note: to run this example, replace +the function ``your_crypt_function_here()`` with a third party crypt +function, such as `bcrypt `_ or +`argon2-cffi `_):: + + from dataclasses import InitVar + from typing import Optional + + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + from sqlalchemy.orm import registry + + reg = registry() + + + @reg.mapped_as_dataclass + class User: + __tablename__ = "user_account" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + name: Mapped[str] + + password: InitVar[str] + repeat_password: InitVar[str] + + password_hash: Mapped[str] = mapped_column(init=False, nullable=False) + + def __post_init__(self, password: str, repeat_password: str): + if password != repeat_password: + raise ValueError("passwords do not match") + + self.password_hash = your_crypt_function_here(password) + +The above object is created with parameters ``password`` and +``repeat_password``, which are consumed up front so that the ``password_hash`` +variable may be generated:: + + >>> u1 = User(name="some_user", password="xyz", repeat_password="xyz") + >>> u1.password_hash + '$6$9ppc... (example crypted string....)' + +.. versionchanged:: 2.0.0b5 When using :meth:`_orm.registry.mapped_as_dataclass` + or :class:`.MappedAsDataclass`, fields that do not include the + :class:`.Mapped` annotation may be included, which will be treated as part + of the resulting dataclass but not be mapped, without the need to + also indicate the ``__allow_unmapped__`` class attribute. Previous 2.0 + beta releases would require this attribute to be explicitly present, + even though the purpose of this attribute was only to allow legacy + ORM typed mappings to continue to function. .. _orm_declarative_dataclasses: diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index 797828377e..db1fafa4c3 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -516,7 +516,7 @@ class _ClassScanMapperConfig(_MapperConfig): self.allow_unmapped_annotations = getattr( self.cls, "__allow_unmapped__", False - ) + ) or bool(self.dataclass_setup_arguments) self.is_dataclass_prior_to_mapping = cld = dataclasses.is_dataclass( cls_ diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 202eaef4ad..5f35d7a01c 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -1,4 +1,5 @@ import dataclasses +from dataclasses import InitVar import inspect as pyinspect from itertools import product from typing import Any @@ -50,7 +51,6 @@ from sqlalchemy.testing import is_false from sqlalchemy.testing import is_true from sqlalchemy.testing import ne_ from sqlalchemy.util import compat -from .test_typed_mapping import expect_annotation_syntax_error class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): @@ -368,28 +368,11 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(e1.engineer_name, "en") eq_(e1.primary_language, "pl") - def test_no_fields_wo_mapped_or_dc( - self, dc_decl_base: Type[MappedAsDataclass] - ): - """since I made this mistake in my own mapping video, lets have it - raise an error""" - - with expect_annotation_syntax_error("A.data"): - - class A(dc_decl_base): - __tablename__ = "a" - - id: Mapped[int] = mapped_column(primary_key=True, init=False) - data: str - ctrl_one: str = dataclasses.field() - some_field: int = dataclasses.field(default=5) - - def test_allow_unmapped_fields_wo_mapped_or_dc( + def test_non_mapped_fields_wo_mapped_or_dc( self, dc_decl_base: Type[MappedAsDataclass] ): class A(dc_decl_base): __tablename__ = "a" - __allow_unmapped__ = True id: Mapped[int] = mapped_column(primary_key=True, init=False) data: str @@ -407,12 +390,11 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): }, ) - def test_allow_unmapped_fields_wo_mapped_or_dc_w_inherits( + def test_non_mapped_fields_wo_mapped_or_dc_w_inherits( self, dc_decl_base: Type[MappedAsDataclass] ): class A(dc_decl_base): __tablename__ = "a" - __allow_unmapped__ = True id: Mapped[int] = mapped_column(primary_key=True, init=False) data: str @@ -439,6 +421,34 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): }, ) + def test_init_var(self, dc_decl_base: Type[MappedAsDataclass]): + class User(dc_decl_base): + __tablename__ = "user_account" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + name: Mapped[str] + + password: InitVar[str] + repeat_password: InitVar[str] + + password_hash: Mapped[str] = mapped_column( + init=False, nullable=False + ) + + def __post_init__(self, password: str, repeat_password: str): + if password != repeat_password: + raise ValueError("passwords do not match") + + self.password_hash = f"some hash... {password}" + + u1 = User(name="u1", password="p1", repeat_password="p1") + eq_(u1.password_hash, "some hash... p1") + self.assert_compile( + select(User), + "SELECT user_account.id, user_account.name, " + "user_account.password_hash FROM user_account", + ) + def test_integrated_dc(self, dc_decl_base: Type[MappedAsDataclass]): """We will be telling users "this is a dataclass that is also mapped". Therefore, they will want *any* kind of attribute to do what -- 2.47.2