From: Federico Caselli Date: Sat, 16 Sep 2023 09:06:17 +0000 (+0200) Subject: Fix typing to ensure that InstrumentedAttribute is hashable X-Git-Tag: rel_2_0_21~6 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=8a3bcd9af491b8a4737ce33e6dd96500342967d3;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Fix typing to ensure that InstrumentedAttribute is hashable 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 --- diff --git a/doc/build/changelog/unreleased_20/10353.rst b/doc/build/changelog/unreleased_20/10353.rst new file mode 100644 index 0000000000..7f00aa7c8c --- /dev/null +++ b/doc/build/changelog/unreleased_20/10353.rst @@ -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. diff --git a/lib/sqlalchemy/sql/elements.py b/lib/sqlalchemy/sql/elements.py index 7f7329f419..a3666c5f10 100644 --- a/lib/sqlalchemy/sql/elements.py +++ b/lib/sqlalchemy/sql/elements.py @@ -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 ... diff --git a/lib/sqlalchemy/sql/operators.py b/lib/sqlalchemy/sql/operators.py index cd878a5957..6402d0fd1b 100644 --- a/lib/sqlalchemy/sql/operators.py +++ b/lib/sqlalchemy/sql/operators.py @@ -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. diff --git a/test/typing/plain_files/orm/typed_queries.py b/test/typing/plain_files/orm/typed_queries.py index 722729b506..7d8a2dd1a3 100644 --- a/test/typing/plain_files/orm/typed_queries.py +++ b/test/typing/plain_files/orm/typed_queries.py @@ -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) diff --git a/test/typing/plain_files/sql/common_sql_element.py b/test/typing/plain_files/sql/common_sql_element.py index 1152a04b17..57aae8fac8 100644 --- a/test/typing/plain_files/sql/common_sql_element.py +++ b/test/typing/plain_files/sql/common_sql_element.py @@ -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", +} diff --git a/test/typing/plain_files/sql/dml.py b/test/typing/plain_files/sql/dml.py index 596f47795c..5381a1f07f 100644 --- a/test/typing/plain_files/sql/dml.py +++ b/test/typing/plain_files/sql/dml.py @@ -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"} +)