From: Mike Bayer Date: Thu, 18 Jan 2024 17:47:02 +0000 (-0500) Subject: include cls locals in annotation evaluate X-Git-Tag: rel_2_0_26~5^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=153f287b9949462ec29d66fc9b329d0144a6ca7c;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git include cls locals in annotation evaluate Fixed issue where it was not possible to use a type (such as an enum) within a :class:`_orm.Mapped` container type if that type were declared locally within the class body. The scope of locals used for the eval now includes that of the class body itself. In addition, the expression within :class:`_orm.Mapped` may also refer to the class name itself, if used as a string or with future annotations mode. Fixes: #10899 Change-Id: Id4d07499558e457e63b483ff44c0972d9265409d (cherry picked from commit e9a05cf88811c4c4ca51b8103539a7727630d2f0) --- diff --git a/doc/build/changelog/unreleased_20/10899.rst b/doc/build/changelog/unreleased_20/10899.rst new file mode 100644 index 0000000000..692381323e --- /dev/null +++ b/doc/build/changelog/unreleased_20/10899.rst @@ -0,0 +1,10 @@ +.. change:: + :tags: bug, orm + :tickets: 10899 + + Fixed issue where it was not possible to use a type (such as an enum) + within a :class:`_orm.Mapped` container type if that type were declared + locally within the class body. The scope of locals used for the eval now + includes that of the class body itself. In addition, the expression within + :class:`_orm.Mapped` may also refer to the class name itself, if used as a + string or with future annotations mode. diff --git a/lib/sqlalchemy/util/typing.py b/lib/sqlalchemy/util/typing.py index a7d2a340e7..2d9e2250a8 100644 --- a/lib/sqlalchemy/util/typing.py +++ b/lib/sqlalchemy/util/typing.py @@ -154,7 +154,7 @@ def de_stringify_annotation( annotation = str_cleanup_fn(annotation, originating_module) annotation = eval_expression( - annotation, originating_module, locals_=locals_ + annotation, originating_module, locals_=locals_, in_class=cls ) if ( @@ -207,6 +207,7 @@ def eval_expression( module_name: str, *, locals_: Optional[Mapping[str, Any]] = None, + in_class: Optional[Type[Any]] = None, ) -> Any: try: base_globals: Dict[str, Any] = sys.modules[module_name].__dict__ @@ -217,7 +218,18 @@ def eval_expression( ) from ke try: - annotation = eval(expression, base_globals, locals_) + if in_class is not None: + cls_namespace = dict(in_class.__dict__) + cls_namespace.setdefault(in_class.__name__, in_class) + + # see #10899. We want the locals/globals to take precedence + # over the class namespace in this context, even though this + # is not the usual way variables would resolve. + cls_namespace.update(base_globals) + + annotation = eval(expression, cls_namespace, locals_) + else: + annotation = eval(expression, base_globals, locals_) except Exception as err: raise NameError( f"Could not de-stringify annotation {expression!r}" diff --git a/test/orm/declarative/test_tm_future_annotations.py b/test/orm/declarative/test_tm_future_annotations.py index 833518a427..e3b5df0ad4 100644 --- a/test/orm/declarative/test_tm_future_annotations.py +++ b/test/orm/declarative/test_tm_future_annotations.py @@ -8,6 +8,7 @@ the ``test_tm_future_annotations_sync`` by the ``sync_test_file`` script. from __future__ import annotations +import enum from typing import ClassVar from typing import Dict from typing import List @@ -29,8 +30,11 @@ from sqlalchemy.orm import KeyFuncDict from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship +from sqlalchemy.sql import sqltypes +from sqlalchemy.testing import eq_ from sqlalchemy.testing import expect_raises_message from sqlalchemy.testing import is_ +from sqlalchemy.testing import is_true from .test_typed_mapping import expect_annotation_syntax_error from .test_typed_mapping import MappedColumnTest as _MappedColumnTest from .test_typed_mapping import RelationshipLHSTest as _RelationshipLHSTest @@ -112,6 +116,85 @@ class MappedColumnTest(_MappedColumnTest): select(Foo), "SELECT foo.id, foo.data, foo.data2 FROM foo" ) + def test_type_favors_outer(self, decl_base): + """test #10899, that we maintain favoring outer names vs. inner. + this is for backwards compatibility as well as what people + usually expect regarding the names of attributes in the class. + + """ + + class User(decl_base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + uuid: Mapped[uuid.UUID] = mapped_column() + + is_true(isinstance(User.__table__.c.uuid.type, sqltypes.Uuid)) + + def test_type_inline_cls_qualified(self, decl_base): + """test #10899, where we test that we can refer to the class name + directly to refer to class-bound elements. + + """ + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[User.Role] + + is_true(isinstance(User.__table__.c.role.type, sqltypes.Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + + def test_type_inline_disambiguate(self, decl_base): + """test #10899, where we test that we can refer to an inner name + that's not in conflict directly without qualification. + + """ + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[Role] + + is_true(isinstance(User.__table__.c.role.type, sqltypes.Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + eq_(User.__table__.c.role.type.name, "role") # and not 'enum' + + def test_type_inner_can_be_qualified(self, decl_base): + """test #10899, same test as that of Role, using it to qualify against + a global variable with the same name. + + """ + + global SomeGlobalName + SomeGlobalName = None + + class User(decl_base): + __tablename__ = "user" + + class SomeGlobalName(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[User.SomeGlobalName] + + is_true(isinstance(User.__table__.c.role.type, sqltypes.Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.SomeGlobalName) + def test_indirect_mapped_name_local_level(self, decl_base): """test #8759. diff --git a/test/orm/declarative/test_tm_future_annotations_sync.py b/test/orm/declarative/test_tm_future_annotations_sync.py index 39ce5051ba..9b55827d49 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -192,6 +192,46 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): else: eq_(Foo.__table__.c.data.default.arg, 5) + def test_type_inline_declaration(self, decl_base): + """test #10899""" + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[Role] + + is_true(isinstance(User.__table__.c.role.type, Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + eq_(User.__table__.c.role.type.name, "role") # and not 'enum' + + def test_type_uses_inner_when_present(self, decl_base): + """test #10899, that we use inner name when appropriate""" + + class Role(enum.Enum): + foo = "foo" + bar = "bar" + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[Role] + + is_true(isinstance(User.__table__.c.role.type, Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + eq_(User.__table__.c.role.type.name, "role") # and not 'enum' + def test_legacy_declarative_base(self): typ = VARCHAR(50) Base = declarative_base(type_annotation_map={str: typ}) diff --git a/test/orm/declarative/test_typed_mapping.py b/test/orm/declarative/test_typed_mapping.py index c61dceea1f..ba8ab455ca 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -183,6 +183,46 @@ class MappedColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): else: eq_(Foo.__table__.c.data.default.arg, 5) + def test_type_inline_declaration(self, decl_base): + """test #10899""" + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[Role] + + is_true(isinstance(User.__table__.c.role.type, Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + eq_(User.__table__.c.role.type.name, "role") # and not 'enum' + + def test_type_uses_inner_when_present(self, decl_base): + """test #10899, that we use inner name when appropriate""" + + class Role(enum.Enum): + foo = "foo" + bar = "bar" + + class User(decl_base): + __tablename__ = "user" + + class Role(enum.Enum): + admin = "admin" + user = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + role: Mapped[Role] + + is_true(isinstance(User.__table__.c.role.type, Enum)) + eq_(User.__table__.c.role.type.length, 5) + is_(User.__table__.c.role.type.enum_class, User.Role) + eq_(User.__table__.c.role.type.name, "role") # and not 'enum' + def test_legacy_declarative_base(self): typ = VARCHAR(50) Base = declarative_base(type_annotation_map={str: typ})