.. _change_12168:
-ORM Mapped Dataclasses no longer populate implicit ``default`` in ``__dict__``
-------------------------------------------------------------------------------
+ORM Mapped Dataclasses no longer populate implicit ``default``, collection-based ``default_factory`` in ``__dict__``
+--------------------------------------------------------------------------------------------------------------------
This behavioral change addresses a widely reported issue with SQLAlchemy's
:ref:`orm_declarative_native_dataclasses` feature that was introduced in 2.0.
>>> so.status
default_status
+default_factory for collection-based relationships internally uses DONT_SET
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+A late add to the behavioral change brings equivalent behavior to the
+use of the :paramref:`_orm.relationship.default_factory` parameter with
+collection-based relationships. This attribute is `documented <orm_declarative_dc_relationships>`
+as being limited to exactly the collection class that's stated on the left side
+of the annotation, which is now enforced at mapper configuration time::
+
+ class Parent(Base):
+ __tablename__ = "parents"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ name: Mapped[str]
+
+ children: Mapped[list["Child"]] = relationship(default_factory=list)
+
+With the above mapping, the actual
+:paramref:`_orm.relationship.default_factory` parameter is replaced internally
+to instead use the same ``DONT_SET`` constant that's applied to
+:paramref:`_orm.relationship.default` for many-to-one relationships.
+SQLAlchemy's existing collection-on-attribute access behavior occurs as always
+on access::
+
+ >>> p1 = Parent(name="p1")
+ >>> p1.children
+ []
+
+This change to :paramref:`_orm.relationship.default_factory` accommodates a
+similar merge-based condition where an empty collection would be forced into
+a new object that in fact wants a merged collection to arrive.
+
+
Related Changes
^^^^^^^^^^^^^^^
A significant behavioral change has been made to the behavior of the
:paramref:`_orm.mapped_column.default` and
- :paramref:`_orm.relationship.default` parameters, when used with
- SQLAlchemy's :ref:`orm_declarative_native_dataclasses` feature introduced
- in 2.0, where the given value (assumed to be an immutable scalar value) is
- no longer passed to the ``@dataclass`` API as a real default, instead a
- token that leaves the value un-set in the object's ``__dict__`` is used, in
- conjunction with a descriptor-level default. This prevents an un-set
- default value from overriding a default that was actually set elsewhere,
- such as in relationship / foreign key assignment patterns as well as in
+ :paramref:`_orm.relationship.default` parameters, as well as the
+ :paramref:`_orm.relationship.default_factory` parameter with
+ collection-based relationships, when used with SQLAlchemy's
+ :ref:`orm_declarative_native_dataclasses` feature introduced in 2.0, where
+ the given value (assumed to be an immutable scalar value for
+ :paramref:`_orm.mapped_column.default` and a simple collection class for
+ :paramref:`_orm.relationship.default_factory`) is no longer passed to the
+ ``@dataclass`` API as a real default, instead a token that leaves the value
+ un-set in the object's ``__dict__`` is used, in conjunction with a
+ descriptor-level default. This prevents an un-set default value from
+ overriding a default that was actually set elsewhere, such as in
+ relationship / foreign key assignment patterns as well as in
:meth:`_orm.Session.merge` scenarios. See the full writeup in the
:ref:`whatsnew_21_toplevel` document which includes guidance on how to
re-enable the 2.0 version of the behavior if needed.
+.. _orm_declarative_dc_relationships:
Relationship Configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^
pop: bool = False,
_adapt: bool = True,
) -> None:
+
+ if value is DONT_SET:
+ return
+
iterable = orig_iterable = value
new_keys = None
_default_scalar_value: Any
+ _disable_dataclass_default_factory: bool = False
+
def _get_dataclass_setup_options(
self,
decl_scan: _ClassScanMapperConfig,
dataclass_setup_arguments: _DataclassArguments,
) -> _AttributeOptions:
+ disable_descriptor_defaults = getattr(
+ decl_scan.cls, "_sa_disable_descriptor_defaults", False
+ )
+
dataclasses_default = self._attribute_options.dataclasses_default
+ dataclasses_default_factory = (
+ self._attribute_options.dataclasses_default_factory
+ )
+
if (
dataclasses_default is not _NoArg.NO_ARG
and not callable(dataclasses_default)
- and not getattr(
- decl_scan.cls, "_sa_disable_descriptor_defaults", False
- )
+ and not disable_descriptor_defaults
):
self._default_scalar_value = (
self._attribute_options.dataclasses_default
)
return self._attribute_options._replace(
- dataclasses_default=DONT_SET
+ dataclasses_default=DONT_SET,
+ )
+ elif (
+ self._disable_dataclass_default_factory
+ and dataclasses_default_factory is not _NoArg.NO_ARG
+ and not disable_descriptor_defaults
+ ):
+ return self._attribute_options._replace(
+ dataclasses_default=DONT_SET,
+ dataclasses_default_factory=_NoArg.NO_ARG,
)
-
return self._attribute_options
direction: RelationshipDirection
_init_args: _RelationshipArgs
+ _disable_dataclass_default_factory = True
def __init__(
self,
criterion = adapt_source(criterion)
return criterion
+ def _format_as_string(self, class_: type, key: str) -> str:
+ return f"{class_.__name__}.{key}"
+
def __str__(self) -> str:
- return str(self.parent.class_.__name__) + "." + self.key
+ return self._format_as_string(self.parent.class_, self.key)
def merge(
self,
if self.argument is None:
self.argument = cast("_RelationshipArgumentType[_T]", argument)
+ 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}"
+ )
+
@util.preload_module("sqlalchemy.orm.mapper")
def _setup_entity(self, __argument: Any = None, /) -> None:
if "entity" in self.__dict__:
class RelationshipDefaultFactoryTest(fixtures.TestBase):
- def test_list(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
-
- bs: Mapped[List["B"]] = relationship( # noqa: F821
- default_factory=lambda: [B(data="hi")]
- )
-
- class B(dc_decl_base):
- __tablename__ = "b"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
-
- a1 = A()
- eq_(a1.bs[0].data, "hi")
-
- def test_set(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
-
- bs: Mapped[Set["B"]] = relationship( # noqa: F821
- default_factory=lambda: {B(data="hi")}
- )
-
- class B(dc_decl_base, unsafe_hash=True):
- __tablename__ = "b"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
- a1 = A()
- eq_(a1.bs.pop().data, "hi")
-
- def test_oh_no_mismatch(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ @testing.variation("collection_type", ["list", "set", "list_set_mismatch"])
+ def test_no_funny_business(
+ self,
+ dc_decl_base: Type[MappedAsDataclass],
+ collection_type: testing.Variation,
+ ):
+ if collection_type.list:
+ expected = "list"
+ else:
+ expected = "set"
- bs: Mapped[Set["B"]] = relationship( # noqa: F821
- default_factory=lambda: [B(data="hi")]
- )
+ with expect_raises_message(
+ exc.ArgumentError,
+ f"For relationship A.bs using dataclass options, "
+ f"default_factory must be exactly <class '{expected}'>",
+ ):
- class B(dc_decl_base, unsafe_hash=True):
- __tablename__ = "b"
+ class A(dc_decl_base):
+ __tablename__ = "a"
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
- # old school collection mismatch error FTW
- with expect_raises_message(
- TypeError, "Incompatible collection type: list is not set-like"
- ):
- A()
+ if collection_type.list:
+ bs: Mapped[List["B"]] = relationship( # noqa: F821
+ default_factory=lambda: [B(data="hi")] # noqa: F821
+ )
+ elif collection_type.set:
+ bs: Mapped[Set["B"]] = relationship( # noqa: F821
+ default_factory=lambda: {B(data="hi")} # noqa: F821
+ )
+ elif collection_type.list_set_mismatch:
+ bs: Mapped[Set["B"]] = relationship( # noqa: F821
+ default_factory=list
+ )
+ else:
+ collection_type.fail()
def test_one_to_one_example(self, dc_decl_base: Type[MappedAsDataclass]):
"""test example in the relationship docs will derive uselist=False
# field was explicit so is overridden by merge
eq_(u3.status, "default_status")
+ @testing.variation("use_attr_init", [True, False])
+ def test_collection_merge_scenario(self, dc_decl_base, use_attr_init):
+ if use_attr_init:
+ attr_init_kw = {}
+ else:
+ attr_init_kw = {"init": False}
+
+ class MyClass(dc_decl_base):
+ __tablename__ = "myclass"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=False
+ )
+ name: Mapped[str]
+ things: Mapped[List["Thing"]] = relationship(
+ cascade="all, delete-orphan",
+ default_factory=list,
+ **attr_init_kw,
+ )
+
+ class Thing(dc_decl_base):
+ __tablename__ = "thing"
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=False
+ )
+ my_id: Mapped[int] = mapped_column(
+ ForeignKey("myclass.id"), init=False
+ )
+ name: Mapped[str]
+
+ dc_decl_base.metadata.create_all(testing.db)
+
+ with Session(testing.db) as sess:
+ if use_attr_init:
+ u1 = MyClass(id=1, name="x", things=[Thing(id=1, name="t1")])
+ else:
+ u1 = MyClass(id=1, name="x")
+ u1.things = [Thing(id=1, name="t1")]
+ sess.add(u1)
+
+ sess.flush()
+
+ u2 = sess.merge(MyClass(id=1, name="y"))
+ is_(u2, u1)
+ eq_(u2.name, "y")
+
+ if MyClass.use_descriptor_defaults:
+ tt = Thing(id=1, name="t1")
+ tt.my_id = 1
+ eq_(u2.things, [tt])
+ else:
+ eq_(u2.things, [])
+
+ if use_attr_init:
+ u3 = sess.merge(MyClass(id=1, name="z", things=[]))
+ else:
+ mc = MyClass(id=1, name="z")
+ mc.things = []
+ u3 = sess.merge(mc)
+
+ is_(u3, u1)
+ eq_(u3.name, "z")
+
+ # field was explicit so is overridden by merge
+ eq_(u3.things, [])
+
class SynonymDescriptorDefaultTest(AssertsCompiledSQL, fixtures.TestBase):
"""test new behaviors for synonyms given dataclasses descriptor defaults
class RelationshipDefaultFactoryTest(fixtures.TestBase):
- def test_list(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
-
- bs: Mapped[List["B"]] = relationship( # noqa: F821
- default_factory=lambda: [B(data="hi")]
- )
-
- class B(dc_decl_base):
- __tablename__ = "b"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
-
- a1 = A()
- eq_(a1.bs[0].data, "hi")
-
- def test_set(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
-
- bs: Mapped[Set["B"]] = relationship( # noqa: F821
- default_factory=lambda: {B(data="hi")}
- )
-
- class B(dc_decl_base, unsafe_hash=True):
- __tablename__ = "b"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
- a1 = A()
- eq_(a1.bs.pop().data, "hi")
-
- def test_oh_no_mismatch(self, dc_decl_base: Type[MappedAsDataclass]):
- class A(dc_decl_base):
- __tablename__ = "a"
-
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ @testing.variation("collection_type", ["list", "set", "list_set_mismatch"])
+ def test_no_funny_business(
+ self,
+ dc_decl_base: Type[MappedAsDataclass],
+ collection_type: testing.Variation,
+ ):
+ if collection_type.list:
+ expected = "list"
+ else:
+ expected = "set"
- bs: Mapped[Set["B"]] = relationship( # noqa: F821
- default_factory=lambda: [B(data="hi")]
- )
+ with expect_raises_message(
+ exc.ArgumentError,
+ f"For relationship A.bs using dataclass options, "
+ f"default_factory must be exactly <class '{expected}'>",
+ ):
- class B(dc_decl_base, unsafe_hash=True):
- __tablename__ = "b"
+ class A(dc_decl_base):
+ __tablename__ = "a"
- id: Mapped[int] = mapped_column(primary_key=True, init=False)
- a_id = mapped_column(ForeignKey("a.id"), init=False)
- data: Mapped[str]
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
- # old school collection mismatch error FTW
- with expect_raises_message(
- TypeError, "Incompatible collection type: list is not set-like"
- ):
- A()
+ if collection_type.list:
+ bs: Mapped[List["B"]] = relationship( # noqa: F821
+ default_factory=lambda: [B(data="hi")] # noqa: F821
+ )
+ elif collection_type.set:
+ bs: Mapped[Set["B"]] = relationship( # noqa: F821
+ default_factory=lambda: {B(data="hi")} # noqa: F821
+ )
+ elif collection_type.list_set_mismatch:
+ bs: Mapped[Set["B"]] = relationship( # noqa: F821
+ default_factory=list
+ )
+ else:
+ collection_type.fail()
def test_one_to_one_example(self, dc_decl_base: Type[MappedAsDataclass]):
"""test example in the relationship docs will derive uselist=False
# field was explicit so is overridden by merge
eq_(u3.status, "default_status")
+ @testing.variation("use_attr_init", [True, False])
+ def test_collection_merge_scenario(self, dc_decl_base, use_attr_init):
+ if use_attr_init:
+ attr_init_kw = {}
+ else:
+ attr_init_kw = {"init": False}
+
+ class MyClass(dc_decl_base):
+ __tablename__ = "myclass"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=False
+ )
+ name: Mapped[str]
+ things: Mapped[List["Thing"]] = relationship(
+ cascade="all, delete-orphan",
+ default_factory=list,
+ **attr_init_kw,
+ )
+
+ class Thing(dc_decl_base):
+ __tablename__ = "thing"
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=False
+ )
+ my_id: Mapped[int] = mapped_column(
+ ForeignKey("myclass.id"), init=False
+ )
+ name: Mapped[str]
+
+ dc_decl_base.metadata.create_all(testing.db)
+
+ with Session(testing.db) as sess:
+ if use_attr_init:
+ u1 = MyClass(id=1, name="x", things=[Thing(id=1, name="t1")])
+ else:
+ u1 = MyClass(id=1, name="x")
+ u1.things = [Thing(id=1, name="t1")]
+ sess.add(u1)
+
+ sess.flush()
+
+ u2 = sess.merge(MyClass(id=1, name="y"))
+ is_(u2, u1)
+ eq_(u2.name, "y")
+
+ if MyClass.use_descriptor_defaults:
+ tt = Thing(id=1, name="t1")
+ tt.my_id = 1
+ eq_(u2.things, [tt])
+ else:
+ eq_(u2.things, [])
+
+ if use_attr_init:
+ u3 = sess.merge(MyClass(id=1, name="z", things=[]))
+ else:
+ mc = MyClass(id=1, name="z")
+ mc.things = []
+ u3 = sess.merge(mc)
+
+ is_(u3, u1)
+ eq_(u3.name, "z")
+
+ # field was explicit so is overridden by merge
+ eq_(u3.things, [])
+
class SynonymDescriptorDefaultTest(AssertsCompiledSQL, fixtures.TestBase):
"""test new behaviors for synonyms given dataclasses descriptor defaults