]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Support default_factory=list for write only / dynamic relationships
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 5 Aug 2026 15:25:19 +0000 (11:25 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 5 Aug 2026 20:37:08 +0000 (16:37 -0400)
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

doc/build/changelog/unreleased_21/13227.rst [new file with mode: 0644]
doc/build/orm/dataclasses.rst
doc/build/orm/large_collections.rst
lib/sqlalchemy/orm/relationships.py
lib/sqlalchemy/orm/writeonly.py
test/orm/declarative/test_dc_transforms.py
test/orm/declarative/test_dc_transforms_future_anno_sync.py

diff --git a/doc/build/changelog/unreleased_21/13227.rst b/doc/build/changelog/unreleased_21/13227.rst
new file mode 100644 (file)
index 0000000..19b6c9e
--- /dev/null
@@ -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.
index d1f133f40de933de2e0585cb5cab12d1501c8bfd..4aec7d1cd8add24a8a1b63172fc2734e0e90c184 100644 (file)
@@ -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
index a081466e7ea36ab5da6bdefae81b0af79ec095b5..ba746cffc64e2bfeb5c3f59a69a8bc50197a3202 100644 (file)
@@ -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
+<dynamic_relationship>`, 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
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
index dc903f6962d17b0d30a83a734d77e8c1318c8440..d02685aa5c7dc5975b6712eeebf9d64440424ece 100644 (file)
@@ -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__:
index 3757110a9b1db3fbb82ad9fb82e0fbf4e4d100a8..f763fb5d3945edc7aa5549e6d0d2ea0127f43343 100644 (file)
@@ -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:
index 1b485857d6ccdd7c3f906073b5260732a421cd3e..b04e014ddc965ccde956b285bed09bc68022d63a 100644 (file)
@@ -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 'list'>",
+        ):
+
+            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"""
index 851a950115b26e7ad98a4eb5e11f8b6fe465f2a7..c5d9aee091ca6a761b7b37cd9eff40ec18f4f148 100644 (file)
@@ -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 'list'>",
+        ):
+
+            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"""