From 850f7ae12ea5783ca451a9e576a17a35966b290f Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Mon, 3 Nov 2025 23:04:45 +0100 Subject: [PATCH] Add DictBundle 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 | 6 +++ doc/build/orm/queryguide/api.rst | 2 + doc/build/orm/queryguide/select.rst | 3 ++ lib/sqlalchemy/orm/__init__.py | 1 + lib/sqlalchemy/orm/util.py | 53 +++++++++++++++++++-- test/orm/dml/test_bulk_statements.py | 25 ++++++---- test/orm/test_bundle.py | 30 +++++++----- 7 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 doc/build/changelog/unreleased_21/12960.rst diff --git a/doc/build/changelog/unreleased_21/12960.rst b/doc/build/changelog/unreleased_21/12960.rst new file mode 100644 index 0000000000..e02a0b33ac --- /dev/null +++ b/doc/build/changelog/unreleased_21/12960.rst @@ -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. diff --git a/doc/build/orm/queryguide/api.rst b/doc/build/orm/queryguide/api.rst index fe4d6b02a4..4cbe3e62d8 100644 --- a/doc/build/orm/queryguide/api.rst +++ b/doc/build/orm/queryguide/api.rst @@ -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 diff --git a/doc/build/orm/queryguide/select.rst b/doc/build/orm/queryguide/select.rst index a8b273a62d..53cf78349a 100644 --- a/doc/build/orm/queryguide/select.rst +++ b/doc/build/orm/queryguide/select.rst @@ -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` diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index a829bf986f..b9611d5f5c 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -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 diff --git a/lib/sqlalchemy/orm/util.py b/lib/sqlalchemy/orm/util.py index fa63591c6d..c050fd4988 100644 --- a/lib/sqlalchemy/orm/util.py +++ b/lib/sqlalchemy/orm/util.py @@ -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) diff --git a/test/orm/dml/test_bulk_statements.py b/test/orm/dml/test_bulk_statements.py index f8153c9edd..430c0bc992 100644 --- a/test/orm/dml/test_bulk_statements.py +++ b/test/orm/dml/test_bulk_statements.py @@ -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] diff --git a/test/orm/test_bundle.py b/test/orm/test_bundle.py index a1bd399a4c..55e19744f7 100644 --- a/test/orm/test_bundle.py +++ b/test/orm/test_bundle.py @@ -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")],), ], ) -- 2.47.3