]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add hash to field-like methods
authorFederico Caselli <cfederico87@gmail.com>
Fri, 11 Oct 2024 19:20:15 +0000 (21:20 +0200)
committerFederico Caselli <cfederico87@gmail.com>
Sat, 12 Oct 2024 08:44:05 +0000 (10:44 +0200)
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 [new file with mode: 0644]
lib/sqlalchemy/ext/associationproxy.py
lib/sqlalchemy/orm/_orm_constructors.py
lib/sqlalchemy/orm/interfaces.py
test/orm/declarative/test_dc_transforms.py
test/orm/declarative/test_tm_future_annotations_sync.py
test/orm/declarative/test_typed_mapping.py

diff --git a/doc/build/changelog/unreleased_20/11923.rst b/doc/build/changelog/unreleased_20/11923.rst
new file mode 100644 (file)
index 0000000..5b5fbce
--- /dev/null
@@ -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.
index ef146f78f16868759015216cb4e10228273a8aec..5b033f735da46d5753fabd5c96d51f8186e462f7 100644 (file)
@@ -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
         ),
     )
 
index ba73045e31b54039d5b4039b585624ef3982da3c..73a83d1543faf5309c43a79a96ae640e5c134867 100644 (file)
@@ -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,
index f5f6582202e1ac9f9bd4b871b03d78824ece8b3a..1955abb97433cc7dc8d17eab1d07d7ef48351fa4 100644 (file)
@@ -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,
 )
 
 
index 8408f696176f715f49ae3eb1c257493e30c4fad5..4eb20f4891f5372e069c634299462be393a02bc4 100644 (file)
@@ -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 = {}
index e473245b82f78ac678b8376bf902f5e364582ffa..579cd7a57a9f95b1827fe235adb372d29fbccb02 100644 (file)
@@ -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:
index 36adbd197dbdf90cfe9cb5e023d1d706dd19321f..ba0c8c916036e68be14283015262c9e252d99063 100644 (file)
@@ -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: