From: Mike Bayer Date: Wed, 5 Aug 2026 15:25:19 +0000 (-0400) Subject: Support default_factory=list for write only / dynamic relationships X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f2ed6422b34a4f738f7178706019ebdbec1e950d;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Support default_factory=list for write only / dynamic relationships Fixed regression caused by the dataclasses change in :ticket:`12168` where passing :paramref:`_orm.relationship.default_factory` as ``list`` to a relationship that used the :class:`_orm.WriteOnlyMapped` or :class:`_orm.DynamicMapped` annotation would raise an error at mapper configuration time, as these relationships have no ``collection_class``. ``list`` is now accepted for these relationships, which behave the same as ordinary collections in this regard; the factory itself is never invoked, and a newly constructed object begins with an empty collection. Documentation is added at :ref:`write_only_dataclasses` illustrating the use of write only and dynamic relationships with ORM mapped dataclasses. Fixes: #13227 Change-Id: I809b408b0eabf0235f9d6e95871a85eba3917e61 --- diff --git a/doc/build/changelog/unreleased_21/13227.rst b/doc/build/changelog/unreleased_21/13227.rst new file mode 100644 index 0000000000..19b6c9ed41 --- /dev/null +++ b/doc/build/changelog/unreleased_21/13227.rst @@ -0,0 +1,15 @@ +.. change:: + :tags: bug, orm, regression + :tickets: 13227 + + Fixed regression caused by the dataclasses change in :ticket:`12168` where + passing :paramref:`_orm.relationship.default_factory` as ``list`` to a + relationship that used the :class:`_orm.WriteOnlyMapped` or + :class:`_orm.DynamicMapped` annotation would raise an error at mapper + configuration time, as these relationships have no ``collection_class``. + ``list`` is now accepted for these relationships, which behave the same + as ordinary collections in this regard; the factory itself is never + invoked, and a newly constructed object begins with an empty + collection. Documentation is added at :ref:`write_only_dataclasses` + illustrating the use of write only and dynamic relationships with ORM + mapped dataclasses. diff --git a/doc/build/orm/dataclasses.rst b/doc/build/orm/dataclasses.rst index d1f133f40d..4aec7d1cd8 100644 --- a/doc/build/orm/dataclasses.rst +++ b/doc/build/orm/dataclasses.rst @@ -527,6 +527,12 @@ of :paramref:`_orm.relationship.default_factory` or :paramref:`_orm.relationship.default` is what determines if the parameter is to be required or optional when rendered into the ``__init__()`` method. +.. seealso:: + + :ref:`write_only_dataclasses` - dataclass configuration for + relationships that use the :class:`_orm.WriteOnlyMapped` and + :class:`_orm.DynamicMapped` annotations + .. _orm_declarative_native_dataclasses_non_mapped_fields: Using Non-Mapped Dataclass Fields diff --git a/doc/build/orm/large_collections.rst b/doc/build/orm/large_collections.rst index a081466e7e..ba746cffc6 100644 --- a/doc/build/orm/large_collections.rst +++ b/doc/build/orm/large_collections.rst @@ -128,6 +128,7 @@ are deleted, as well as when ``AccountTransaction`` objects are removed from the .. versionadded:: 2.0 Added "Write only" relationship loaders. +.. _write_only_relationship_creating: Creating and Persisting New Write Only Collections ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -181,6 +182,8 @@ loaded into memory in order to reconcile the old entries with the new ones:: sqlalchemy.exc.InvalidRequestError: Collection "Account.account_transactions" does not support implicit iteration; collection replacement operations can't be used +.. _write_only_relationship_adding: + Adding New Items to an Existing Collection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -528,6 +531,96 @@ produce a :term:`scalar subquery`:: [...] (' (audited)', 1) <...> +.. _write_only_dataclasses: + +Using Write Only Relationships with ORM Dataclasses +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When the mapped class is also an ORM mapped dataclass, described at +:ref:`orm_declarative_native_dataclasses`, a write only relationship takes +part in the dataclass process in the same way as an ordinary collection, +where :paramref:`_orm.relationship.default_factory` is passed as ``list`` +in order for the attribute to be optional within the ``__init__()`` +method: + +.. sourcecode:: python + + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + from sqlalchemy.orm import MappedAsDataclass + from sqlalchemy.orm import relationship + from sqlalchemy.orm import WriteOnlyMapped + + + class Base(MappedAsDataclass, DeclarativeBase): + pass + + + class Account(Base): + __tablename__ = "account" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + identifier: Mapped[str] + + account_transactions: WriteOnlyMapped["AccountTransaction"] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + order_by="AccountTransaction.timestamp", + default_factory=list, + ) + +As a write only relationship has no in-memory collection, ``list`` is the +only value accepted for :paramref:`_orm.relationship.default_factory`, and +the factory is not actually invoked; constructing ``Account()`` without +passing ``account_transactions`` leaves the collection empty. As +described at :ref:`write_only_relationship_creating`, a sequence of objects +may still be passed to the constructor of a new object: + +.. sourcecode:: python + + account = Account( + identifier="account_01", + account_transactions=[ + AccountTransaction(description="initial deposit", amount=Decimal("500.00")), + ], + ) + +The :paramref:`_orm.relationship.init` parameter may alternatively be set to +``False``, which omits the attribute from the ``__init__()`` method +altogether: + +.. sourcecode:: python + + class Account(Base): + __tablename__ = "account" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + identifier: Mapped[str] + + account_transactions: WriteOnlyMapped["AccountTransaction"] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + order_by="AccountTransaction.timestamp", + init=False, + ) + +With the above mapping, items are added to the collection after the object +is constructed, using the :meth:`_orm.WriteOnlyCollection.add` and +:meth:`_orm.WriteOnlyCollection.add_all` methods described at +:ref:`write_only_relationship_adding`: + +.. sourcecode:: python + + account = Account(identifier="account_01") + account.account_transactions.add( + AccountTransaction(description="initial deposit", amount=Decimal("500.00")) + ) + +The same configurations apply to :ref:`dynamic relationships +`, substituting :class:`_orm.DynamicMapped` for +:class:`_orm.WriteOnlyMapped`. + Write Only Collections - API Documentation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -617,6 +710,12 @@ enabled on the :class:`.Session` in use, this will occur automatically each time the collection is about to emit a query. +.. seealso:: + + :ref:`write_only_dataclasses` - dataclass configuration, which applies + equally to relationships using the :class:`_orm.DynamicMapped` + annotation + Dynamic Relationship Loaders - API ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/lib/sqlalchemy/orm/relationships.py b/lib/sqlalchemy/orm/relationships.py index dc903f6962..d02685aa5c 100644 --- a/lib/sqlalchemy/orm/relationships.py +++ b/lib/sqlalchemy/orm/relationships.py @@ -1913,15 +1913,25 @@ class RelationshipProperty( if ( self._attribute_options.dataclasses_default_factory is not _NoArg.NO_ARG - and self._attribute_options.dataclasses_default_factory - is not self.collection_class ): - raise sa_exc.ArgumentError( - f"For relationship {self._format_as_string(cls, key)} using " - "dataclass options, default_factory must be exactly " - f"{self.collection_class}" + # write only / dynamic relationships have no collection_class, + # however they accept a list of objects when assigned, so + # ``list`` is the expected default_factory for these + expected_default_factory = ( + list if is_write_only or is_dynamic else self.collection_class ) + if ( + self._attribute_options.dataclasses_default_factory + is not expected_default_factory + ): + raise sa_exc.ArgumentError( + "For relationship " + f"{self._format_as_string(cls, key)} using " + "dataclass options, default_factory must be exactly " + f"{expected_default_factory}" + ) + @util.preload_module("sqlalchemy.orm.mapper") def _setup_entity(self, __argument: Any = None, /) -> None: if "entity" in self.__dict__: diff --git a/lib/sqlalchemy/orm/writeonly.py b/lib/sqlalchemy/orm/writeonly.py index 3757110a9b..f763fb5d39 100644 --- a/lib/sqlalchemy/orm/writeonly.py +++ b/lib/sqlalchemy/orm/writeonly.py @@ -41,6 +41,7 @@ from . import interfaces from . import relationships from . import strategies from .base import ATTR_EMPTY +from .base import DONT_SET from .base import NEVER_SET from .base import object_mapper from .base import PassiveFlag @@ -314,6 +315,12 @@ class _WriteOnlyAttributeImpl( if pop and value is None: return + if value is DONT_SET: + # dataclasses default_factory for a write only collection + # sends DONT_SET; there's no collection to initialize so + # this is a no-op + return + iterable = value new_values = list(iterable) if state.has_identity: diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 1b485857d6..b04e014ddc 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -35,6 +35,7 @@ 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 DynamicMapped from sqlalchemy.orm import interfaces from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_as_dataclass @@ -48,6 +49,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.orm import Session from sqlalchemy.orm import synonym from sqlalchemy.orm import unmapped_dataclass +from sqlalchemy.orm import WriteOnlyMapped from sqlalchemy.orm.attributes import LoaderCallableStatus from sqlalchemy.orm.base import _DeclarativeMapped from sqlalchemy.orm.base import _is_mapped_class @@ -1138,6 +1140,93 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase): else: collection_type.fail() + @testing.variation("collection_type", ["write_only", "dynamic"]) + def test_no_funny_business_write_only( + self, + dc_decl_base: Type[MappedAsDataclass], + collection_type: testing.Variation, + ): + """test #13227""" + + with expect_raises_message( + exc.ArgumentError, + "For relationship A.bs using dataclass options, " + "default_factory must be exactly ", + ): + + class A(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + + if collection_type.write_only: + bs: WriteOnlyMapped["B"] = relationship( # noqa: F821 + default_factory=set + ) + elif collection_type.dynamic: + bs: DynamicMapped["B"] = relationship( # noqa: F821 + default_factory=set + ) + else: + collection_type.fail() + + @testing.variation("collection_type", ["write_only", "dynamic"]) + def test_write_only_default_factory( + self, registry: _RegistryType, collection_type: testing.Variation + ): + """test #13227 + + ``default_factory=list`` for a write only / dynamic relationship + is accepted and leaves the collection empty, in the same way that + ``default_factory=list`` works for a normal collection. + + """ + + @mapped_as_dataclass(registry) + class A: + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] + + if collection_type.write_only: + bs: WriteOnlyMapped["B"] = relationship( # noqa: F821 + default_factory=list + ) + elif collection_type.dynamic: + bs: DynamicMapped["B"] = relationship( # noqa: F821 + default_factory=list + ) + else: + collection_type.fail() + + @mapped_as_dataclass(registry) + class B: + __tablename__ = "b" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + a_id: Mapped[int] = mapped_column(ForeignKey("a.id"), init=False) + data: Mapped[str] + + registry.metadata.create_all(testing.db) + + with Session(testing.db) as sess: + a1 = A("a1") + a2 = A("a2", [B("b1"), B("b2")]) + + sess.add_all([a1, a2]) + sess.commit() + + if collection_type.write_only: + a1_bs = sess.scalars(a1.bs.select()).all() + a2_bs = sess.scalars(a2.bs.select()).all() + else: + a1_bs = a1.bs.all() + a2_bs = a2.bs.all() + + eq_(a1_bs, []) + eq_([b.data for b in a2_bs], ["b1", "b2"]) + def test_one_to_one_example(self, dc_decl_base: Type[MappedAsDataclass]): """test example in the relationship docs will derive uselist=False correctly""" diff --git a/test/orm/declarative/test_dc_transforms_future_anno_sync.py b/test/orm/declarative/test_dc_transforms_future_anno_sync.py index 851a950115..c5d9aee091 100644 --- a/test/orm/declarative/test_dc_transforms_future_anno_sync.py +++ b/test/orm/declarative/test_dc_transforms_future_anno_sync.py @@ -44,6 +44,7 @@ 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 DynamicMapped from sqlalchemy.orm import interfaces from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_as_dataclass @@ -57,6 +58,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.orm import Session from sqlalchemy.orm import synonym from sqlalchemy.orm import unmapped_dataclass +from sqlalchemy.orm import WriteOnlyMapped from sqlalchemy.orm.attributes import LoaderCallableStatus from sqlalchemy.orm.base import _DeclarativeMapped from sqlalchemy.orm.base import _is_mapped_class @@ -1151,6 +1153,93 @@ class RelationshipDefaultFactoryTest(fixtures.TestBase): else: collection_type.fail() + @testing.variation("collection_type", ["write_only", "dynamic"]) + def test_no_funny_business_write_only( + self, + dc_decl_base: Type[MappedAsDataclass], + collection_type: testing.Variation, + ): + """test #13227""" + + with expect_raises_message( + exc.ArgumentError, + "For relationship A.bs using dataclass options, " + "default_factory must be exactly ", + ): + + class A(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + + if collection_type.write_only: + bs: WriteOnlyMapped["B"] = relationship( # noqa: F821 + default_factory=set + ) + elif collection_type.dynamic: + bs: DynamicMapped["B"] = relationship( # noqa: F821 + default_factory=set + ) + else: + collection_type.fail() + + @testing.variation("collection_type", ["write_only", "dynamic"]) + def test_write_only_default_factory( + self, registry: _RegistryType, collection_type: testing.Variation + ): + """test #13227 + + ``default_factory=list`` for a write only / dynamic relationship + is accepted and leaves the collection empty, in the same way that + ``default_factory=list`` works for a normal collection. + + """ + + @mapped_as_dataclass(registry) + class A: + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] + + if collection_type.write_only: + bs: WriteOnlyMapped["B"] = relationship( # noqa: F821 + default_factory=list + ) + elif collection_type.dynamic: + bs: DynamicMapped["B"] = relationship( # noqa: F821 + default_factory=list + ) + else: + collection_type.fail() + + @mapped_as_dataclass(registry) + class B: + __tablename__ = "b" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + a_id: Mapped[int] = mapped_column(ForeignKey("a.id"), init=False) + data: Mapped[str] + + registry.metadata.create_all(testing.db) + + with Session(testing.db) as sess: + a1 = A("a1") + a2 = A("a2", [B("b1"), B("b2")]) + + sess.add_all([a1, a2]) + sess.commit() + + if collection_type.write_only: + a1_bs = sess.scalars(a1.bs.select()).all() + a2_bs = sess.scalars(a2.bs.select()).all() + else: + a1_bs = a1.bs.all() + a2_bs = a2.bs.all() + + eq_(a1_bs, []) + eq_([b.data for b in a2_bs], ["b1", "b2"]) + def test_one_to_one_example(self, dc_decl_base: Type[MappedAsDataclass]): """test example in the relationship docs will derive uselist=False correctly"""