]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add function mapped_as_dataclass
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 9 Sep 2025 19:16:45 +0000 (15:16 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 10 Sep 2025 18:53:12 +0000 (14:53 -0400)
Added new decorator :func:`_orm.mapped_as_dataclass`, which is a function
based form of :meth:`_orm.registry.mapped_as_dataclass`; the method form
:meth:`_orm.registry.mapped_as_dataclass` does not seem to be correctly
recognized within the scope of :pep:`681` in recent mypy versions.

The new function is tested and mentioned in the docs, in 2.1 in a
subsequent patch (probably the one that adds unmapped_dataclass also)
we'll switch this new decorator to be the prominent one.

also alphabetize mapping_api.rst.  while the summary box at the top
auto-sorts, have the sidebar alpha also, it's kind of weird how
these were in no order at all

Fixes: #12855
Change-Id: If98724fd466004ec4c8a312a0cbf1c934a6ce9e3

doc/build/changelog/unreleased_20/12855.rst [new file with mode: 0644]
doc/build/orm/dataclasses.rst
doc/build/orm/mapping_api.rst
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/decl_api.py
test/orm/declarative/test_dc_transforms.py
test/orm/declarative/test_dc_transforms_future_anno_sync.py
test/typing/plain_files/orm/dataclass_transforms_decorator.py [new file with mode: 0644]

diff --git a/doc/build/changelog/unreleased_20/12855.rst b/doc/build/changelog/unreleased_20/12855.rst
new file mode 100644 (file)
index 0000000..c33110a
--- /dev/null
@@ -0,0 +1,8 @@
+.. change::
+    :tags: bug, typing
+    :tickets: 12855
+
+    Added new decorator :func:`_orm.mapped_as_dataclass`, which is a function
+    based form of :meth:`_orm.registry.mapped_as_dataclass`; the method form
+    :meth:`_orm.registry.mapped_as_dataclass` does not seem to be correctly
+    recognized within the scope of :pep:`681` in recent mypy versions.
index f8af3fb8d69db33b9b2313c8466a945ea1b2701b..2062b7fd0f0100fa26ee902eb803b45cff6b87b8 100644 (file)
@@ -95,7 +95,7 @@ Or may be applied directly to classes that extend from the Declarative base::
         id: Mapped[int] = mapped_column(init=False, primary_key=True)
         name: Mapped[str]
 
-When using the decorator form, only the :meth:`_orm.registry.mapped_as_dataclass`
+When using the decorator form, the :meth:`_orm.registry.mapped_as_dataclass`
 decorator is supported::
 
     from sqlalchemy.orm import Mapped
@@ -113,6 +113,28 @@ decorator is supported::
         id: Mapped[int] = mapped_column(init=False, primary_key=True)
         name: Mapped[str]
 
+The same method is available in a standalone function form, which may
+have better compatibility with some versions of the mypy type checker::
+
+    from sqlalchemy.orm import Mapped
+    from sqlalchemy.orm import mapped_as_dataclass
+    from sqlalchemy.orm import mapped_column
+    from sqlalchemy.orm import registry
+
+
+    reg = registry()
+
+
+    @mapped_as_dataclass(reg)
+    class User:
+        __tablename__ = "user_account"
+
+        id: Mapped[int] = mapped_column(init=False, primary_key=True)
+        name: Mapped[str]
+
+.. versionadded:: 2.0.44 Added :func:`_orm.mapped_as_dataclass` after observing
+   mypy compatibility issues with the method form of the same feature
+
 Class level feature configuration
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -142,8 +164,9 @@ class configuration arguments are passed as class-level parameters::
         id: Mapped[int] = mapped_column(init=False, primary_key=True)
         name: Mapped[str]
 
-When using the decorator form with :meth:`_orm.registry.mapped_as_dataclass`,
-class configuration arguments are passed to the decorator directly::
+When using the decorator form with :meth:`_orm.registry.mapped_as_dataclass` or
+:func:`_orm.mapped_as_dataclass`, class configuration arguments are passed to
+the decorator directly::
 
     from sqlalchemy.orm import registry
     from sqlalchemy.orm import Mapped
index f453429759951ee2fe053a88dba223ff8558a2e3..bcab2067bd10480e287dad606ab8081d7b39afc1 100644 (file)
@@ -4,18 +4,27 @@
 Class Mapping API
 =================
 
-.. autoclass:: registry
-    :members:
-
 .. autofunction:: add_mapped_attribute
 
+.. autofunction:: as_declarative
+
+.. autofunction:: class_mapper
+
+.. autofunction:: clear_mappers
+
 .. autofunction:: column_property
 
+.. autofunction:: configure_mappers
+
 .. autofunction:: declarative_base
 
-.. autofunction:: as_declarative
+.. autoclass:: DeclarativeBase
+    :members:
+    :special-members: __table__, __mapper__, __mapper_args__, __tablename__, __table_args__
 
-.. autofunction:: mapped_column
+.. autoclass:: DeclarativeBaseNoMeta
+    :members:
+    :special-members: __table__, __mapper__, __mapper_args__, __tablename__, __table_args__
 
 .. autoclass:: declared_attr
 
@@ -107,39 +116,34 @@ Class Mapping API
             :class:`_orm.declared_attr`
 
 
-.. autoclass:: DeclarativeBase
-    :members:
-    :special-members: __table__, __mapper__, __mapper_args__, __tablename__, __table_args__
-
-.. autoclass:: DeclarativeBaseNoMeta
-    :members:
-    :special-members: __table__, __mapper__, __mapper_args__, __tablename__, __table_args__
-
 .. autofunction:: has_inherited_table
 
-.. autofunction:: synonym_for
+.. autofunction:: sqlalchemy.orm.util.identity_key
 
-.. autofunction:: object_mapper
+.. autofunction:: mapped_as_dataclass
 
-.. autofunction:: class_mapper
+.. autofunction:: mapped_column
 
-.. autofunction:: configure_mappers
+.. autoclass:: MappedAsDataclass
+    :members:
 
-.. autofunction:: clear_mappers
+.. autoclass:: MappedClassProtocol
+    :no-members:
 
-.. autofunction:: sqlalchemy.orm.util.identity_key
+.. autoclass:: Mapper
+   :members:
 
-.. autofunction:: polymorphic_union
+.. autofunction:: object_mapper
 
 .. autofunction:: orm_insert_sentinel
 
-.. autofunction:: reconstructor
+.. autofunction:: polymorphic_union
 
-.. autoclass:: Mapper
-   :members:
+.. autofunction:: reconstructor
 
-.. autoclass:: MappedAsDataclass
+.. autoclass:: registry
     :members:
 
-.. autoclass:: MappedClassProtocol
-    :no-members:
+.. autofunction:: synonym_for
+
+
index 7771de47eb2dd9df0dc766f70c69e8fa38a77af5..bd957fb4cbeb429e2ba6bd35f65394af694a4f81 100644 (file)
@@ -63,6 +63,7 @@ from .decl_api import DeclarativeBaseNoMeta as DeclarativeBaseNoMeta
 from .decl_api import DeclarativeMeta as DeclarativeMeta
 from .decl_api import declared_attr as declared_attr
 from .decl_api import has_inherited_table as has_inherited_table
+from .decl_api import mapped_as_dataclass as mapped_as_dataclass
 from .decl_api import MappedAsDataclass as MappedAsDataclass
 from .decl_api import registry as registry
 from .decl_api import synonym_for as synonym_for
index b99906ed61bd5d1e2d98b3dc36198687666e560a..7200faeec2a22d9d8e6d06ed272353594a2d7f8e 100644 (file)
@@ -1594,6 +1594,8 @@ class registry:
             :ref:`orm_declarative_native_dataclasses` - complete background
             on SQLAlchemy native dataclass mapping
 
+            :func:`_orm.mapped_as_dataclass` - functional version that may
+            provide better compatibility with mypy
 
         .. versionadded:: 2.0
 
@@ -1855,6 +1857,73 @@ def as_declarative(**kw: Any) -> Callable[[Type[_T]], Type[_T]]:
     ).as_declarative_base(**kw)
 
 
+@compat_typing.dataclass_transform(
+    field_specifiers=(
+        MappedColumn,
+        RelationshipProperty,
+        Composite,
+        Synonym,
+        mapped_column,
+        relationship,
+        composite,
+        synonym,
+        deferred,
+    ),
+)
+def mapped_as_dataclass(
+    registry: RegistryType,
+    /,
+    *,
+    init: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    repr: Union[_NoArg, bool] = _NoArg.NO_ARG,  # noqa: A002
+    eq: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    order: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    unsafe_hash: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    match_args: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    kw_only: Union[_NoArg, bool] = _NoArg.NO_ARG,
+    dataclass_callable: Union[
+        _NoArg, Callable[..., Type[Any]]
+    ] = _NoArg.NO_ARG,
+) -> Callable[[Type[_O]], Type[_O]]:
+    """Standalone function form of :meth:`_orm.registry.mapped_as_dataclass`
+    which may have better compatibility with mypy.
+
+    The :class:`_orm.registry` is passed as the first argument to the
+    decorator.
+
+    e.g.::
+
+        from sqlalchemy.orm import Mapped
+        from sqlalchemy.orm import mapped_as_dataclass
+        from sqlalchemy.orm import mapped_column
+        from sqlalchemy.orm import registry
+
+        some_registry = registry()
+
+
+        @mapped_as_dataclass(some_registry)
+        class Relationships:
+            __tablename__ = "relationships"
+
+            entity_id1: Mapped[int] = mapped_column(primary_key=True)
+            entity_id2: Mapped[int] = mapped_column(primary_key=True)
+            level: Mapped[int] = mapped_column(Integer)
+
+    .. versionadded:: 2.0.44
+
+    """
+    return registry.mapped_as_dataclass(
+        init=init,
+        repr=repr,
+        eq=eq,
+        order=order,
+        unsafe_hash=unsafe_hash,
+        match_args=match_args,
+        kw_only=kw_only,
+        dataclass_callable=dataclass_callable,
+    )
+
+
 @inspection._inspects(
     DeclarativeMeta, DeclarativeBase, DeclarativeAttributeIntercept
 )
