]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
remove __allow_unmapped__ requirement from dataclasses
authorMike Bayer <mike_mp@zzzcomputing.com>
Sun, 18 Dec 2022 20:35:39 +0000 (15:35 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 19 Dec 2022 14:57:36 +0000 (09:57 -0500)
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 [new file with mode: 0644]
doc/build/orm/dataclasses.rst
lib/sqlalchemy/orm/decl_base.py
test/orm/declarative/test_dc_transforms.py

diff --git a/doc/build/changelog/unreleased_20/8973.rst b/doc/build/changelog/unreleased_20/8973.rst
new file mode 100644 (file)
index 0000000..a2ac320
--- /dev/null
@@ -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`
+
index a0ac0e3cb5095b9d203d7b556528dd961ca7c023..0e2e5a9705ff234c5fea1340fa30fbad7c59f6f3 100644 (file)
@@ -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:
 
index 797828377e0dd6772da641a5b0a28e909d1e5a32..db1fafa4c3d18d1da3953db40395edc2cfc3f2b9 100644 (file)
@@ -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_
index 202eaef4ad19cbf4648bc0329d1e0eeea54409dd..5f35d7a01cfeb26ec8083b1ade4e4e7a92bdb453 100644 (file)
@@ -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