From: Sigmund Lahn Date: Thu, 3 Jul 2025 16:14:13 +0000 (-0400) Subject: Add ``dataclass_metadata`` parameter to orm cols X-Git-Tag: rel_2_0_42~2^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=acf6cecd180d874c8f720ed601e0eddb6ad73aff;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add ``dataclass_metadata`` parameter to orm cols Added ``dataclass_metadata`` argument to a all column functions used in the ORM that accept dataclasses parameters. It's passed to the underlying dataclass ``metadata`` attribute of the dataclass field. Pull request courtesy Sigmund Lahn. Fixes: #10674 Closes: #12619 Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/12619 Pull-request-sha: 9ea6a95b2bc6816e5b69d6d559783a6009877838 Change-Id: I4551c24b85cebee4064df6ab752d0700f0f191f1 (cherry picked from commit aeb7830d22cdb4ac387242d46833cb2f67e0a952) --- diff --git a/doc/build/changelog/unreleased_20/10674.rst b/doc/build/changelog/unreleased_20/10674.rst new file mode 100644 index 0000000000..8c2c04fec8 --- /dev/null +++ b/doc/build/changelog/unreleased_20/10674.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: usecase, rom + :tickets: 10674 + + Added ``dataclass_metadata`` argument to a all column functions + used in the ORM that accept dataclasses parameters. + It's passed to the underlying dataclass ``metadata`` attribute + of the dataclass field. + Pull request courtesy Sigmund Lahn. \ No newline at end of file diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index 8f2c19b876..d72cfc3edd 100644 --- a/lib/sqlalchemy/ext/associationproxy.py +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -99,6 +99,7 @@ def association_proxy( compare: Union[_NoArg, bool] = _NoArg.NO_ARG, kw_only: Union[_NoArg, bool] = _NoArg.NO_ARG, hash: Union[_NoArg, bool, None] = _NoArg.NO_ARG, # noqa: A002 + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, ) -> AssociationProxy[Any]: r"""Return a Python property implementing a view of a target attribute which references an attribute on members of the @@ -206,6 +207,12 @@ def association_proxy( .. versionadded:: 2.0.36 + :param dataclass_metadata: Specific to + :ref:`orm_declarative_native_dataclasses`, supplies metadata + to be attached to the generated dataclass field. + + .. versionadded:: 2.0.42 + :param info: optional, will be assigned to :attr:`.AssociationProxy.info` if present. @@ -245,7 +252,14 @@ def association_proxy( cascade_scalar_deletes=cascade_scalar_deletes, create_on_none_assignment=create_on_none_assignment, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), ) diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index 30fc794672..9c07bf1800 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -12,6 +12,7 @@ from typing import Any from typing import Callable from typing import Collection from typing import Iterable +from typing import Mapping from typing import NoReturn from typing import Optional from typing import overload @@ -134,6 +135,7 @@ def mapped_column( system: bool = False, comment: Optional[str] = None, sort_order: Union[_NoArg, int] = _NoArg.NO_ARG, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, **kw: Any, ) -> MappedColumn[Any]: r"""declare a new ORM-mapped :class:`_schema.Column` construct @@ -339,6 +341,12 @@ def mapped_column( .. versionadded:: 2.0.36 + :param dataclass_metadata: Specific to + :ref:`orm_declarative_native_dataclasses`, supplies metadata + to be attached to the generated dataclass field. + + .. versionadded:: 2.0.42 + :param \**kw: All remaining keyword arguments are passed through to the constructor for the :class:`_schema.Column`. @@ -353,7 +361,14 @@ def mapped_column( autoincrement=autoincrement, insert_default=insert_default, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), doc=doc, key=key, @@ -459,6 +474,7 @@ def column_property( expire_on_flush: bool = True, info: Optional[_InfoType] = None, doc: Optional[str] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, ) -> MappedSQLExpression[_T]: r"""Provide a column-level property for use with a mapping. @@ -581,6 +597,12 @@ def column_property( .. versionadded:: 2.0.36 + :param dataclass_metadata: Specific to + :ref:`orm_declarative_native_dataclasses`, supplies metadata + to be attached to the generated dataclass field. + + .. versionadded:: 2.0.42 + """ return MappedSQLExpression( column, @@ -593,6 +615,7 @@ def column_property( compare, kw_only, hash, + dataclass_metadata, ), group=group, deferred=deferred, @@ -624,6 +647,7 @@ def composite( hash: Union[_NoArg, bool, None] = _NoArg.NO_ARG, # noqa: A002 info: Optional[_InfoType] = None, doc: Optional[str] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, **__kw: Any, ) -> Composite[Any]: ... @@ -691,6 +715,7 @@ def composite( hash: Union[_NoArg, bool, None] = _NoArg.NO_ARG, # noqa: A002 info: Optional[_InfoType] = None, doc: Optional[str] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, **__kw: Any, ) -> Composite[Any]: r"""Return a composite column-based property for use with a Mapper. @@ -769,6 +794,13 @@ def composite( class. .. versionadded:: 2.0.36 + + :param dataclass_metadata: Specific to + :ref:`orm_declarative_native_dataclasses`, supplies metadata + to be attached to the generated dataclass field. + + .. versionadded:: 2.0.42 + """ if __kw: raise _no_kw() @@ -777,7 +809,14 @@ def composite( _class_or_attr, *attrs, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), group=group, deferred=deferred, @@ -1031,6 +1070,7 @@ def relationship( info: Optional[_InfoType] = None, omit_join: Literal[None, False] = None, sync_backref: Optional[bool] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, **kw: Any, ) -> _RelationshipDeclared[Any]: """Provide a relationship between two mapped classes. @@ -1841,6 +1881,13 @@ def relationship( class. .. versionadded:: 2.0.36 + + :param dataclass_metadata: Specific to + :ref:`orm_declarative_native_dataclasses`, supplies metadata + to be attached to the generated dataclass field. + + .. versionadded:: 2.0.42 + """ return _RelationshipDeclared( @@ -1858,7 +1905,14 @@ def relationship( cascade=cascade, viewonly=viewonly, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), lazy=lazy, passive_deletes=passive_deletes, @@ -1896,6 +1950,7 @@ def synonym( hash: Union[_NoArg, bool, None] = _NoArg.NO_ARG, # noqa: A002 info: Optional[_InfoType] = None, doc: Optional[str] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, ) -> Synonym[Any]: """Denote an attribute name as a synonym to a mapped property, in that the attribute will mirror the value and expression behavior @@ -2009,7 +2064,14 @@ def synonym( descriptor=descriptor, comparator_factory=comparator_factory, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), doc=doc, info=info, @@ -2144,6 +2206,7 @@ def deferred( expire_on_flush: bool = True, info: Optional[_InfoType] = None, doc: Optional[str] = None, + dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] = _NoArg.NO_ARG, ) -> MappedSQLExpression[_T]: r"""Indicate a column-based mapped attribute that by default will not load unless accessed. @@ -2174,7 +2237,14 @@ def deferred( column, *additional_columns, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only, hash + init, + repr, + default, + default_factory, + compare, + kw_only, + hash, + dataclass_metadata, ), group=group, deferred=True, @@ -2218,6 +2288,7 @@ def query_expression( compare, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ), expire_on_flush=expire_on_flush, info=info, diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index 77b03a0385..418e312787 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -1600,9 +1600,15 @@ class _ClassScanMapperConfig(_MapperConfig): "default_factory", "repr", "default", + "dataclass_metadata", ] else: - argnames = ["init", "default_factory", "repr"] + argnames = [ + "init", + "default_factory", + "repr", + "dataclass_metadata", + ] args = { a diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index b4462e5459..dfcd13058d 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -29,6 +29,7 @@ from typing import Dict from typing import Generic from typing import Iterator from typing import List +from typing import Mapping from typing import NamedTuple from typing import NoReturn from typing import Optional @@ -207,6 +208,7 @@ class _AttributeOptions(NamedTuple): dataclasses_compare: Union[_NoArg, bool] dataclasses_kw_only: Union[_NoArg, bool] dataclasses_hash: Union[_NoArg, bool, None] + dataclasses_dataclass_metadata: Union[_NoArg, Mapping[Any, Any], None] def _as_dataclass_field(self, key: str) -> Any: """Return a ``dataclasses.Field`` object given these arguments.""" @@ -226,6 +228,8 @@ class _AttributeOptions(NamedTuple): kw["kw_only"] = self.dataclasses_kw_only if self.dataclasses_hash is not _NoArg.NO_ARG: kw["hash"] = self.dataclasses_hash + if self.dataclasses_dataclass_metadata is not _NoArg.NO_ARG: + kw["metadata"] = self.dataclasses_dataclass_metadata if "default" in kw and callable(kw["default"]): # callable defaults are ambiguous. deprecate them in favour of @@ -263,7 +267,7 @@ class _AttributeOptions(NamedTuple): key: str, annotation: _AnnotationScanType, mapped_container: Optional[Any], - elem: _T, + elem: Any, ) -> Union[ Tuple[str, _AnnotationScanType], Tuple[str, _AnnotationScanType, dataclasses.Field[Any]], @@ -306,6 +310,7 @@ _DEFAULT_ATTRIBUTE_OPTIONS = _AttributeOptions( _NoArg.NO_ARG, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) _DEFAULT_READONLY_ATTRIBUTE_OPTIONS = _AttributeOptions( @@ -316,6 +321,7 @@ _DEFAULT_READONLY_ATTRIBUTE_OPTIONS = _AttributeOptions( _NoArg.NO_ARG, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 53a9366c3a..e2613cf237 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -867,6 +867,19 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(fields["value"].default, cd) eq_(fields["no_init"].default, cd) + def test_dataclass_metadata(self, dc_decl_base): + class A(dc_decl_base): + __tablename__ = "a" + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[str] = mapped_column( + dataclass_metadata={"meta_key": "meta_value"} + ) + + fields = {f.name: f for f in dataclasses.fields(A)} + + eq_(fields["id"].metadata, {}) + eq_(fields["value"].metadata, {"meta_key": "meta_value"}) + class RelationshipDefaultFactoryTest(fixtures.TestBase): def test_list(self, dc_decl_base: Type[MappedAsDataclass]): @@ -1851,9 +1864,10 @@ class DataclassArgsTest(fixtures.TestBase): "compare": True, "kw_only": False, "hash": False, + "dataclass_metadata": None, } exp = interfaces._AttributeOptions( - False, False, False, list, True, False, False + False, False, False, list, True, False, False, None ) else: kw = {} @@ -1882,6 +1896,7 @@ class DataclassArgsTest(fixtures.TestBase): True, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) else: kw = {} 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 8701990526..6ebd0855bb 100644 --- a/test/orm/declarative/test_dc_transforms_future_anno_sync.py +++ b/test/orm/declarative/test_dc_transforms_future_anno_sync.py @@ -880,6 +880,19 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(fields["value"].default, cd) eq_(fields["no_init"].default, cd) + def test_dataclass_metadata(self, dc_decl_base): + class A(dc_decl_base): + __tablename__ = "a" + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[str] = mapped_column( + dataclass_metadata={"meta_key": "meta_value"} + ) + + fields = {f.name: f for f in dataclasses.fields(A)} + + eq_(fields["id"].metadata, {}) + eq_(fields["value"].metadata, {"meta_key": "meta_value"}) + class RelationshipDefaultFactoryTest(fixtures.TestBase): def test_list(self, dc_decl_base: Type[MappedAsDataclass]): @@ -1870,9 +1883,10 @@ class DataclassArgsTest(fixtures.TestBase): "compare": True, "kw_only": False, "hash": False, + "dataclass_metadata": None, } exp = interfaces._AttributeOptions( - False, False, False, list, True, False, False + False, False, False, list, True, False, False, None ) else: kw = {} @@ -1901,6 +1915,7 @@ class DataclassArgsTest(fixtures.TestBase): True, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) else: kw = {} diff --git a/test/orm/declarative/test_tm_future_annotations_sync.py b/test/orm/declarative/test_tm_future_annotations_sync.py index 5b17e3e6e5..3a84853099 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -1502,6 +1502,13 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "Argument 'hash' is a dataclass argument" ), ), + ( + "dataclass_metadata", + {}, + exc.SADeprecationWarning( + "Argument 'dataclass_metadata' is a dataclass argument" + ), + ), argnames="argname, argument, assertion", ) @testing.variation("use_annotated", [True, False, "control"]) @@ -1526,6 +1533,7 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "compare", "default_factory", "hash", + "dataclass_metadata", ) if is_dataclass: diff --git a/test/orm/declarative/test_typed_mapping.py b/test/orm/declarative/test_typed_mapping.py index acc07ba7d4..750a1b34f5 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -1493,6 +1493,13 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "Argument 'hash' is a dataclass argument" ), ), + ( + "dataclass_metadata", + {}, + exc.SADeprecationWarning( + "Argument 'dataclass_metadata' is a dataclass argument" + ), + ), argnames="argname, argument, assertion", ) @testing.variation("use_annotated", [True, False, "control"]) @@ -1517,6 +1524,7 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "compare", "default_factory", "hash", + "dataclass_metadata", ) if is_dataclass: