From: Mike Bayer Date: Fri, 3 Feb 2023 15:50:14 +0000 (-0500) Subject: disallow ORM instrumented attributes from reaching dataclasses X-Git-Tag: rel_2_0_2~12^2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=188ed2228b3f80ece2d01a77b320de2967cd8f7d;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git disallow ORM instrumented attributes from reaching dataclasses More adjustments to ORM Declarative Dataclasses mappings, building on the improved support for mixins with dataclasses added in 2.0.1 via :ticket:`9179`, where a combination of using mixins plus ORM inheritance would mis-classify fields in some cases leading to their dataclass arguments such as ``init=False`` being lost. Fixes: #9226 Change-Id: Ia36f413e23e91dfbdb900f5ff3f8cdd3d5847064 --- diff --git a/doc/build/changelog/unreleased_20/9226.rst b/doc/build/changelog/unreleased_20/9226.rst new file mode 100644 index 0000000000..36547602b1 --- /dev/null +++ b/doc/build/changelog/unreleased_20/9226.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: bug, orm + :tickets: 9226 + + More adjustments to ORM Declarative Dataclasses mappings, building on the + improved support for mixins with dataclasses added in 2.0.1 via + :ticket:`9179`, where a combination of using mixins plus ORM inheritance + would mis-classify fields in some cases leading to their dataclass + arguments such as ``init=False`` being lost. diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index a858f12cb9..9f49302438 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -1060,6 +1060,17 @@ class _ClassScanMapperConfig(_MapperConfig): attr_value, originating_module, ) in self.collected_annotations.items() + if key not in self.collected_attributes + # issue #9226; check for attributes that we've collected + # which are already instrumented, which we would assume + # mean we are in an ORM inheritance mapping and this attribute + # is already mapped on the superclass. Under no circumstance + # should any QueryableAttribute be sent to the dataclass() + # function; anything that's mapped should be Field and + # that's it + or not isinstance( + self.collected_attributes[key], QueryableAttribute + ) ) ] annotations = {} diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 63450f4a17..857ad9a536 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -30,6 +30,7 @@ from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import column_property from sqlalchemy.orm import composite from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import declared_attr from sqlalchemy.orm import deferred from sqlalchemy.orm import interfaces from sqlalchemy.orm import Mapped @@ -983,6 +984,130 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase): eq_regex(repr(Child(a=5, b=6, c=7)), r".*\.Child\(c=7\)") + @testing.variation( + "dataclass_scope", + ["on_base", "on_mixin", "on_base_class", "on_sub_class"], + ) + def test_mixin_w_inheritance(self, dataclass_scope): + """test #9226""" + + if dataclass_scope.on_base: + + class Base(DeclarativeBase, MappedAsDataclass): + pass + + else: + + class Base(DeclarativeBase): + pass + + if dataclass_scope.on_mixin: + + class Mixin(MappedAsDataclass): + @declared_attr.directive + @classmethod + def __tablename__(cls) -> str: + return cls.__name__.lower() + + @declared_attr.directive + @classmethod + def __mapper_args__(cls) -> Dict[str, Any]: + return { + "polymorphic_identity": cls.__name__, + "polymorphic_on": "polymorphic_type", + } + + @declared_attr + @classmethod + def polymorphic_type(cls) -> Mapped[str]: + return mapped_column( + String, + insert_default=cls.__name__, + init=False, + ) + + else: + + class Mixin: + @declared_attr.directive + @classmethod + def __tablename__(cls) -> str: + return cls.__name__.lower() + + @declared_attr.directive + @classmethod + def __mapper_args__(cls) -> Dict[str, Any]: + return { + "polymorphic_identity": cls.__name__, + "polymorphic_on": "polymorphic_type", + } + + if dataclass_scope.on_base or dataclass_scope.on_base_class: + + @declared_attr + @classmethod + def polymorphic_type(cls) -> Mapped[str]: + return mapped_column( + String, + insert_default=cls.__name__, + init=False, + ) + + else: + + @declared_attr + @classmethod + def polymorphic_type(cls) -> Mapped[str]: + return mapped_column( + String, + insert_default=cls.__name__, + ) + + if dataclass_scope.on_base_class: + + class Book(Mixin, MappedAsDataclass, Base): + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + init=False, + ) + + else: + + class Book(Mixin, Base): + if not dataclass_scope.on_sub_class: + id: Mapped[int] = mapped_column( # noqa: A001 + Integer, primary_key=True, init=False + ) + else: + id: Mapped[int] = mapped_column( # noqa: A001 + Integer, + primary_key=True, + ) + + if dataclass_scope.on_sub_class: + + class Novel(MappedAsDataclass, Book): + id: Mapped[int] = mapped_column( # noqa: A001 + ForeignKey("book.id"), + primary_key=True, + init=False, + ) + description: Mapped[Optional[str]] + + else: + + class Novel(Book): + id: Mapped[int] = mapped_column( + ForeignKey("book.id"), + primary_key=True, + init=False, + ) + description: Mapped[Optional[str]] + + n1 = Novel("the description") + eq_(n1.description, "the description") + class DataclassArgsTest(fixtures.TestBase): dc_arg_names = ("init", "repr", "eq", "order", "unsafe_hash")