--- /dev/null
+.. 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.
: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
.. versionadded:: 2.0 Added "Write only" relationship loaders.
+.. _write_only_relationship_creating:
Creating and Persisting New Write Only Collections
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[...] (' (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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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__:
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
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:
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
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
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"""
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
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
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"""