From: Sigmund Lahn Date: Thu, 22 May 2025 16:43:12 +0000 (+0200) Subject: Add `dataclass_metadata` parameter to `mapped_column` and friends. X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=9246e162ab972b0edd81501cd7f867ff546b36b1;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add `dataclass_metadata` parameter to `mapped_column` and friends. This parameter is then passed to `dataclasses.field()` if mapped as a dataclass. Fixes: #10674 --- diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index f96018e51e..0164a87668 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 @@ -243,7 +244,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 5dad065396..b6f3e7d286 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -8,7 +8,7 @@ from __future__ import annotations import typing -from typing import Any +from typing import Any, Mapping from typing import Callable from typing import Collection from typing import Iterable @@ -136,6 +136,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 @@ -355,7 +356,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, @@ -461,6 +469,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. @@ -595,6 +604,7 @@ def column_property( compare, kw_only, hash, + dataclass_metadata, ), group=group, deferred=deferred, @@ -627,6 +637,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]: ... @@ -697,6 +708,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. @@ -783,7 +795,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, @@ -1037,6 +1056,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. @@ -1870,7 +1890,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, @@ -1908,6 +1935,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 @@ -2021,7 +2049,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, @@ -2156,6 +2191,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. @@ -2186,7 +2222,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, @@ -2228,6 +2271,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 55f5236ce3..b064377666 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -1580,9 +1580,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 9045e09a7c..d3297ade58 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -21,7 +21,7 @@ from __future__ import annotations import collections import dataclasses import typing -from typing import Any +from typing import Any, Mapping from typing import Callable from typing import cast from typing import ClassVar @@ -227,6 +227,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, dataclass_setup_arguments: _DataclassArguments @@ -248,6 +249,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 @@ -335,6 +338,7 @@ _DEFAULT_ATTRIBUTE_OPTIONS = _AttributeOptions( _NoArg.NO_ARG, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) _DEFAULT_READONLY_ATTRIBUTE_OPTIONS = _AttributeOptions( @@ -345,6 +349,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 004a119acd..e19a42df39 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -55,6 +55,7 @@ from sqlalchemy.testing import expect_deprecated from sqlalchemy.testing import expect_raises from sqlalchemy.testing import expect_raises_message from sqlalchemy.testing import fixtures +from sqlalchemy.testing import in_ from sqlalchemy.testing import is_ from sqlalchemy.testing import is_false from sqlalchemy.testing import is_true @@ -900,6 +901,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]): @@ -1877,9 +1891,10 @@ class DataclassArgsTest(fixtures.TestBase): "compare": True, "kw_only": False, "hash": False, + "dataclass_metadata": None, } exp = interfaces._AttributeOptions( - False, False, None, list, True, False, False + False, False, None, list, True, False, False, None ) else: kw = {} @@ -1908,6 +1923,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 f0b3e81fd7..d55f9f80b5 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -1457,6 +1457,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"]) @@ -1481,6 +1488,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 748ad03f7a..c8c8fec9cd 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -1448,6 +1448,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"]) @@ -1472,6 +1479,7 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "compare", "default_factory", "hash", + "dataclass_metadata", ) if is_dataclass: