From c14111b5bb2c624dd0bcb677fc3c9d811b46a2e7 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 11 Oct 2024 21:20:15 +0200 Subject: [PATCH] Add hash to field-like methods Added the dataclass field ``hash`` parameter to the orm field-like methods, like :meth:`_orn.mapped_column`, :meth:`_orm.relationship`, etc. Fixes: #11923 Change-Id: I80220f6dcd9c42f465d8a4c4ae2e4efa45279ecc --- doc/build/changelog/unreleased_20/11923.rst | 6 ++ lib/sqlalchemy/ext/associationproxy.py | 10 ++- lib/sqlalchemy/orm/_orm_constructors.py | 79 ++++++++++++++++--- lib/sqlalchemy/orm/interfaces.py | 5 ++ test/orm/declarative/test_dc_transforms.py | 34 +++++++- .../test_tm_future_annotations_sync.py | 8 ++ test/orm/declarative/test_typed_mapping.py | 8 ++ 7 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/11923.rst diff --git a/doc/build/changelog/unreleased_20/11923.rst b/doc/build/changelog/unreleased_20/11923.rst new file mode 100644 index 0000000000..5b5fbceee3 --- /dev/null +++ b/doc/build/changelog/unreleased_20/11923.rst @@ -0,0 +1,6 @@ +.. change:: + :tags: usecase, orm + :tickets: 11923 + + Added the dataclass field ``hash`` parameter to the orm field-like methods, + like :meth:`_orn.mapped_column`, :meth:`_orm.relationship`, etc. diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index ef146f78f1..5b033f735d 100644 --- a/lib/sqlalchemy/ext/associationproxy.py +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -98,6 +98,7 @@ def association_proxy( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 ) -> AssociationProxy[Any]: r"""Return a Python property implementing a view of a target attribute which references an attribute on members of the @@ -198,6 +199,13 @@ def association_proxy( .. versionadded:: 2.0.0b4 + :param hash: Specific to + :ref:`orm_declarative_native_dataclasses`, controls if this field + is included when generating the ``__hash__()`` method for the mapped + class. + + .. versionadded:: 2.0.36 + :param info: optional, will be assigned to :attr:`.AssociationProxy.info` if present. @@ -237,7 +245,7 @@ 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 + init, repr, default, default_factory, compare, kw_only, hash ), ) diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index ba73045e31..73a83d1543 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -110,6 +110,7 @@ def mapped_column( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 nullable: Optional[ Union[bool, Literal[SchemaConst.NULL_UNSPECIFIED]] ] = SchemaConst.NULL_UNSPECIFIED, @@ -333,6 +334,13 @@ def mapped_column( :ref:`orm_declarative_native_dataclasses`, indicates if this field should be marked as keyword-only when generating the ``__init__()``. + :param hash: Specific to + :ref:`orm_declarative_native_dataclasses`, controls if this field + is included when generating the ``__hash__()`` method for the mapped + class. + + .. versionadded:: 2.0.36 + :param \**kw: All remaining keyword arguments are passed through to the constructor for the :class:`_schema.Column`. @@ -347,7 +355,7 @@ def mapped_column( autoincrement=autoincrement, insert_default=insert_default, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + init, repr, default, default_factory, compare, kw_only, hash ), doc=doc, key=key, @@ -442,12 +450,13 @@ def column_property( deferred: bool = False, raiseload: bool = False, comparator_factory: Optional[Type[PropComparator[_T]]] = None, - init: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 + init: Union[_NoArg, bool] = _NoArg.NO_ARG, repr: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 default: Optional[Any] = _NoArg.NO_ARG, default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 active_history: bool = False, expire_on_flush: bool = True, info: Optional[_InfoType] = None, @@ -536,13 +545,43 @@ def column_property( :ref:`orm_queryguide_deferred_raiseload` - :param init: + :param init: Specific to :ref:`orm_declarative_native_dataclasses`, + specifies if the mapped attribute should be part of the ``__init__()`` + method as generated by the dataclass process. + :param repr: Specific to :ref:`orm_declarative_native_dataclasses`, + specifies if the mapped attribute should be part of the ``__repr__()`` + method as generated by the dataclass process. + :param default_factory: Specific to + :ref:`orm_declarative_native_dataclasses`, + specifies a default-value generation function that will take place + as part of the ``__init__()`` + method as generated by the dataclass process. + + .. seealso:: + + :ref:`defaults_default_factory_insert_default` - :param default: + :paramref:`_orm.mapped_column.default` - :param default_factory: + :paramref:`_orm.mapped_column.insert_default` - :param kw_only: + :param compare: Specific to + :ref:`orm_declarative_native_dataclasses`, indicates if this field + should be included in comparison operations when generating the + ``__eq__()`` and ``__ne__()`` methods for the mapped class. + + .. versionadded:: 2.0.0b4 + + :param kw_only: Specific to + :ref:`orm_declarative_native_dataclasses`, indicates if this field + should be marked as keyword-only when generating the ``__init__()``. + + :param hash: Specific to + :ref:`orm_declarative_native_dataclasses`, controls if this field + is included when generating the ``__hash__()`` method for the mapped + class. + + .. versionadded:: 2.0.36 """ return MappedSQLExpression( @@ -555,6 +594,7 @@ def column_property( default_factory, compare, kw_only, + hash, ), group=group, deferred=deferred, @@ -584,6 +624,7 @@ def composite( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 info: Optional[_InfoType] = None, doc: Optional[str] = None, **__kw: Any, @@ -606,6 +647,7 @@ def composite( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 info: Optional[_InfoType] = None, doc: Optional[str] = None, **__kw: Any, @@ -628,6 +670,7 @@ def composite( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 info: Optional[_InfoType] = None, doc: Optional[str] = None, **__kw: Any, @@ -651,6 +694,7 @@ def composite( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 info: Optional[_InfoType] = None, doc: Optional[str] = None, **__kw: Any, @@ -725,6 +769,12 @@ def composite( :ref:`orm_declarative_native_dataclasses`, indicates if this field should be marked as keyword-only when generating the ``__init__()``. + :param hash: Specific to + :ref:`orm_declarative_native_dataclasses`, controls if this field + is included when generating the ``__hash__()`` method for the mapped + class. + + .. versionadded:: 2.0.36 """ if __kw: raise _no_kw() @@ -733,7 +783,7 @@ def composite( _class_or_attr, *attrs, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + init, repr, default, default_factory, compare, kw_only, hash ), group=group, deferred=deferred, @@ -961,6 +1011,7 @@ def relationship( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 lazy: _LazyLoadArgumentType = "select", passive_deletes: Union[Literal["all"], bool] = False, passive_updates: bool = True, @@ -1784,7 +1835,12 @@ def relationship( :ref:`orm_declarative_native_dataclasses`, indicates if this field should be marked as keyword-only when generating the ``__init__()``. + :param hash: Specific to + :ref:`orm_declarative_native_dataclasses`, controls if this field + is included when generating the ``__hash__()`` method for the mapped + class. + .. versionadded:: 2.0.36 """ return _RelationshipDeclared( @@ -1802,7 +1858,7 @@ def relationship( cascade=cascade, viewonly=viewonly, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + init, repr, default, default_factory, compare, kw_only, hash ), lazy=lazy, passive_deletes=passive_deletes, @@ -1837,6 +1893,7 @@ def synonym( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 info: Optional[_InfoType] = None, doc: Optional[str] = None, ) -> Synonym[Any]: @@ -1947,7 +2004,7 @@ def synonym( descriptor=descriptor, comparator_factory=comparator_factory, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + init, repr, default, default_factory, compare, kw_only, hash ), doc=doc, info=info, @@ -2078,6 +2135,7 @@ def deferred( default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, 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 active_history: bool = False, expire_on_flush: bool = True, info: Optional[_InfoType] = None, @@ -2112,7 +2170,7 @@ def deferred( column, *additional_columns, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + init, repr, default, default_factory, compare, kw_only, hash ), group=group, deferred=True, @@ -2155,6 +2213,7 @@ def query_expression( _NoArg.NO_ARG, compare, _NoArg.NO_ARG, + _NoArg.NO_ARG, ), expire_on_flush=expire_on_flush, info=info, diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index f5f6582202..1955abb974 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -209,6 +209,7 @@ class _AttributeOptions(NamedTuple): dataclasses_default_factory: Union[_NoArg, Callable[[], Any]] dataclasses_compare: Union[_NoArg, bool] dataclasses_kw_only: Union[_NoArg, bool] + dataclasses_hash: Union[_NoArg, bool, None] def _as_dataclass_field(self, key: str) -> Any: """Return a ``dataclasses.Field`` object given these arguments.""" @@ -226,6 +227,8 @@ class _AttributeOptions(NamedTuple): kw["compare"] = self.dataclasses_compare if self.dataclasses_kw_only is not _NoArg.NO_ARG: kw["kw_only"] = self.dataclasses_kw_only + if self.dataclasses_hash is not _NoArg.NO_ARG: + kw["hash"] = self.dataclasses_hash if "default" in kw and callable(kw["default"]): # callable defaults are ambiguous. deprecate them in favour of @@ -305,6 +308,7 @@ _DEFAULT_ATTRIBUTE_OPTIONS = _AttributeOptions( _NoArg.NO_ARG, _NoArg.NO_ARG, _NoArg.NO_ARG, + _NoArg.NO_ARG, ) _DEFAULT_READONLY_ATTRIBUTE_OPTIONS = _AttributeOptions( @@ -314,6 +318,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 8408f69617..4eb20f4891 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -76,6 +76,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): if request.param == "(MAD, DB)": class Base(MappedAsDataclass, DeclarativeBase): + _mad_before = True metadata = _md type_annotation_map = { str: String().with_variant(String(50), "mysql", "mariadb") @@ -84,6 +85,7 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): else: # test #8665 by reversing the order of the classes class Base(DeclarativeBase, MappedAsDataclass): + _mad_before = False metadata = _md type_annotation_map = { str: String().with_variant(String(50), "mysql", "mariadb") @@ -683,6 +685,27 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(fas.args, ["self", "id"]) eq_(fas.kwonlyargs, ["data"]) + @testing.combinations(True, False, argnames="unsafe_hash") + def test_hash_attribute( + self, dc_decl_base: Type[MappedAsDataclass], unsafe_hash + ): + class A(dc_decl_base, unsafe_hash=unsafe_hash): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, hash=False) + data: Mapped[str] = mapped_column(hash=True) + + a = A(id=1, data="x") + if not unsafe_hash or not dc_decl_base._mad_before: + with expect_raises(TypeError): + a_hash1 = hash(a) + else: + a_hash1 = hash(a) + a.id = 41 + eq_(hash(a), a_hash1) + a.data = "y" + ne_(hash(a), a_hash1) + @testing.requires.python310 def test_kw_only_dataclass_constant( self, dc_decl_base: Type[MappedAsDataclass] @@ -1798,9 +1821,10 @@ class DataclassArgsTest(fixtures.TestBase): "default_factory": list, "compare": True, "kw_only": False, + "hash": False, } exp = interfaces._AttributeOptions( - False, False, False, list, True, False + False, False, False, list, True, False, False ) else: kw = {} @@ -1822,7 +1846,13 @@ class DataclassArgsTest(fixtures.TestBase): "compare": True, } exp = interfaces._AttributeOptions( - False, False, _NoArg.NO_ARG, _NoArg.NO_ARG, True, _NoArg.NO_ARG + False, + False, + _NoArg.NO_ARG, + _NoArg.NO_ARG, + True, + _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 e473245b82..579cd7a57a 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -1058,6 +1058,13 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "Argument 'init' is a dataclass argument" ), ), + ( + "hash", + True, + exc.SADeprecationWarning( + "Argument 'hash' is a dataclass argument" + ), + ), argnames="argname, argument, assertion", ) @testing.variation("use_annotated", [True, False, "control"]) @@ -1081,6 +1088,7 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "repr", "compare", "default_factory", + "hash", ) if is_dataclass: diff --git a/test/orm/declarative/test_typed_mapping.py b/test/orm/declarative/test_typed_mapping.py index 36adbd197d..ba0c8c9160 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -1049,6 +1049,13 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "Argument 'init' is a dataclass argument" ), ), + ( + "hash", + True, + exc.SADeprecationWarning( + "Argument 'hash' is a dataclass argument" + ), + ), argnames="argname, argument, assertion", ) @testing.variation("use_annotated", [True, False, "control"]) @@ -1072,6 +1079,7 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): "repr", "compare", "default_factory", + "hash", ) if is_dataclass: -- 2.47.3