]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add DictBundle
authorFederico Caselli <cfederico87@gmail.com>
Mon, 3 Nov 2025 22:04:45 +0000 (23:04 +0100)
committerFederico Caselli <cfederico87@gmail.com>
Sat, 15 Nov 2025 18:33:07 +0000 (18:33 +0000)
Added :class:`_orm.DictBundle` as a subclass of :class:`_orm.Bundle`
that returns ``dict`` objects.

Fixes: #12960
Change-Id: I798fb917779eb95bda575f2809e58c2f6d3c4706

doc/build/changelog/unreleased_21/12960.rst [new file with mode: 0644]
doc/build/orm/queryguide/api.rst
doc/build/orm/queryguide/select.rst
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/util.py
test/orm/dml/test_bulk_statements.py
test/orm/test_bundle.py

diff --git a/doc/build/changelog/unreleased_21/12960.rst b/doc/build/changelog/unreleased_21/12960.rst
new file mode 100644 (file)
index 0000000..e02a0b3
--- /dev/null
@@ -0,0 +1,6 @@
+.. change::
+    :tags: orm, usecase
+    :tickets: 12960
+
+    Added :class:`_orm.DictBundle` as a subclass of :class:`_orm.Bundle`
+    that returns ``dict`` objects.
index fe4d6b02a49f33cfca48d6136c6ef91c997a6f08..4cbe3e62d8a4e53f8e834cae36d7e0200c1ab98b 100644 (file)
@@ -528,6 +528,8 @@ Additional ORM API Constructs
 .. autoclass:: sqlalchemy.orm.Bundle
     :members:
 
+.. autoclass:: sqlalchemy.orm.DictBundle
+
 .. autofunction:: sqlalchemy.orm.with_loader_criteria
 
 .. autofunction:: sqlalchemy.orm.join
index a8b273a62dcca2b77495c4267609a5d201055122..53cf78349a9478c2a872f10593036ee05e7df50c 100644 (file)
@@ -231,11 +231,14 @@ The :class:`_orm.Bundle` is potentially useful for creating lightweight views
 and custom column groupings. :class:`_orm.Bundle` may also be subclassed in
 order to return alternate data structures; see
 :meth:`_orm.Bundle.create_row_processor` for an example.
+A dict-returning subclass :class:`_orm.DictBundle` is provided for convenience.
 
 .. seealso::
 
     :class:`_orm.Bundle`
 
+    :class:`_orm.DictBundle`
+
     :meth:`_orm.Bundle.create_row_processor`
 
 
index a829bf986f3b410e1ded82f803d15a270de7f99c..b9611d5f5c4dccb8fd1be6bdee6f2f378d1166fe 100644 (file)
@@ -157,6 +157,7 @@ from .strategy_options import with_expression as with_expression
 from .unitofwork import UOWTransaction as UOWTransaction
 from .util import Bundle as Bundle
 from .util import CascadeOptions as CascadeOptions
+from .util import DictBundle as DictBundle
 from .util import LoaderCriteriaOption as LoaderCriteriaOption
 from .util import object_mapper as object_mapper
 from .util import polymorphic_union as polymorphic_union
index fa63591c6d99b84695f1497e3e5c3f644fbae53a..c050fd498850afd29a9de5129b547984a9e7772a 100644 (file)
@@ -37,7 +37,6 @@ from typing import Union
 import weakref
 
 from . import attributes  # noqa
-from . import exc
 from . import exc as orm_exc
 from ._typing import _O
 from ._typing import insp_is_aliased_class
