--- /dev/null
+.. change::
+ :tags: bug, orm
+ :tickets: 9957
+
+ Fixed issue in ORM Annotated Declarative which prevented a
+ :class:`_orm.declared_attr` from being used on a mixin which did not return
+ a :class:`.Mapped` datatype, and instead returned a supplemental ORM
+ datatype such as :class:`.AssociationProxy`. The Declarative runtime would
+ erroneously try to interpret this annotation as needing to be
+ :class:`.Mapped` and raise an error.
+
+
+.. change::
+ :tags: bug, orm, typing
+ :tickets: 9957
+
+ Fixed typing issue where using the :class:`.AssociationProxy` return type
+ from a :class:`_orm.declared_attr` function was disallowed.
self._attribute_options = _DEFAULT_ATTRIBUTE_OPTIONS
@overload
- def __get__(self, instance: Any, owner: Literal[None]) -> Self:
+ def __get__(self, instance: Literal[None], owner: Literal[None]) -> Self:
...
@overload
from .base import _inspect_mapped_class
from .base import _is_mapped_class
from .base import Mapped
+from .base import ORMDescriptor
from .decl_base import _add_attribute
from .decl_base import _as_declarative
from .decl_base import _ClassScanMapperConfig
_MutableTypeAnnotationMapType = Dict[Any, "_TypeEngineArgument[Any]"]
_DeclaredAttrDecorated = Callable[
- ..., Union[Mapped[_T], SQLCoreOperations[_T]]
+ ..., Union[Mapped[_T], ORMDescriptor[_T], SQLCoreOperations[_T]]
]
from .base import object_mapper as object_mapper
from .base import object_state as object_state # noqa: F401
from .base import opt_manager_of_class
+from .base import ORMDescriptor
from .base import state_attribute_str as state_attribute_str # noqa: F401
from .base import state_class_str as state_class_str # noqa: F401
from .base import state_str as state_str # noqa: F401
annotated, _MappedAnnotationBase
):
if expect_mapped:
- if getattr(annotated, "__origin__", None) is typing.ClassVar:
+ if not raiseerr:
return None
- if not raiseerr:
+ origin = getattr(annotated, "__origin__", None)
+ if origin is typing.ClassVar:
+ return None
+
+ # check for other kind of ORM descriptor like AssociationProxy,
+ # don't raise for that (issue #9957)
+ elif isinstance(origin, type) and issubclass(
+ origin, ORMDescriptor
+ ):
return None
raise sa_exc.ArgumentError(
--- /dev/null
+from __future__ import annotations
+
+from typing import List
+
+from sqlalchemy import ForeignKey
+from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.ext.associationproxy import AssociationProxy
+from sqlalchemy.orm import DeclarativeBase
+from sqlalchemy.orm import declared_attr
+from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import mapped_column
+from sqlalchemy.orm import relationship
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class Milestone:
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ @declared_attr
+ def users(self) -> Mapped[List["User"]]:
+ return relationship("User")
+
+ @declared_attr
+ def user_ids(self) -> AssociationProxy[List[int]]:
+ return association_proxy("users", "id")
+
+
+class BranchMilestone(Milestone, Base):
+ __tablename__ = "branch_milestones"
+
+
+class User(Base):
+ __tablename__ = "user"
+ id: Mapped[int] = mapped_column(primary_key=True)
+ branch_id: Mapped[int] = mapped_column(ForeignKey("branch_milestones.id"))
+
+
+bm = BranchMilestone()
+
+x1 = bm.user_ids
+
+# EXPECTED_TYPE: list[int]
+reveal_type(x1)
from sqlalchemy import types
from sqlalchemy import VARCHAR
from sqlalchemy.exc import ArgumentError
+from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import as_declarative
from sqlalchemy.orm import composite
"JOIN related ON related.id = b.related_id",
)
+ @testing.variation("use_directive", [True, False])
+ @testing.variation("use_annotation", [True, False])
+ def test_supplemental_declared_attr(
+ self, decl_base, use_directive, use_annotation
+ ):
+ """test #9957"""
+
+ class User(decl_base):
+ __tablename__ = "user"
+ id: Mapped[int] = mapped_column(primary_key=True)
+ branch_id: Mapped[int] = mapped_column(ForeignKey("thing.id"))
+
+ class Mixin:
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ @declared_attr
+ def users(self) -> Mapped[List[User]]:
+ return relationship(User)
+
+ if use_directive:
+ if use_annotation:
+
+ @declared_attr.directive
+ def user_ids(self) -> AssociationProxy[List[int]]:
+ return association_proxy("users", "id")
+
+ else:
+
+ @declared_attr.directive
+ def user_ids(self):
+ return association_proxy("users", "id")
+
+ else:
+ if use_annotation:
+
+ @declared_attr
+ def user_ids(self) -> AssociationProxy[List[int]]:
+ return association_proxy("users", "id")
+
+ else:
+
+ @declared_attr
+ def user_ids(self):
+ return association_proxy("users", "id")
+
+ class Thing(Mixin, decl_base):
+ __tablename__ = "thing"
+
+ t1 = Thing()
+ t1.users.extend([User(id=1), User(id=2)])
+ eq_(t1.user_ids, [1, 2])
+
class RelationshipLHSTest(fixtures.TestBase, testing.AssertsCompiledSQL):
__dialect__ = "default"
from sqlalchemy import types
from sqlalchemy import VARCHAR
from sqlalchemy.exc import ArgumentError
+from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import as_declarative
from sqlalchemy.orm import composite
"JOIN related ON related.id = b.related_id",
)
+ @testing.variation("use_directive", [True, False])
+ @testing.variation("use_annotation", [True, False])
+ def test_supplemental_declared_attr(
+ self, decl_base, use_directive, use_annotation
+ ):
+ """test #9957"""
+
+ class User(decl_base):
+ __tablename__ = "user"
+ id: Mapped[int] = mapped_column(primary_key=True)
+ branch_id: Mapped[int] = mapped_column(ForeignKey("thing.id"))
+
+ class Mixin:
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ @declared_attr
+ def users(self) -> Mapped[List[User]]:
+ return relationship(User)
+
+ if use_directive:
+ if use_annotation:
+
+ @declared_attr.directive
+ def user_ids(self) -> AssociationProxy[List[int]]:
+ return association_proxy("users", "id")
+
+ else:
+
+ @declared_attr.directive
+ def user_ids(self):
+ return association_proxy("users", "id")
+
+ else:
+ if use_annotation:
+
+ @declared_attr
+ def user_ids(self) -> AssociationProxy[List[int]]:
+ return association_proxy("users", "id")
+
+ else:
+
+ @declared_attr
+ def user_ids(self):
+ return association_proxy("users", "id")
+
+ class Thing(Mixin, decl_base):
+ __tablename__ = "thing"
+
+ t1 = Thing()
+ t1.users.extend([User(id=1), User(id=2)])
+ eq_(t1.user_ids, [1, 2])
+
class RelationshipLHSTest(fixtures.TestBase, testing.AssertsCompiledSQL):
__dialect__ = "default"