From: Mike Bayer Date: Thu, 30 Mar 2023 18:48:39 +0000 (-0400) Subject: warn for DC mixin / abstract fields that are not on a dataclass X-Git-Tag: rel_2_0_8~6^2 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=8eaf6f30cb8b316a6a6b4bb8743322f03b4903c4;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git warn for DC mixin / abstract fields that are not on a dataclass Fields that are declared on Declarative Mixins and then combined with classes that make use of :class:`_orm.MappedAsDataclass`, where those mixin fields are not themselves part of a dataclass, now emit a deprecation warning as these fields will be ignored in a future release, as Python dataclasses behavior is to ignore these fields. Type checkers will not see these fields under pep-681. Fixes: #9350 Change-Id: Ie0a0ac0f0bb58d1c2aae13b4a8dcd0439a4f5477 --- diff --git a/doc/build/changelog/unreleased_20/9350.rst b/doc/build/changelog/unreleased_20/9350.rst new file mode 100644 index 0000000000..9bb88230e2 --- /dev/null +++ b/doc/build/changelog/unreleased_20/9350.rst @@ -0,0 +1,16 @@ +.. change:: + :tags: bug, orm + :tickets: 9350 + + Fields that are declared on Declarative Mixins and then combined with + classes that make use of :class:`_orm.MappedAsDataclass`, where those mixin + fields are not themselves part of a dataclass, now emit a deprecation + warning as these fields will be ignored in a future release, as Python + dataclasses behavior is to ignore these fields. Type checkers will not see + these fields under pep-681. + + .. seealso:: + + :ref:`error_dcmx` - background on rationale + + :ref:`orm_declarative_dc_mixins` diff --git a/doc/build/errors.rst b/doc/build/errors.rst index a242ced39d..307a27414c 100644 --- a/doc/build/errors.rst +++ b/doc/build/errors.rst @@ -35,12 +35,12 @@ most common runtime errors as well as programming time errors. Connections and Transactions -============================ +---------------------------- .. _error_3o7r: QueuePool limit of size overflow reached, connection timed out, timeout ------------------------------------------------------------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This is possibly the most common runtime error experienced, as it directly involves the work load of the application surpassing a configured limit, one @@ -192,7 +192,7 @@ sooner. .. _error_8s2b: Can't reconnect until invalid transaction is rolled back. Please rollback() fully before proceeding ------------------------------------------------------------------------------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error condition refers to the case where a :class:`_engine.Connection` was invalidated, either due to a database disconnect detection or due to an @@ -206,7 +206,7 @@ back in order to remove it from the :class:`_engine.Connection`. .. _error_dbapi: DBAPI Errors -============ +------------ The Python database API, or DBAPI, is a specification for database drivers which can be located at `Pep-249 `_. @@ -221,7 +221,7 @@ exception :class:`.DBAPIError`, however the messaging within the exception is .. _error_rvf5: InterfaceError --------------- +~~~~~~~~~~~~~~ Exception raised for errors that are related to the database interface rather than the database itself. @@ -237,7 +237,7 @@ to the database. For tips on how to deal with this, see the section .. _error_4xp6: DatabaseError --------------- +~~~~~~~~~~~~~ Exception raised for errors that are related to the database itself, and not the interface or data being passed. @@ -248,7 +248,7 @@ the database driver (DBAPI), not SQLAlchemy itself. .. _error_9h9h: DataError ---------- +~~~~~~~~~ Exception raised for errors that are due to problems with the processed data like division by zero, numeric value out of range, etc. @@ -259,7 +259,7 @@ the database driver (DBAPI), not SQLAlchemy itself. .. _error_e3q8: OperationalError ------------------ +~~~~~~~~~~~~~~~~ Exception raised for errors that are related to the database's operation and not necessarily under the control of the programmer, e.g. an unexpected @@ -277,7 +277,7 @@ the section :ref:`pool_disconnects`. .. _error_gkpj: IntegrityError --------------- +~~~~~~~~~~~~~~ Exception raised when the relational integrity of the database is affected, e.g. a foreign key check fails. @@ -288,7 +288,7 @@ the database driver (DBAPI), not SQLAlchemy itself. .. _error_2j85: InternalError -------------- +~~~~~~~~~~~~~ Exception raised when the database encounters an internal error, e.g. the cursor is not valid anymore, the transaction is out of sync, etc. @@ -304,7 +304,7 @@ to the database. For tips on how to deal with this, see the section .. _error_f405: ProgrammingError ----------------- +~~~~~~~~~~~~~~~~ Exception raised for programming errors, e.g. table not found or already exists, syntax error in the SQL statement, wrong number of parameters @@ -321,7 +321,7 @@ to the database. For tips on how to deal with this, see the section .. _error_tw8g: NotSupportedError ------------------- +~~~~~~~~~~~~~~~~~ Exception raised in case a method or database API was used which is not supported by the database, e.g. requesting a .rollback() on a connection that @@ -331,12 +331,12 @@ This error is a :ref:`DBAPI Error ` and originates from the database driver (DBAPI), not SQLAlchemy itself. SQL Expression Language -======================= +----------------------- .. _error_cprf: .. _caching_caveats: Object will not produce a cache key, Performance Implications --------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SQLAlchemy as of version 1.4 includes a :ref:`SQL compilation caching facility ` which will allow @@ -425,7 +425,7 @@ each, see the section :ref:`faq_new_caching`. .. _error_l7de: Compiler StrSQLCompiler can't render element of type -------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error usually occurs when attempting to stringify a SQL expression construct that includes elements which are not part of the default compilation; @@ -496,7 +496,7 @@ compilation of SQL elements. TypeError: not supported between instances of 'ColumnProperty' and ------------------------------------------------------------------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This often occurs when attempting to use a :func:`.column_property` or :func:`.deferred` object in the context of a SQL expression, usually within @@ -537,7 +537,7 @@ The solution is to access the :class:`_schema.Column` directly using the .. _error_cd3x: A value is required for bind parameter (in parameter group ) -------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error occurs when a statement makes use of :func:`.bindparam` either implicitly or explicitly and does not provide a value when the statement @@ -603,7 +603,7 @@ Since "b" is required, pass it as ``None`` so that the INSERT may proceed:: .. _error_89ve: Expected FROM clause, got Select. To create a FROM clause, use the .subquery() method --------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This refers to a change made as of SQLAlchemy 1.4 where a SELECT statement as generated by a function such as :func:`_expression.select`, but also including things like unions and textual @@ -654,7 +654,7 @@ therefore requires that :meth:`_expression.SelectBase.subquery` is used:: .. _error_xaj1: An alias is being generated automatically for raw clauseelement ----------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 1.4.26 @@ -722,7 +722,7 @@ Join to an alias:: .. _error_xaj2: An alias is being generated automatically due to overlapping tables -------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 1.4.26 @@ -833,12 +833,12 @@ and instead keep the SQL construction as explicit as possible. Object Relational Mapping -========================= +------------------------- .. _error_bhk3: Parent instance is not bound to a Session; (lazy load/deferred load/refresh/etc.) operation cannot proceed --------------------------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This is likely the most common error message when dealing with the ORM, and it occurs as a result of the nature of a technique the ORM makes wide use of known @@ -914,7 +914,7 @@ Mitigation of this error is via these techniques: .. _error_7s2a: This Session's transaction has been rolled back due to a previous exception during flush ----------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The flush process of the :class:`.Session`, described at :ref:`session_flushing`, will roll back the database transaction if an error is @@ -931,7 +931,7 @@ application that doesn't yet have correct "framing" around its .. _error_bbf0: For relationship , delete-orphan cascade is normally configured only on the "one" side of a one-to-many relationship, and not on the "many" side of a many-to-one or many-to-many relationship. ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error arises when the "delete-orphan" :ref:`cascade ` @@ -1103,7 +1103,7 @@ in the "many" side, and not the other way around. .. _error_bbf1: Instance is already associated with an instance of via its attribute, and is only allowed a single parent. ---------------------------------------------------------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error is emitted when the :paramref:`_orm.relationship.single_parent` flag @@ -1156,7 +1156,7 @@ message for details. .. _error_qzyx: relationship X will copy column Q to column P, which conflicts with relationship(s): 'Y' ----------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This warning refers to the case when two or more relationships will write data to the same columns on flush, but the ORM does not have any means of @@ -1242,7 +1242,7 @@ Above, the ORM will know that the overlap between ``Parent.c1``, .. _error_lkrp: Object cannot be converted to 'persistent' state, as this identity map is no longer valid. -------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 1.4.26 @@ -1319,7 +1319,7 @@ the :term:`detached` state. .. _error_zlpr: Type annotation can't be interpreted for Annotated Declarative Table form --------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SQLAlchemy 2.0 introduces a new :ref:`Annotated Declarative Table ` declarative @@ -1341,13 +1341,117 @@ notes at :ref:`migration_20_step_six` for an example. :ref:`migration_20_step_six` - in the :ref:`migration_20_toplevel` document +.. _error_dcmx: + +When transforming to a dataclass, attribute(s) originate from superclass which is not a dataclass. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This warning occurs when using the SQLAlchemy ORM Mapped Dataclasses feature +described at :ref:`orm_declarative_native_dataclasses` in conjunction with +any mixin class or abstract base that is not itself declared as a +dataclass, such as in the example below:: + + from __future__ import annotations + + import inspect + from typing import Optional + from uuid import uuid4 + + from sqlalchemy import String + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + from sqlalchemy.orm import MappedAsDataclass + + + class Mixin: + + create_user: Mapped[int] = mapped_column() + update_user: Mapped[Optional[int]] = mapped_column(default=None, init=False) + + + class Base(DeclarativeBase, MappedAsDataclass): + pass + + + class User(Base, Mixin): + __tablename__ = "sys_user" + + uid: Mapped[str] = mapped_column( + String(50), init=False, default_factory=uuid4, primary_key=True + ) + username: Mapped[str] = mapped_column() + email: Mapped[str] = mapped_column() + +Above, since ``Mixin`` does not itself extend from :class:`_orm.MappedAsDataclass`, +the following warning is generated: + +.. sourcecode:: none + + SADeprecationWarning: When transforming to a + dataclass, attribute(s) "create_user", "update_user" originates from + superclass , which is not a dataclass. This usage is deprecated and + will raise an error in SQLAlchemy 2.1. When declaring SQLAlchemy + Declarative Dataclasses, ensure that all mixin classes and other + superclasses which include attributes are also a subclass of + MappedAsDataclass. + +The fix is to add :class:`_orm.MappedAsDataclass` to the signature of +``Mixin`` as well:: + + class Mixin(MappedAsDataclass): + + create_user: Mapped[int] = mapped_column() + update_user: Mapped[Optional[int]] = mapped_column(default=None, init=False) + +Python's :pep:`681` specification does not accommodate for attributes declared +on superclasses of dataclasses that are not themselves dataclasses; per the +behavior of Python dataclasses, such fields are ignored, as in the following +example:: + + from dataclasses import dataclass + from dataclasses import field + import inspect + from typing import Optional + from uuid import uuid4 + + + class Mixin: + create_user: int + update_user: Optional[int] = field(default=None) + + + @dataclass + class User(Mixin): + uid: str = field(init=False, default_factory=lambda: str(uuid4())) + username: str + password: str + email: str + +Above, the ``User`` class will not include ``create_user`` in its constructor +nor will it attempt to interpret ``update_user`` as a dataclass attribute. +This is because ``Mixin`` is not a dataclass. + +SQLAlchemy's dataclasses feature within the 2.0 series does not honor this +behavior correctly; instead, attributes on non-dataclass mixins and +superclasses are treated as part of the final dataclass configuration. However +type checkers such as Pyright and Mypy will not consider these fields as +part of the dataclass constructor as they are to be ignored per :pep:`681`. +Since their presence is ambiguous otherwise, SQLAlchemy 2.1 will require that +mixin classes which have SQLAlchemy mapped attributes within a dataclass +hierarchy have to themselves be dataclasses. + + + + AsyncIO Exceptions -================== +------------------ .. _error_xd1r: AwaitRequired -------------- +~~~~~~~~~~~~~ The SQLAlchemy async mode requires an async driver to be used to connect to the db. This error is usually raised when trying to use the async version of SQLAlchemy @@ -1360,7 +1464,7 @@ with a non compatible :term:`DBAPI`. .. _error_xd2s: MissingGreenlet ---------------- +~~~~~~~~~~~~~~~ A call to the async :term:`DBAPI` was initiated outside the greenlet spawn context usually setup by the SQLAlchemy AsyncIO proxy classes. Usually this @@ -1376,7 +1480,7 @@ attempt, which is unsupported when using SQLAlchemy with AsyncIO dialects. .. _error_xd3s: No Inspection Available ------------------------ +~~~~~~~~~~~~~~~~~~~~~~~ Using the :func:`_sa.inspect` function directly on an :class:`_asyncio.AsyncConnection` or :class:`_asyncio.AsyncEngine` object is @@ -1403,20 +1507,20 @@ that performs the desired operations:: Core Exception Classes -====================== +---------------------- See :ref:`core_exceptions_toplevel` for Core exception classes. ORM Exception Classes -====================== +--------------------- See :ref:`orm_exceptions_toplevel` for ORM exception classes. Legacy Exceptions -================= +----------------- Exceptions in this section are not generated by current SQLAlchemy versions, however are provided here to suit exception message hyperlinks. @@ -1424,7 +1528,7 @@ versions, however are provided here to suit exception message hyperlinks. .. _error_b8d9: The in SQLAlchemy 2.0 will no longer --------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SQLAlchemy 2.0 represents a major shift for a wide variety of key SQLAlchemy usage patterns in both the Core and ORM components. The goal @@ -1457,7 +1561,7 @@ this warning is at :ref:`deprecation_20_mode`. .. _error_s9r1: Object is being merged into a Session along the backref cascade ---------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This message refers to the "backref cascade" behavior of SQLAlchemy, removed in version 2.0. This refers to the action of @@ -1488,7 +1592,7 @@ by passing ``True`` for the :paramref:`_orm.Session.future` parameter. .. _error_c9ae: select() construct created in "legacy" mode; keyword arguments, etc. --------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`_expression.select` construct has been updated as of SQLAlchemy 1.4 to support the newer calling style that is standard in @@ -1536,7 +1640,7 @@ of :ref:`2.0 Migration `. .. _error_c9bf: A bind was located via legacy bound metadata, but since future=True is set on this Session, this bind is ignored. -------------------------------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The concept of "bound metadata" is present up until SQLAlchemy 1.4; as of SQLAlchemy 2.0 it's been removed. @@ -1591,7 +1695,7 @@ or :class:`_orm.Session`. .. _error_2afi: This Compiled object is not bound to any Engine or Connection -------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error refers to the concept of "bound metadata", which is a legacy SQLAlchemy pattern present only in 1.x versions. The issue occurs when one invokes @@ -1632,7 +1736,7 @@ When using the ORM, a similar facility is available via the :class:`.Session`:: .. _error_8s2a: This connection is on an inactive transaction. Please rollback() fully before proceeding ------------------------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This error condition was added to SQLAlchemy as of version 1.4, and does not apply to SQLAlchemy 2.0. The error diff --git a/doc/build/orm/dataclasses.rst b/doc/build/orm/dataclasses.rst index 34749334fe..6285ddef72 100644 --- a/doc/build/orm/dataclasses.rst +++ b/doc/build/orm/dataclasses.rst @@ -375,6 +375,53 @@ the other arguments can remain within the ``Annotated`` construct:: u1 = User() +.. _orm_declarative_dc_mixins: + +Using mixins and abstract superclasses +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Any mixins or base classes that are used in a :class:`_orm.MappedAsDataclass` +mapped class which include :class:`_orm.Mapped` attributes must themselves be +part of a :class:`_orm.MappedAsDataclass` +hierarchy, such as in the example below using a mixin:: + + + class Mixin(MappedAsDataclass): + + create_user: Mapped[int] = mapped_column() + update_user: Mapped[Optional[int]] = mapped_column(default=None, init=False) + + + class Base(DeclarativeBase, MappedAsDataclass): + pass + + + class User(Base, Mixin): + __tablename__ = "sys_user" + + uid: Mapped[str] = mapped_column( + String(50), init=False, default_factory=uuid4, primary_key=True + ) + username: Mapped[str] = mapped_column() + email: Mapped[str] = mapped_column() + +Python type checkers which support :pep:`681` will otherwise not consider +attributes from non-dataclass mixins to be part of the dataclass. + +.. deprecated:: 2.0.8 Using mixins and abstract bases within + :class:`_orm.MappedAsDataclass` or + :meth:`_orm.registry.mapped_as_dataclass` hierarchies which are not + themselves dataclasses is deprecated, as these fields are not supported + by :pep:`681` as belonging to the dataclass. A warning is emitted for this + case which will later be an error. + + .. seealso:: + + :ref:`error_dcmx` - background on rationale + + + + Relationship Configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index 6be5142765..f59b1c90ec 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -440,6 +440,7 @@ class _CollectedAnnotation(NamedTuple): is_dataclass: bool attr_value: Any originating_module: str + originating_class: Type[Any] class _ClassScanMapperConfig(_MapperConfig): @@ -1051,6 +1052,19 @@ class _ClassScanMapperConfig(_MapperConfig): "'@registry.mapped_as_dataclass'" ) + warn_for_non_dc_attrs = collections.defaultdict(list) + + def _allow_dataclass_field( + key: str, originating_class: Type[Any] + ) -> bool: + if ( + originating_class is not self.cls + and "__dataclass_fields__" not in originating_class.__dict__ + ): + warn_for_non_dc_attrs[originating_class].append(key) + + return True + manager = instrumentation.manager_of_class(self.cls) assert manager is not None @@ -1074,20 +1088,45 @@ class _ClassScanMapperConfig(_MapperConfig): is_dc, attr_value, originating_module, + originating_class, ) 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 + if _allow_dataclass_field(key, originating_class) + and ( + 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 + ) ) ) ] + + if warn_for_non_dc_attrs: + for ( + originating_class, + non_dc_attrs, + ) in warn_for_non_dc_attrs.items(): + util.warn_deprecated( + f"When transforming {self.cls} to a dataclass, " + f"attribute(s) " + f"{', '.join(repr(key) for key in non_dc_attrs)} " + f"originates from superclass " + f"{originating_class}, which is not a dataclass. This " + f"usage is deprecated and will raise an error in " + f"SQLAlchemy 2.1. When declaring SQLAlchemy Declarative " + f"Dataclasses, ensure that all mixin classes and other " + f"superclasses which include attributes are also a " + f"subclass of MappedAsDataclass.", + "2.0", + code="dcmx", + ) + annotations = {} defaults = {} for item in field_list: @@ -1271,6 +1310,7 @@ class _ClassScanMapperConfig(_MapperConfig): is_dataclass, attr_value, originating_class.__module__, + originating_class, ) return ca @@ -1468,8 +1508,9 @@ class _ClassScanMapperConfig(_MapperConfig): is_dataclass, attr_value, originating_module, + originating_class, ) = self.collected_annotations.get( - k, (None, None, None, False, None, None) + k, (None, None, None, False, None, None, None) ) # issue #8692 - don't do any annotation interpretation if diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 5abcaa46ef..a8a5e04bbf 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -1,5 +1,7 @@ +import contextlib import dataclasses from dataclasses import InitVar +import functools import inspect as pyinspect from itertools import product from typing import Any @@ -56,6 +58,13 @@ from sqlalchemy.testing import Variation from sqlalchemy.util import compat +def _dataclass_mixin_warning(clsname, attrnames): + return testing.expect_deprecated( + rf"When transforming .* to a dataclass, attribute\(s\) " + rf"{attrnames} originates from superclass .*{clsname}" + ) + + class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): @testing.fixture(params=["(MAD, DB)", "(DB, MAD)"]) def dc_decl_base(self, request, metadata): @@ -197,6 +206,32 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): a1 = Address("email@address") eq_(a1.email_address, "email@address") + def test_warn_on_non_dc_mixin(self): + class _BaseMixin: + + create_user: Mapped[int] = mapped_column() + update_user: Mapped[Optional[int]] = mapped_column( + default=None, init=False + ) + + class Base(DeclarativeBase, MappedAsDataclass, _BaseMixin): + pass + + class SubMixin: + foo: Mapped[str] + bar: Mapped[str] = mapped_column() + + with _dataclass_mixin_warning( + "_BaseMixin", "'create_user', 'update_user'" + ), _dataclass_mixin_warning("SubMixin", "'foo', 'bar'"): + + class User(SubMixin, Base): + __tablename__ = "sys_user" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + username: Mapped[str] = mapped_column(String) + password: Mapped[str] = mapped_column(String) + def test_basic_constructor_repr_cls_decorator( self, registry: _RegistryType ): @@ -667,10 +702,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): def test_mapped_column_overrides(self, dc_decl_base): """test #8688""" - class TriggeringMixin: + class TriggeringMixin(MappedAsDataclass): mixin_value: Mapped[int] = mapped_column(BigInteger) - class NonTriggeringMixin: + class NonTriggeringMixin(MappedAsDataclass): mixin_value: Mapped[int] class Foo(dc_decl_base, TriggeringMixin): @@ -1167,6 +1202,9 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase): ) expected_annotations[Mixin] = {} + + non_dc_mixin = contextlib.nullcontext + else: class Mixin: @@ -1204,50 +1242,66 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase): insert_default=cls.__name__, ) - if dataclass_scope.on_base_class: + non_dc_mixin = functools.partial( + _dataclass_mixin_warning, "Mixin", "'polymorphic_type'" + ) - class Book(Mixin, MappedAsDataclass, Base, **klass_kw): - id: Mapped[int] = mapped_column( - Integer, - primary_key=True, - init=False, - ) + if dataclass_scope.on_base_class: - else: + with non_dc_mixin(): - 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 + class Book(Mixin, MappedAsDataclass, Base, **klass_kw): + id: Mapped[int] = mapped_column( Integer, primary_key=True, + init=False, ) + else: + if dataclass_scope.on_base: + local_non_dc_mixin = non_dc_mixin + else: + local_non_dc_mixin = contextlib.nullcontext + + with local_non_dc_mixin(): + + 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 MappedAsDataclass in Book.__mro__: expected_annotations[Book] = {"id": int, "polymorphic_type": str} if dataclass_scope.on_sub_class: - class Novel(MappedAsDataclass, Book, **klass_kw): - id: Mapped[int] = mapped_column( # noqa: A001 - ForeignKey("book.id"), - primary_key=True, - init=False, - ) - description: Mapped[Optional[str]] + with non_dc_mixin(): + + class Novel(MappedAsDataclass, Book, **klass_kw): + 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]] + with non_dc_mixin(): + + class Novel(Book): + id: Mapped[int] = mapped_column( + ForeignKey("book.id"), + primary_key=True, + init=False, + ) + description: Mapped[Optional[str]] expected_annotations[Novel] = {"id": int, "description": Optional[str]} @@ -1712,7 +1766,7 @@ class MixinColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): class BaseEntity(DeclarativeBase): pass - class IdMixin: + class IdMixin(MappedAsDataclass): id: Mapped[int] = mapped_column( primary_key=True, init=False ) @@ -1764,14 +1818,14 @@ class MixinColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): elif mad_setup == "subclass": - class BaseEntity(DeclarativeBase): + class BaseEntity(MappedAsDataclass, DeclarativeBase): id: Mapped[int] = mapped_column( primary_key=True, init=False ) if mad_setup == "subclass": - class A(MappedAsDataclass, BaseEntity, **dataclass_kw): + class A(BaseEntity, **dataclass_kw): __mapper_args__ = { "polymorphic_on": "type", "polymorphic_identity": "a",