summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/build/changelog/unreleased_20/8973.rst16
-rw-r--r--doc/build/orm/dataclasses.rst88
-rw-r--r--lib/sqlalchemy/orm/decl_base.py2
-rw-r--r--test/orm/declarative/test_dc_transforms.py52
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