]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Fix typing to ensure that InstrumentedAttribute is hashable
authorFederico Caselli <cfederico87@gmail.com>
Sat, 16 Sep 2023 09:06:17 +0000 (11:06 +0200)
committerFederico Caselli <cfederico87@gmail.com>
Sun, 17 Sep 2023 17:33:53 +0000 (19:33 +0200)
Repaired the core "SQL element" class ``SQLCoreOperations`` to support the
``__hash__()`` method from a typing perspective, as objects like
:class:`.Column` and ORM :class:`.InstrumentedAttribute` are hashable and
are used as dictionary keys in the public API for the :class:`_dml.Update`
and :class:`_dml.Insert` constructs.  Previously, type checkers were not
aware the root SQL element was hashable.

Fixes: #10353
Change-Id: I3c8eeb7ceb29a3087596e17d09aa6a7f45a8cf99

doc/build/changelog/unreleased_20/10353.rst [new file with mode: 0644]
lib/sqlalchemy/sql/elements.py
lib/sqlalchemy/sql/operators.py
test/typing/plain_files/orm/typed_queries.py
test/typing/plain_files/sql/common_sql_element.py
test/typing/plain_files/sql/dml.py

diff --git a/doc/build/changelog/unreleased_20/10353.rst b/doc/build/changelog/unreleased_20/10353.rst
new file mode 100644 (file)
index 0000000..7f00aa7
--- /dev/null
@@ -0,0 +1,10 @@
+.. change::
+    :tags: bug, typing
+    :tickets: 10353
+
+    Repaired the core "SQL element" class ``SQLCoreOperations`` to support the
+    ``__hash__()`` method from a typing perspective, as objects like
+    :class:`.Column` and ORM :class:`.InstrumentedAttribute` are hashable and
+    are used as dictionary keys in the public API for the :class:`_dml.Update`
+    and :class:`_dml.Insert` constructs.  Previously, type checkers were not
+    aware the root SQL element was hashable.
index 7f7329f419d609d8f4cd01854cf35cb410fcfc27..a3666c5f10cfde2e9e7fffd3da4550be24c4c5d4 100644 (file)
@@ -883,6 +883,13 @@ class SQLCoreOperations(Generic[_T_co], ColumnOperators, TypingOnly):
         def __le__(self, other: Any) -> ColumnElement[bool]:
             ...
 
+        # declare also that this class has an hash method otherwise
+        # it may be assumed to be None by type checkers since the
+        # object defines __eq__ and python sets it to None in that case:
+        # https://docs.python.org/3/reference/datamodel.html#object.__hash__
+        def __hash__(self) -> int:
+            ...
+
         def __eq__(self, other: Any) -> ColumnElement[bool]:  # type: ignore[override]  # noqa: E501
             ...
 
index cd878a5957cbf4eba5d5b83c9df78f297960e13a..6402d0fd1b2ee9dfaff0102c72e5f6872caf2b2e 100644 (file)
@@ -569,8 +569,16 @@ class ColumnOperators(Operators):
         """
         return self.operate(le, other)
 
-    # TODO: not sure why we have this
-    __hash__ = Operators.__hash__
+    # ColumnOperators defines an __eq__ so it must explicitly declare also
+    # an hash or it's set to None by python:
+    # https://docs.python.org/3/reference/datamodel.html#object.__hash__
+    if TYPE_CHECKING:
+
+        def __hash__(self) -> int:
+            ...
+
+    else:
+        __hash__ = Operators.__hash__
 
     def __eq__(self, other: Any) -> ColumnOperators:  # type: ignore[override]
         """Implement the ``==`` operator.
index 722729b506f1d4ccf1519783ac16a5f3493dd8c6..7d8a2dd1a3283f8572f2b2051dd844d752c3e4e0 100644 (file)
@@ -459,6 +459,15 @@ def t_dml_bare_update() -> None:
     reveal_type(r1.rowcount)
 
 
+def t_dml_update_with_values() -> None:
+    s1 = update(User).values({User.id: 123, User.data: "value"})
+    r1 = session.execute(s1)
+    # EXPECTED_TYPE: CursorResult[Any]
+    reveal_type(r1)
+    # EXPECTED_TYPE: int
+    reveal_type(r1.rowcount)
+
+
 def t_dml_bare_delete() -> None:
     s1 = delete(User)
     r1 = session.execute(s1)
index 1152a04b1731f8e67aba165ced139420a19b8c41..57aae8fac81895b673c082d2a49023857ce5d80d 100644 (file)
@@ -11,6 +11,7 @@ from __future__ import annotations
 
 from sqlalchemy import asc
 from sqlalchemy import Column
+from sqlalchemy import column
 from sqlalchemy import desc
 from sqlalchemy import Integer
 from sqlalchemy import literal
@@ -160,3 +161,14 @@ reveal_type(literal("5", None))
 reveal_type(literal("123", Integer))
 # EXPECTED_TYPE: BindParameter[int]
 reveal_type(literal("123", Integer))
+
+
+# hashable (issue #10353):
+
+mydict = {
+    Column("q"): "q",
+    Column("q").desc(): "q",
+    User.id: "q",
+    literal("5"): "q",
+    column("q"): "q",
+}
index 596f47795c41b7330a09c3f2819cb2e776990f74..5381a1f07f1fc47ddeee650ab390abb46f5130bc 100644 (file)
@@ -5,7 +5,12 @@ from typing import Dict
 
 from sqlalchemy import Column
 from sqlalchemy import insert
+from sqlalchemy import Integer
+from sqlalchemy import MetaData
 from sqlalchemy import select
+from sqlalchemy import String
+from sqlalchemy import Table
+from sqlalchemy import update
 from sqlalchemy.orm import DeclarativeBase
 from sqlalchemy.orm import Mapped
 from sqlalchemy.orm import mapped_column
@@ -15,6 +20,14 @@ class Base(DeclarativeBase):
     pass
 
 
+user_table = Table(
+    "user",
+    MetaData(),
+    Column("id", Integer, primary_key=True),
+    Column("data", String),
+)
+
+
 class User(Base):
     __tablename__ = "user"
 
@@ -39,3 +52,11 @@ stmt4 = insert(User).from_select(
     [User.id, "name", User.__table__.c.data],
     select(User.id, User.name, User.data),
 )
+
+
+# test #10353
+stmt5 = update(User).values({User.id: 123, User.data: "value"})
+
+stmt6 = user_table.update().values(
+    {user_table.c.d: 123, user_table.c.data: "value"}
+)