From adabce3aa6ce25e785762b5a28a294e9112b03b5 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Tue, 9 Sep 2025 15:16:45 -0400 Subject: [PATCH] Add function mapped_as_dataclass 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 | 8 +++ doc/build/orm/dataclasses.rst | 29 +++++++- doc/build/orm/mapping_api.rst | 56 ++++++++------- lib/sqlalchemy/orm/__init__.py | 1 + lib/sqlalchemy/orm/decl_api.py | 69 +++++++++++++++++++ test/orm/declarative/test_dc_transforms.py | 32 +++++++-- .../test_dc_transforms_future_anno_sync.py | 32 +++++++-- .../orm/dataclass_transforms_decorator.py | 23 +++++++ 8 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/12855.rst create mode 100644 test/typing/plain_files/orm/dataclass_transforms_decorator.py diff --git a/doc/build/changelog/unreleased_20/12855.rst b/doc/build/changelog/unreleased_20/12855.rst new file mode 100644 index 0000000000..c33110ad0e --- /dev/null +++ b/doc/build/changelog/unreleased_20/12855.rst @@ -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. diff --git a/doc/build/orm/dataclasses.rst b/doc/build/orm/dataclasses.rst index f8af3fb8d6..2062b7fd0f 100644 --- a/doc/build/orm/dataclasses.rst +++ b/doc/build/orm/dataclasses.rst @@ -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 diff --git a/doc/build/orm/mapping_api.rst b/doc/build/orm/mapping_api.rst index f453429759..bcab2067bd 100644 --- a/doc/build/orm/mapping_api.rst +++ b/doc/build/orm/mapping_api.rst @@ -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 + + diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index 7771de47eb..bd957fb4cb 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -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 diff --git a/lib/sqlalchemy/orm/decl_api.py b/lib/sqlalchemy/orm/decl_api.py index b99906ed61..7200faeec2 100644 --- a/lib/sqlalchemy/orm/decl_api.py +++ b/lib/sqlalchemy/orm/decl_api.py @@ -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 ) diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 6d26c58f02..d16b225a12 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -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" diff --git a/test/orm/declarative/test_dc_transforms_future_anno_sync.py b/test/orm/declarative/test_dc_transforms_future_anno_sync.py index a5e2c53c45..b326fefc98 100644 --- a/test/orm/declarative/test_dc_transforms_future_anno_sync.py +++ b/test/orm/declarative/test_dc_transforms_future_anno_sync.py @@ -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 index 0000000000..01114c51e9 --- /dev/null +++ b/test/typing/plain_files/orm/dataclass_transforms_decorator.py @@ -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) -- 2.47.3