index 6d26c58f02ce122402e8bbe249d19e1e149b46ac..d16b225a1285ec33eaecc0184f73c7614cd58ee6 100644 (file)
@@ -36,6 +36,7 @@ from sqlalchemy.orm import declared_attr
 from sqlalchemy.orm import deferred
 from sqlalchemy.orm import interfaces
 from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import mapped_as_dataclass
 from sqlalchemy.orm import mapped_column
 from sqlalchemy.orm import MappedAsDataclass
 from sqlalchemy.orm import MappedColumn
@@ -313,7 +314,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
 
     # TODO: get this test to work with future anno mode as well
     # anno only: @testing.exclusions.closed("doesn't work for future annotations mode yet")  # noqa: E501
-    @testing.variation("dc_type", ["decorator", "superclass"])
+    @testing.variation("dc_type", ["fn_decorator", "decorator", "superclass"])
     def test_dataclass_fn(self, dc_type: Variation):
         annotations = {}
 
@@ -321,7 +322,19 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             annotations[kls] = kls.__annotations__
             return dataclasses.dataclass(kls, **kw)  # type: ignore
 
-        if dc_type.decorator:
+        if dc_type.fn_decorator:
+            reg = registry()
+
+            @mapped_as_dataclass(reg, dataclass_callable=dc_callable)
+            class MappedClass:
+                __tablename__ = "mapped_class"
+
+                id: Mapped[int] = mapped_column(primary_key=True)
+                name: Mapped[str]
+
+            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+
+        elif dc_type.decorator:
             reg = registry()
 
             @reg.mapped_as_dataclass(dataclass_callable=dc_callable)
@@ -805,7 +818,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             "are not being mixed. ",
         ):
 
-            @registry.mapped_as_dataclass
+            @mapped_as_dataclass(registry)
             class Foo(Mixin):
                 bar_value: Mapped[float] = mapped_column(default=78)
 
@@ -990,7 +1003,7 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase):
     def test_replace_operation_works_w_history_etc(
         self, registry: _RegistryType
     ):
-        @registry.mapped_as_dataclass
+        @mapped_as_dataclass(registry)
         class A:
             __tablename__ = "a"
 
@@ -1003,7 +1016,7 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase):
                 default_factory=list
             )
 
-        @registry.mapped_as_dataclass
+        @mapped_as_dataclass(registry)
         class B:
             __tablename__ = "b"
 
@@ -1697,13 +1710,20 @@ class DataclassArgsTest(fixtures.TestBase):
             )
             eq_(fas.kwonlyargs, [])
 
+    @testing.variation("decorator_type", ["fn", "method"])
     def test_dc_arguments_decorator(
         self,
         dc_argument_fixture,
         mapped_expr_constructor,
         registry: _RegistryType,
+        decorator_type,
     ):