@@ -1519,7 +1518,7 @@ def _inspect_mc(
         if class_manager is None or not class_manager.is_mapped:
             return None
         mapper = class_manager.mapper
-    except exc.NO_STATE:
+    except orm_exc.NO_STATE:
         return None
     else:
         return mapper
@@ -1559,6 +1558,7 @@ class Bundle(
 
         :ref:`bundles`
 
+        :class:`.DictBundle`
 
     """
 
@@ -1582,7 +1582,7 @@ class Bundle(
 
     def __init__(
         self, name: str, *exprs: _ColumnExpressionArgument[Any], **kw: Any
-    ):
+    ) -> None:
         r"""Construct a new :class:`.Bundle`.
 
         e.g.::
@@ -1748,6 +1748,12 @@ class Bundle(
             for row in session.execute(select(bn)).where(bn.c.data1 == "d1"):
                 print(row.mybundle["data1"], row.mybundle["data2"])
 
+        The above example is available natively using :class:`.DictBundle`
+
+        .. seealso::
+
+            :class:`.DictBundle`
+
         """  # noqa: E501
         keyed_tuple = result_tuple(labels, [() for l in labels])
 
@@ -1757,6 +1763,47 @@ class Bundle(
         return proc
 
 
+class DictBundle(Bundle[_T]):
+    """Like :class:`.Bundle` but returns ``dict`` instances instead of
+    named tuple like objects::
+
+        bn = DictBundle("mybundle", MyClass.data1, MyClass.data2)
+        for row in session.execute(select(bn)).where(bn.c.data1 == "d1"):
+            print(row.mybundle["data1"], row.mybundle["data2"])
+
+    Differently from :class:`.Bundle`, multiple columns with the same name are
+    not supported.
+
+    .. versionadded:: 2.1
+
+    .. seealso::
+
+        :ref:`bundles`
+
+        :class:`.Bundle`
+    """
+
+    def __init__(
+        self, name: str, *exprs: _ColumnExpressionArgument[Any], **kw: Any
+    ) -> None:
+        super().__init__(name, *exprs, **kw)
+        if len(set(self.c.keys())) != len(self.c):
+            raise sa_exc.ArgumentError(
+                "DictBundle does not support duplicate column names"
+            )
+
+    def create_row_processor(
+        self,
+        query: Select[Unpack[TupleAny]],
+        procs: Sequence[Callable[[Row[Unpack[TupleAny]]], Any]],
+        labels: Sequence[str],
+    ) -> Callable[[Row[Unpack[TupleAny]]], dict[str, Any]]:
+        def proc(row: Row[Unpack[TupleAny]]) -> dict[str, Any]:
+            return dict(zip(labels, (proc(row) for proc in procs)))
+
+        return proc
+
+
 def _orm_full_deannotate(element: _SA) -> _SA:
     return sql_util._deep_deannotate(element)
 
index f8153c9edd1a1e97930a7b7cfdc825eeca62299c..430c0bc992d3a71c0c06be99d8a983f2df475cf1 100644 (file)
@@ -29,6 +29,7 @@ from sqlalchemy.orm import aliased
 from sqlalchemy.orm import Bundle
 from sqlalchemy.orm import column_property
 from sqlalchemy.orm import DeclarativeBase
+from sqlalchemy.orm import DictBundle
 from sqlalchemy.orm import immediateload
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm import lazyload
@@ -482,7 +483,8 @@ class InsertStmtTest(testing.AssertsExecutionResults, fixtures.TestBase):
         "insert_type",
         ["bulk", ("values", testing.requires.multivalues_inserts), "single"],
     )
-    def test_insert_returning_bundle(self, decl_base, insert_type):
+    @testing.combinations(Bundle, DictBundle, argnames="kind")
+    def test_insert_returning_bundle(self, decl_base, insert_type, kind):
         """test #10776"""
 
         class User(decl_base):
@@ -496,7 +498,7 @@ class InsertStmtTest(testing.AssertsExecutionResults, fixtures.TestBase):
 
         decl_base.metadata.create_all(testing.db)
         insert_stmt = insert(User).returning(
-            User.name, Bundle("mybundle", User.id, User.x, User.y)
+            User.name, kind("mybundle", User.id, User.x, User.y)
         )
 
         s = fixture_session()
@@ -528,16 +530,23 @@ class InsertStmtTest(testing.AssertsExecutionResults, fixtures.TestBase):
             insert_type.fail()
 
         if insert_type.single:
-            eq_(result.all(), [("some name 1", (1, 1, 2))])
+            exp = {Bundle: (1, 1, 2), DictBundle: {"id": 1, "x": 1, "y": 2}}
+            eq_(result.all(), [("some name 1", exp[kind])])
         else:
-            eq_(
-                result.all(),
-                [
+            if kind is Bundle:
+                exp = [
                     ("some name 1", (1, 1, 2)),
                     ("some name 2", (2, 2, 3)),
                     ("some name 3", (3, 3, 4)),
-                ],
-            )
+                ]
+            else:
+                exp = [
+                    ("some name 1", {"id": 1, "x": 1, "y": 2}),
+                    ("some name 2", {"id": 2, "x": 2, "y": 3}),
+                    ("some name 3", {"id": 3, "x": 3, "y": 4}),
+                ]
+
+            eq_(result.all(), exp)
 
     @testing.variation(
         "use_returning", [(True, testing.requires.insert_returning), False]
index a1bd399a4cb6cc39f8398d798b42212c8448b0e7..55e19744f75fba4508e1c175d0568fb065e1688f 100644 (file)
@@ -9,6 +9,7 @@ from sqlalchemy import testing
 from sqlalchemy import tuple_
 from sqlalchemy.orm import aliased
 from sqlalchemy.orm import Bundle
+from sqlalchemy.orm import DictBundle
 from sqlalchemy.orm import relationship
 from sqlalchemy.orm import Session
 from sqlalchemy.sql.elements import ClauseList
@@ -16,6 +17,7 @@ from sqlalchemy.testing import assert_raises_message
 from sqlalchemy.testing import AssertsCompiledSQL
 from sqlalchemy.testing import eq_
 from sqlalchemy.testing import fixtures
+from sqlalchemy.testing.assertions import expect_raises_message
 from sqlalchemy.testing.fixtures import fixture_session
 from sqlalchemy.testing.schema import Column
 from sqlalchemy.testing.schema import Table
@@ -124,6 +126,14 @@ class BundleTest(fixtures.MappedTest, AssertsCompiledSQL):
         self.assert_compile(ClauseList(Data.id, Other.id), "data.id, other.id")
         self.assert_compile(bundle.__clause_element__(), "data.id, other.id")
 
+    def test_dict_same_named_col_clauselist(self):
+        Data, Other = self.classes("Data", "Other")
+        with expect_raises_message(
+            exc.ArgumentError,
+            "DictBundle does not support duplicate column names",
+        ):
+            DictBundle("pk", Data.id, Other.id)
+
     def test_same_named_col_in_orderby(self):
         Data, Other = self.classes("Data", "Other")
         bundle = Bundle("pk", Data.id, Other.id)
@@ -151,10 +161,11 @@ class BundleTest(fixtures.MappedTest, AssertsCompiledSQL):
             [((1, 1),), ((2, 2),)],
         )
 
-    def test_c_attr(self):
+    @testing.combinations(Bundle, DictBundle, argnames="kind")
+    def test_c_attr(self, kind):
         Data = self.classes.Data
 
-        b1 = Bundle("b1", Data.d1, Data.d2)
+        b1 = kind("b1", Data.d1, Data.d2)
 
         self.assert_compile(
             select(b1.c.d1, b1.c.d2), "SELECT data.d1, data.d2 FROM data"
@@ -226,13 +237,6 @@ class BundleTest(fixtures.MappedTest, AssertsCompiledSQL):
         Data = self.classes.Data
         sess = fixture_session()
 
-        class DictBundle(Bundle):
-            def create_row_processor(self, query, procs, labels):
-                def proc(row):
-                    return dict(zip(labels, (proc(row) for proc in procs)))
-
-                return proc
-
         if col_type.core:
             data_table = self.tables.data
 
@@ -323,7 +327,7 @@ class BundleTest(fixtures.MappedTest, AssertsCompiledSQL):
         class MyBundle(Bundle):
             def create_row_processor(self, query, procs, labels):
                 def proc(row):
-                    return dict(zip(labels, (proc(row) for proc in procs)))
+                    return list(zip(labels, (proc(row) for proc in procs)))
 
                 return proc
 
@@ -332,9 +336,9 @@ class BundleTest(fixtures.MappedTest, AssertsCompiledSQL):
         eq_(
             sess.query(b1).filter(b1.c.d1.between("d3d1", "d5d1")).all(),
             [
-                ({"d2": "d3d2", "d1": "d3d1"},),
-                ({"d2": "d4d2", "d1": "d4d1"},),
-                ({"d2": "d5d2", "d1": "d5d1"},),
+                ([("d1", "d3d1"), ("d2", "d3d2")],),
+                ([("d1", "d4d1"), ("d2", "d4d2")],),
+                ([("d1", "d5d1"), ("d2", "d5d2")],),
             ],
         )