diff options
| -rw-r--r-- | doc/build/changelog/unreleased_20/8973.rst | 16 | ||||
| -rw-r--r-- | doc/build/orm/dataclasses.rst | 88 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/decl_base.py | 2 | ||||
| -rw-r--r-- | test/orm/declarative/test_dc_transforms.py | 52 |
4 files changed, 136 insertions, 22 deletions
diff --git a/doc/build/changelog/unreleased_20/8973.rst b/doc/build/changelog/unreleased_20/8973.rst new file mode 100644 index 000000000..a2ac32099 --- /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 a0ac0e3cb..0e2e5a970 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 <https://pypi.org/project/bcrypt/>`_ or +`argon2-cffi <https://pypi.org/project/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 797828377..db1fafa4c 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 202eaef4a..5f35d7a01 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 |