-        @registry.mapped_as_dataclass(**dc_argument_fixture[0])
+        if decorator_type.fn:
+            dec = mapped_as_dataclass(registry, **dc_argument_fixture[0])
+        else:
+            dec = registry.mapped_as_dataclass(**dc_argument_fixture[0])
+
+        @dec
         class A:
             __tablename__ = "a"
 
index a5e2c53c456897554b976a0bb223b5fe7be0ed33..b326fefc984229d4466ef81a201d6b255fc27a75 100644 (file)
@@ -45,6 +45,7 @@ from sqlalchemy.orm import declared_attr
 from sqlalchemy.orm import deferred
 from sqlalchemy.orm import interfaces
 from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import mapped_as_dataclass
 from sqlalchemy.orm import mapped_column
 from sqlalchemy.orm import MappedAsDataclass
 from sqlalchemy.orm import MappedColumn
@@ -326,7 +327,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
     @testing.exclusions.closed(
         "doesn't work for future annotations mode yet"
     )  # noqa: E501
-    @testing.variation("dc_type", ["decorator", "superclass"])
+    @testing.variation("dc_type", ["fn_decorator", "decorator", "superclass"])
     def test_dataclass_fn(self, dc_type: Variation):
         annotations = {}
 
@@ -334,7 +335,19 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             annotations[kls] = kls.__annotations__
             return dataclasses.dataclass(kls, **kw)  # type: ignore
 
-        if dc_type.decorator:
+        if dc_type.fn_decorator:
+            reg = registry()
+
+            @mapped_as_dataclass(reg, dataclass_callable=dc_callable)
+            class MappedClass:
+                __tablename__ = "mapped_class"
+
+                id: Mapped[int] = mapped_column(primary_key=True)
+                name: Mapped[str]
+
+            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+
+        elif dc_type.decorator:
             reg = registry()
 
             @reg.mapped_as_dataclass(dataclass_callable=dc_callable)
@@ -818,7 +831,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             "are not being mixed. ",
         ):
 
-            @registry.mapped_as_dataclass
+            @mapped_as_dataclass(registry)
             class Foo(Mixin):
                 bar_value: Mapped[float] = mapped_column(default=78)
 
@@ -1003,7 +1016,7 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase):
     def test_replace_operation_works_w_history_etc(
         self, registry: _RegistryType
     ):
-        @registry.mapped_as_dataclass
+        @mapped_as_dataclass(registry)
         class A:
             __tablename__ = "a"
 
@@ -1016,7 +1029,7 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase):
                 default_factory=list
             )
 
-        @registry.mapped_as_dataclass
+        @mapped_as_dataclass(registry)
         class B:
             __tablename__ = "b"
 
@@ -1716,13 +1729,20 @@ class DataclassArgsTest(fixtures.TestBase):
             )
             eq_(fas.kwonlyargs, [])
 
+    @testing.variation("decorator_type", ["fn", "method"])
     def test_dc_arguments_decorator(
         self,
         dc_argument_fixture,
         mapped_expr_constructor,
         registry: _RegistryType,
+        decorator_type,
     ):
-        @registry.mapped_as_dataclass(**dc_argument_fixture[0])
+        if decorator_type.fn:
+            dec = mapped_as_dataclass(registry, **dc_argument_fixture[0])
+        else:
+            dec = registry.mapped_as_dataclass(**dc_argument_fixture[0])
+
+        @dec
         class A:
             __tablename__ = "a"
 
diff --git a/test/typing/plain_files/orm/dataclass_transforms_decorator.py b/test/typing/plain_files/orm/dataclass_transforms_decorator.py
new file mode 100644 (file)
index 0000000..01114c5
--- /dev/null
@@ -0,0 +1,23 @@
+from sqlalchemy import Integer
+from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import mapped_as_dataclass
+from sqlalchemy.orm import mapped_column
+from sqlalchemy.orm import registry
+
+some_target_tables_registry = registry()
+
+
+@mapped_as_dataclass(some_target_tables_registry)
+class Relationships:
+    __tablename__ = "relationships"
+
+    entity_id1: Mapped[int] = mapped_column(primary_key=True)
+    entity_id2: Mapped[int] = mapped_column(primary_key=True)
+    level: Mapped[int] = mapped_column(Integer)
+
+
+rs = Relationships(entity_id1=1, entity_id2=2, level=1)
+
+
+# EXPECTED_TYPE: int
+reveal_type(rs.entity_id1)