]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
add option to create scalar object on none attribute set
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 24 Jun 2023 01:57:41 +0000 (21:57 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 24 Jun 2023 01:57:41 +0000 (21:57 -0400)
Added new option to :func:`.association_proxy`
:paramref:`.association_proxy.create_on_none_assignment`; when an
association proxy which refers to a scalar relationship is assigned the
value ``None``, and the referenced object is not present, a new object is
created via the creator.  This was apparently an undefined behavior in the
1.2 series that was silently removed.

Fixes: #10013
Change-Id: I3aae484b8cf5218588b1db63e691cd86214fbbad

doc/build/changelog/unreleased_20/10013.rst [new file with mode: 0644]
lib/sqlalchemy/ext/associationproxy.py
test/ext/test_associationproxy.py

diff --git a/doc/build/changelog/unreleased_20/10013.rst b/doc/build/changelog/unreleased_20/10013.rst
new file mode 100644 (file)
index 0000000..8939abb
--- /dev/null
@@ -0,0 +1,10 @@
+.. change::
+    :tags: usecase, ext
+    :tickets: 10013
+
+    Added new option to :func:`.association_proxy`
+    :paramref:`.association_proxy.create_on_none_assignment`; when an
+    association proxy which refers to a scalar relationship is assigned the
+    value ``None``, and the referenced object is not present, a new object is
+    created via the creator.  This was apparently an undefined behavior in the
+    1.2 series that was silently removed.
index 0b74e647d4e81a15ad9d1444c850793a5f29121a..38755c8fa3e2ec1dff1e4ef7145e5e864fca543a 100644 (file)
@@ -91,6 +91,7 @@ def association_proxy(
     proxy_bulk_set: Optional[_ProxyBulkSetProtocol] = None,
     info: Optional[_InfoType] = None,
     cascade_scalar_deletes: bool = False,
+    create_on_none_assignment: bool = False,
     init: Union[_NoArg, bool] = _NoArg.NO_ARG,
     repr: Union[_NoArg, bool] = _NoArg.NO_ARG,  # noqa: A002
     default: Optional[Any] = _NoArg.NO_ARG,
@@ -156,6 +157,14 @@ def association_proxy(
 
             :ref:`cascade_scalar_deletes` - complete usage example
 
+    :param create_on_none_assignment: when True, indicates that setting
+      the proxied value to ``None`` should **create** the source object
+      if it does not exist, using the creator.  Only applies to scalar
+      attributes.  This is mutually exclusive
+      vs. the :paramref:`.assocation_proxy.cascade_scalar_deletes`.
+
+      .. versionadded:: 2.0.18
+
     :param init: Specific to :ref:`orm_declarative_native_dataclasses`,
      specifies if the mapped attribute should be part of the ``__init__()``
      method as generated by the dataclass process.
@@ -226,6 +235,7 @@ def association_proxy(
         proxy_bulk_set=proxy_bulk_set,
         info=info,
         cascade_scalar_deletes=cascade_scalar_deletes,
+        create_on_none_assignment=create_on_none_assignment,
         attribute_options=_AttributeOptions(
             init, repr, default, default_factory, compare, kw_only
         ),
@@ -320,6 +330,8 @@ class _AssociationProxyProtocol(Protocol[_T]):
     key: str
     target_collection: str
     value_attr: str
+    cascade_scalar_deletes: bool
+    create_on_none_assignment: bool
     getset_factory: Optional[_GetSetFactoryProtocol]
     proxy_factory: Optional[_ProxyFactoryProtocol]
     proxy_bulk_set: Optional[_ProxyBulkSetProtocol]
@@ -361,6 +373,7 @@ class AssociationProxy(
         proxy_bulk_set: Optional[_ProxyBulkSetProtocol] = None,
         info: Optional[_InfoType] = None,
         cascade_scalar_deletes: bool = False,
+        create_on_none_assignment: bool = False,
         attribute_options: Optional[_AttributeOptions] = None,
     ):
         """Construct a new :class:`.AssociationProxy`.
@@ -378,7 +391,14 @@ class AssociationProxy(
         self.getset_factory = getset_factory
         self.proxy_factory = proxy_factory
         self.proxy_bulk_set = proxy_bulk_set
+
+        if cascade_scalar_deletes and create_on_none_assignment:
+            raise exc.ArgumentError(
+                "The cascade_scalar_deletes and create_on_none_assignment "
+                "parameters are mutually exclusive."
+            )
         self.cascade_scalar_deletes = cascade_scalar_deletes
+        self.create_on_none_assignment = create_on_none_assignment
 
         self.key = "_%s_%s_%s" % (
             type(self).__name__,
@@ -891,7 +911,10 @@ class AssociationProxyInstance(SQLORMOperations[_T]):
             )
             target = getattr(obj, self.target_collection)
             if target is None:
-                if values is None:
+                if (
+                    values is None
+                    and not self.parent.create_on_none_assignment
+                ):
                     return
                 setattr(obj, self.target_collection, creator(values))
             else:
index d7b7b0bb20e223577451bb46cdabfbc1642eb804..7feb1267c6a194e944901464acd4ec3f9b49442f 100644 (file)
@@ -44,6 +44,8 @@ from sqlalchemy.testing import expect_warnings
 from sqlalchemy.testing import fixtures
 from sqlalchemy.testing import is_
 from sqlalchemy.testing import is_false
+from sqlalchemy.testing import is_none
+from sqlalchemy.testing import is_not_none
 from sqlalchemy.testing.assertions import expect_raises_message
 from sqlalchemy.testing.entities import ComparableMixin  # noqa
 from sqlalchemy.testing.fixtures import fixture_session
@@ -1101,6 +1103,64 @@ class ScalarTest(fixtures.MappedTest):
         p2 = Parent("p2")
         p2.bar = "quux"
 
+    def test_scalar_opts_exclusive(self):
+        with expect_raises_message(
+            exc.ArgumentError,
+            "The cascade_scalar_deletes and create_on_none_assignment "
+            "parameters are mutually exclusive.",
+        ):
+            association_proxy(
+                "a",
+                "b",
+                cascade_scalar_deletes=True,
+                create_on_none_assignment=True,
+            )
+
+    @testing.variation("create_on_none", [True, False])
+    @testing.variation("specify_creator", [True, False])
+    def test_create_on_set_none(
+        self, create_on_none, specify_creator, decl_base
+    ):
+        class A(decl_base):
+            __tablename__ = "a"
+            id = mapped_column(Integer, primary_key=True)
+            b_id = mapped_column(ForeignKey("b.id"))
+            b = relationship("B")
+
+            if specify_creator:
+                b_data = association_proxy(
+                    "b",
+                    "data",
+                    create_on_none_assignment=bool(create_on_none),
+                    creator=lambda data: B(data=data),
+                )
+            else:
+                b_data = association_proxy(
+                    "b", "data", create_on_none_assignment=bool(create_on_none)
+                )
+
+        class B(decl_base):
+            __tablename__ = "b"
+            id = mapped_column(Integer, primary_key=True)
+            data = mapped_column(String)
+
+            def __init__(self, data=None):
+                self.data = data
+
+        a1 = A()
+        is_none(a1.b)
+        a1.b_data = None
+
+        if create_on_none:
+            is_not_none(a1.b)
+        else:
+            is_none(a1.b)
+
+        a1.b_data = "data"
+
+        a1.b_data = None
+        is_not_none(a1.b)
+
     @testing.provide_metadata
     def test_empty_scalars(self):
         metadata = self.metadata
@@ -2711,6 +2771,7 @@ class ScalarRemoveTest:
     useobject = None
     cascade_scalar_deletes = None
     uselist = None
+    create_on_none_assignment = False
 
     @classmethod
     def setup_classes(cls):
@@ -2725,6 +2786,7 @@ class ScalarRemoveTest:
                 "b",
                 creator=lambda b: AB(b=b),
                 cascade_scalar_deletes=cls.cascade_scalar_deletes,
+                create_on_none_assignment=cls.create_on_none_assignment,
             )
 
         if cls.useobject:
@@ -2793,7 +2855,12 @@ class ScalarRemoveTest:
 
         a1.b = None
 
-        assert a1.ab is None
+        if self.create_on_none_assignment:
+            assert isinstance(a1.ab, AB)
+            assert a1.ab is not None
+            eq_(a1.ab.b, None)
+        else:
+            assert a1.ab is None
 
     def test_del_already_nonpresent(self):
         if self.useobject:
@@ -2932,6 +2999,12 @@ class ScalarRemoveScalarObjectNoCascade(
     uselist = False
 
 
+class ScalarRemoveScalarObjectNoCascadeNoneAssign(
+    ScalarRemoveScalarObjectNoCascade
+):
+    create_on_none_assignment = True
+
+
 class ScalarRemoveListScalarNoCascade(
     ScalarRemoveTest, fixtures.DeclarativeMappedTest
 ):
@@ -2941,6 +3014,12 @@ class ScalarRemoveListScalarNoCascade(
     uselist = True
 
 
+class ScalarRemoveListScalarNoCascadeNoneAssign(
+    ScalarRemoveScalarObjectNoCascade
+):
+    create_on_none_assignment = True
+
+
 class ScalarRemoveScalarScalarNoCascade(
     ScalarRemoveTest, fixtures.DeclarativeMappedTest
 ):