]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Fixes: 13363 Process ORM result rows as plain tuples without Row construction
authorOliver Parker <oliver.parker@gridedge.co.uk>
Mon, 15 Jun 2026 14:41:06 +0000 (10:41 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 15 Jun 2026 20:01:49 +0000 (16:01 -0400)
ORM result row fetching now processes rows as plain tuples rather than
constructing :class:`.Row` objects, as ORM loaders use position-based
access and do not require the :class:`.Row` interface. :class:`.Row`
construction is still used when engine-level debug logging is enabled so
that individual rows can be logged. Benchmarks show a 3-16% improvement in
ORM entity load times depending on query shape.  Pull request courtesy
Oliver Parker.

Adds Result._all_interim_rows(), which returns the remaining rows as processed plain tuples, applying result processors and tuple filters but skipping Row object construction.  ORM loading uses this for its row fetch; its row getters are position-based itemgetters that accept any tuple-like row.  Results that require row logging or have scalar sources fall back to Row construction.

Adds tests covering the new behavior: rows are plain tuples with result processors applied, and Row construction still occurs when engine-level row logging is enabled, at both the Result and ORM loading level.

Benchmarked on an in-memory SQLite database with this change alone (median of 30 runs, ms); this path is used by all ORM entity loads:

    plain_small (500 rows x 5 cols)    2.43 ->  2.28    -6%
    plain_wide (2000 rows x 25 cols)   8.17 ->  7.24   -11%
    joined_o2m (500 x 10)             18.56 -> 16.95    -9%
    selectin_o2m (500 x 10)           18.45 -> 17.79    -4%
    selectin_nested (50 x 10 x 10)    18.67 -> 17.33    -7%
    selectin_m2m (500 x 10)           12.70 -> 11.53    -9%
    selectin_m2o (5000 -> 200)        15.83 -> 15.29    -3%
    selectin_o2m_few_big (20 x 500)   32.96 -> 31.29    -5%
    subquery_o2m (500 x 10)           21.51 -> 18.16   -16%

Fixes: #13363
Closes: #13365
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/13365
Pull-request-sha: 46f4125ead7e37b780160ae70e03ec204ecb9617

Change-Id: Ia91a89c8cac78d4790391bc5b171b76d4aaca71b

doc/build/changelog/unreleased_21/13363.rst [new file with mode: 0644]
lib/sqlalchemy/engine/_result_cy.py
lib/sqlalchemy/orm/loading.py
lib/sqlalchemy/orm/strategies.py
lib/sqlalchemy/testing/fixtures/base.py
test/engine/test_logging.py
test/orm/test_loading.py
test/perf/compiled_extensions/result.py
test/profiles.txt
test/sql/test_resultset.py

diff --git a/doc/build/changelog/unreleased_21/13363.rst b/doc/build/changelog/unreleased_21/13363.rst
new file mode 100644 (file)
index 0000000..4daaac4
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: performance, orm
+    :tickets: 13363
+
+    ORM result row fetching now processes rows as plain tuples rather than
+    constructing :class:`.Row` objects, as ORM loaders use position-based
+    access and do not require the :class:`.Row` interface. :class:`.Row`
+    construction is still used when engine-level debug logging is enabled so
+    that individual rows can be logged. Benchmarks show a 3-16% improvement in
+    ORM entity load times depending on query shape.  Pull request courtesy
+    Oliver Parker.
index 51c56020024ca42f741007a62545404379bb2165..760ed87ec3f3d0d2d80f985ef6310226742563c5 100644 (file)
@@ -132,7 +132,22 @@ class BaseResultInternal(Generic[_R]):
     @HasMemoized_ro_memoized_attribute
     def _row_getter(
         self,
-    ) -> tuple[Callable[..., _R] | None, Callable[..., Sequence[_R]] | None]:
+    ) -> tuple[
+        Callable[..., _R] | None,
+        Callable[..., Sequence[_R]] | None,
+        Callable[..., Sequence[Any]] | None,
+    ]:
+        """Return a three-tuple of callables
+        ``(single_row, many_rows, interim_rows)``.
+
+        ``single_row`` and ``many_rows`` produce :class:`.Row` objects for
+        one input row or a sequence of them.  ``interim_rows`` produces
+        rows in "interim" form for a sequence of input rows: plain tuples
+        with result processors and tuple filters applied but no
+        :class:`.Row` construction, falling back to ``many_rows`` when
+        Row objects are required (row logging, scalar sources).
+
+        """
         real_result = self if self._real_result is None else self._real_result
 
         metadata = self._metadata
@@ -141,7 +156,7 @@ class BaseResultInternal(Generic[_R]):
 
         if real_result._source_supports_scalars:
             if not self._generate_rows:
-                return None, None
+                return None, None, None
             else:
                 flag = _FLAG_SCALAR_TO_TUPLE
         elif tuple_filters is not None:
@@ -197,7 +212,15 @@ class BaseResultInternal(Generic[_R]):
                         _Row(metadata, None, key_to_index, row) for row in rows
                     ]
 
-            return single_row_simple, many_rows_simple  # type: ignore[return-value] # noqa: E501
+            def interim_rows_simple(rows: Sequence[Any], /) -> Sequence[Any]:
+                # interim rows must be tuples so that position-based
+                # getters and slices apply; most DBAPIs return tuples
+                # already
+                if rows and type(rows[0]) is not tuple:
+                    return [tuple(row) for row in rows]
+                return rows
+
+            return single_row_simple, many_rows_simple, interim_rows_simple  # type: ignore[return-value] # noqa: E501
 
         first_row: cython.bint = True
 
@@ -239,7 +262,41 @@ class BaseResultInternal(Generic[_R]):
             def many_rows(rows: Sequence[Any], /) -> list[Any]:
                 return [single_row(row) for row in rows]
 
-        return single_row, many_rows  # type: ignore[return-value]
+        if flag == _FLAG_SCALAR_TO_TUPLE or has_log_row:
+            # scalar-source rows have no tuple form; row logging requires
+            # the Row objects themselves
+            interim_rows = many_rows
+        else:
+
+            def single_interim_row(input_row: Sequence[Any], /) -> Any:
+                if flag == _FLAG_TUPLE_FILTER:
+                    input_row = tuple_filters(input_row)
+                if proc_size != 0:
+                    input_row = _apply_processors(
+                        processors, proc_size, proc_valid, input_row
+                    )
+                elif type(input_row) is not tuple:
+                    input_row = tuple(input_row)
+                return input_row
+
+            if cython.compiled:
+
+                def interim_rows(rows: Sequence[Any], /) -> Sequence[Any]:
+                    size: cython.Py_hash_t = len(rows)
+                    i: cython.Py_ssize_t
+                    result: list = PyList_New(size)
+                    for i in range(size):
+                        row: object = single_interim_row(rows[i])
+                        Py_INCREF(row)
+                        PyList_SET_ITEM(result, i, row)
+                    return result
+
+            else:
+
+                def interim_rows(rows: Sequence[Any], /) -> Sequence[Any]:
+                    return [single_interim_row(row) for row in rows]
+
+        return single_row, many_rows, interim_rows  # type: ignore[return-value] # noqa: E501
 
     @HasMemoized_ro_memoized_attribute
     def _iterator_getter(self) -> Callable[[], Iterator[_R]]:
@@ -277,10 +334,26 @@ class BaseResultInternal(Generic[_R]):
 
         return iterrows
 
-    def _raw_all_rows(self) -> Sequence[_R]:
-        make_rows = self._row_getter[1]
-        assert make_rows is not None
-        return make_rows(self._fetchall_impl())
+    def _raw_all_tuples(self) -> Sequence[tuple[Any, ...]]:
+        """Return all remaining rows as plain processed tuples, as produced
+        by the ``interim_rows`` element of :attr:`._row_getter`;
+        :class:`.Row` objects are produced instead where required (row
+        logging, scalar sources).
+
+        Used by ORM loading, whose row getters are position-based and
+        accept any tuple-like row.
+
+        Uniquing and post-creational filters are not applied here, so
+        may not be present on the :class:`.Result` when this method is
+        used.
+
+        """
+        assert self._unique_filter_state is None
+        assert self._post_creational_filter is None
+        interim_rows = self._row_getter[2]
+        assert interim_rows is not None
+        rows = self._fetchall_impl()
+        return interim_rows(rows)
 
     def _allrows(self) -> Sequence[_R]:
         post_creational_filter = self._post_creational_filter
index 6bae934508096050a5176b6f278bc402c2a13934..7d703357c254393b5c9dc974da3a6ca02d1ef801 100644 (file)
@@ -228,7 +228,7 @@ def instances(
                 if not fetch:
                     break
             else:
-                fetch = cursor._raw_all_rows()
+                fetch = cursor._raw_all_tuples()
 
             if single_entity:
                 proc = process[0]
index dae48d2e6d2d8db1fe0980a12b14161ce5e81934..2bb9c14cb46e69387302b87fc9fb4f273200374d 100644 (file)
@@ -3426,8 +3426,12 @@ class _SelectInLoader(_PostLoader, util.MemoizedSlots):
                 execution_options=execution_options,
             )
             if result.context is not None and result.context.requires_uniquing:
-                result = result.unique()
-            data = {k: v for k, v in result}
+                rows = result.unique()
+            else:
+                # consume the result as plain tuples, skipping per-row
+                # Row construction
+                rows = result._raw_all_tuples()
+            data = {k: v for k, v in rows}
 
             for key in chunk:
                 # for a real foreign key and no concurrent changes to the
@@ -3475,9 +3479,13 @@ class _SelectInLoader(_PostLoader, util.MemoizedSlots):
                 execution_options=execution_options,
             )
             if result.context is not None and result.context.requires_uniquing:
-                result = result.unique()
+                rows = result.unique()
+            else:
+                # consume the result as plain tuples, skipping per-row
+                # Row construction
+                rows = result._raw_all_tuples()
             data = collections.defaultdict(list)
-            for k, v in itertools.groupby(result, lambda x: x[0]):
+            for k, v in itertools.groupby(rows, lambda x: x[0]):
                 data[k].extend(vv[1] for vv in v)
 
             for key, state, state_dict, overwrite in chunk:
index d45ff46151b69ea92d56a7a3b2dcde8599407181..cbfc77833cd504844296c09d48cc2c8b58d384ec 100644 (file)
@@ -11,6 +11,7 @@ from __future__ import annotations
 
 from collections.abc import Callable
 from collections.abc import Sequence
+import logging
 
 import sqlalchemy as sa
 from .. import assertions
@@ -381,6 +382,28 @@ class TestBase:
 
         return run_test
 
+    @config.fixture()
+    def debug_logging_engine(self, testing_engine):
+        log = logging.getLogger("sqlalchemy.engine")
+        existing_level = log.level
+
+        buf = logging.handlers.BufferingHandler(100)
+        log.addHandler(buf)
+
+        def get_testing_engine(echo=None, log_level=None):
+            options = {"sqlite_share_pool": True}
+            if echo is not None:
+                options["echo"] = echo
+            if log_level:
+                log.setLevel(logging.DEBUG)
+            return testing_engine(options=options)
+
+        try:
+            yield get_testing_engine, buf
+        finally:
+            log.setLevel(existing_level)
+            log.removeHandler(buf)
+
 
 _connection_fixture_connection = None
 
index 5ba30927875c203ae7357b1ab09f66dacbf10bf7..1866f258c6bd3921daf97544085b8f61e8edcd61 100644 (file)
@@ -1161,32 +1161,18 @@ class RowLoggingTest(fixtures.TablesTest):
             ],
         )
 
-    def setup_test(self):
-        self.buf = logging.handlers.BufferingHandler(100)
-        logging.getLogger("sqlalchemy.engine").addHandler(self.buf)
-
-    def teardown_test(self):
-        logging.getLogger("sqlalchemy.engine").removeHandler(self.buf)
-
     @testing.fixture(params=["echo_debug", "plain_logging"])
-    def debug_engine(self, testing_engine, request):
+    def debug_engine(self, debug_logging_engine, request):
+        testing_engine, buf = debug_logging_engine
         if request.param == "echo_debug":
-            yield testing_engine(
-                options={"echo": "debug", "sqlite_share_pool": True}
-            )
+            yield testing_engine(echo="debug"), buf
         elif request.param == "plain_logging":
-            log = logging.getLogger("sqlalchemy.engine")
-            existing_level = log.level
-            log.setLevel(logging.DEBUG)
-            try:
-                yield testing_engine(options={"sqlite_share_pool": True})
-            finally:
-                log.setLevel(existing_level)
-
-    def _get_row_messages(self):
+            yield testing_engine(log_level=logging.DEBUG), buf
+
+    def _get_row_messages(self, buf):
         return [
             rec.getMessage()
-            for rec in self.buf.buffer
+            for rec in buf.buffer
             if rec.getMessage().startswith("Row ")
         ]
 
@@ -1197,36 +1183,44 @@ class RowLoggingTest(fixtures.TablesTest):
         ("fetchmany", lambda result: result.fetchmany(2), 2),
         ("scalar", lambda result: result.scalar(), 1),
         ("partitions", lambda result: list(result.partitions(2)), 3),
-        ("_raw_all_rows", lambda result: result._raw_all_rows(), 3),
+        ("_raw_all_tuples", lambda result: result._raw_all_tuples(), 3),
         id_="iaa",
         argnames="consume,expected_rows",
     )
     def test_row_logging(self, debug_engine, consume, expected_rows):
         t = self.tables.data
-        self.buf.flush()
 
-        with debug_engine.connect() as conn:
+        engine, buf = debug_engine
+        with engine.connect() as conn:
             result = conn.execute(select(t).order_by(t.c.id))
             consume(result)
 
         eq_(
-            self._get_row_messages(),
+            self._get_row_messages(buf),
             ["Row (%d, 'v%d')" % (i, i) for i in range(1, expected_rows + 1)],
         )
 
     @testing.combinations(
         ("echo_false", False),
         ("echo_true", True),
+        ("echo_debug", "debug"),
         id_="ia",
         argnames="echo",
     )
-    def test_no_row_logging(self, testing_engine, echo):
+    def test_row_logging_flag(self, debug_logging_engine, echo):
         t = self.tables.data
-        eng = testing_engine(options={"echo": echo, "sqlite_share_pool": True})
-        self.buf.flush()
+
+        testing_engine, buf = debug_logging_engine
+        eng = testing_engine(echo=echo)
 
         with eng.connect() as conn:
             result = conn.execute(select(t).order_by(t.c.id))
             result.all()
 
-        eq_(self._get_row_messages(), [])
+        if echo == "debug":
+            eq_(
+                self._get_row_messages(buf),
+                ["Row (%d, 'v%d')" % (i, i) for i in range(1, 4)],
+            )
+        else:
+            eq_(self._get_row_messages(buf), [])
index 8be0f3168d8207980b894f979fce6c4e0919ee01..29397d45bf14a93998962fbe57c9a26fecce6ed4 100644 (file)
@@ -1,21 +1,28 @@
 from sqlalchemy import delete
 from sqlalchemy import exc
 from sqlalchemy import insert
+from sqlalchemy import Integer
 from sqlalchemy import literal
 from sqlalchemy import literal_column
 from sqlalchemy import select
+from sqlalchemy import String
 from sqlalchemy import testing
 from sqlalchemy import text
+from sqlalchemy import TypeDecorator
 from sqlalchemy import update
 from sqlalchemy.orm import loading
 from sqlalchemy.orm import relationship
+from sqlalchemy.orm import Session
+from sqlalchemy.testing import fixtures
 from sqlalchemy.testing import is_true
 from sqlalchemy.testing import mock
 from sqlalchemy.testing.assertions import assert_raises
 from sqlalchemy.testing.assertions import assert_raises_message
 from sqlalchemy.testing.assertions import eq_
 from sqlalchemy.testing.assertions import expect_raises_message
+from sqlalchemy.testing.assertions import is_
 from sqlalchemy.testing.fixtures import fixture_session
+from sqlalchemy.testing.schema import Column
 from . import _fixtures
 
 # class GetFromIdentityTest(_fixtures.FixtureTest):
@@ -270,3 +277,64 @@ class MergeResultTest(_fixtures.FixtureTest):
         it = list(it())
         eq_([(x.id, y) for x, y in it], [(7, 7), (8, 8), (9, 9)])
         eq_(list(it[0]._mapping.keys()), ["User", "id"])
+
+
+class InterimRowsLoadTest(fixtures.DeclarativeMappedTest):
+    """ORM loading fetches rows as plain processed tuples via
+    Result._raw_all_tuples(); test that result processors apply and
+    that engine-level row logging, which requires Row objects, still
+    loads correctly via its fallback."""
+
+    @classmethod
+    def setup_classes(cls):
+        Base = cls.DeclarativeBasic
+
+        class UpperString(TypeDecorator):
+            impl = String(50)
+            cache_ok = True
+
+            def process_result_value(self, value, dialect):
+                return value.upper() if value is not None else None
+
+        class A(Base):
+            __tablename__ = "interim_a"
+            id = Column(Integer, primary_key=True)
+            data = Column(UpperString())
+
+    @classmethod
+    def insert_data(cls, connection):
+        A = cls.classes.A
+        s = Session(connection)
+        s.add_all([A(id=1, data="one"), A(id=2, data=None)])
+        s.commit()
+
+    def _assert_load(self, connection):
+        A = self.classes.A
+        s = fixture_session(bind=connection)
+        a1, a2 = s.query(A).order_by(A.id).all()
+        eq_(a1.data, "ONE")
+        is_(a2.data, None)
+
+    def test_result_processors_applied(self, connection):
+        self._assert_load(connection)
+
+    def test_row_logging_fallback(self, debug_logging_engine):
+        """with debug-level logging, the ORM row fetch falls back to
+        constructing Row objects, and each row is logged"""
+
+        testing_engine, buf = debug_logging_engine
+
+        engine = testing_engine(echo="debug")
+
+        with engine.connect() as conn:
+            self._assert_load(conn)
+
+        row_messages = [
+            rec.getMessage()
+            for rec in buf.buffer
+            if rec.getMessage().startswith("Row ")
+        ]
+        eq_(
+            row_messages,
+            ["Row (1, 'ONE')", "Row (2, None)"],
+        )
index 1dc1792e803d3c97af718137ae454dbeed8bc5a1..d70d365f9638302f4017337435fe22abf2365b83 100644 (file)
@@ -124,6 +124,16 @@ class _CommonResult(Case):
 
             setattr(cls, name + "_iter", test_case(go_iter, number=number))
 
+            def go_raw_all_tuples(self):
+                result = self.impl(*init_args())
+                result._raw_all_tuples()
+
+            setattr(
+                cls,
+                name + "_raw_all_tuples",
+                test_case(go_raw_all_tuples, number=number),
+            )
+
             def go_iter_uq(self):
                 result = self.impl(*init_args()).unique()
                 for _ in result:
index a650f68d6ef2c8d853ae57dc652a04efd5427d42..9fcd432a06de988309def41a019084aa7d77ec5e 100644 (file)
@@ -311,16 +311,16 @@ test.aaa_profiling.test_orm.BranchedOptionTest.test_query_opts_unbound_branching
 # TEST: test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline
 
 test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 13312
-test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 25384
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 16387
 test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 13347
-test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 25386
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 16389
 
 # TEST: test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols
 
 test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 19455
-test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 25491
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 22494
 test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 19454
-test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 25493
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 22496
 
 # TEST: test.aaa_profiling.test_orm.JoinConditionTest.test_a_to_b_aliased
 
@@ -360,9 +360,9 @@ test.aaa_profiling.test_orm.JoinConditionTest.test_a_to_d_aliased x86_64_linux_c
 # TEST: test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated
 
 test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 25393,982,92557
-test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 26572,1222,116557
+test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 26572,1104,105747
 test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 25362,987,93057
-test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 26385,1223,116657
+test.aaa_profiling.test_orm.JoinedEagerLoadTest.test_fetch_results_integrated x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 26385,1102,105547
 
 # TEST: test.aaa_profiling.test_orm.LoadManyToOneFromIdentityTest.test_many_to_one_load_identity
 
@@ -402,9 +402,9 @@ test.aaa_profiling.test_orm.MergeTest.test_merge_no_load x86_64_linux_cpython_3.
 # TEST: test.aaa_profiling.test_orm.QueryTest.test_query_cols
 
 test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 6502
-test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 7562
+test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 7075
 test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 6512
-test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 7572
+test.aaa_profiling.test_orm.QueryTest.test_query_cols x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 7085
 
 # TEST: test.aaa_profiling.test_orm.SelectInEagerLoadTest.test_round_trip_results
 
index 99ca1e89bf9cda4b4e65a53fb8e1be5de4fdda85..8af1fcc53845c5a86e46a365ce190b57d559c2ad 100644 (file)
@@ -37,6 +37,7 @@ from sqlalchemy import VARCHAR
 from sqlalchemy.engine import cursor as _cursor
 from sqlalchemy.engine import default
 from sqlalchemy.engine import Row
+from sqlalchemy.engine.result import IteratorResult
 from sqlalchemy.engine.result import SimpleResultMetaData
 from sqlalchemy.ext.compiler import compiles
 from sqlalchemy.sql import ColumnElement
@@ -3934,3 +3935,115 @@ class GenerativeResultTest(fixtures.TablesTest):
             start += 20
 
         assert result._soft_closed
+
+
+class AllTuplesTest(fixtures.TablesTest):
+    """test Result._raw_all_tuples(), which ORM loading uses to fetch
+    processed rows without constructing Row objects."""
+
+    @classmethod
+    def define_tables(cls, metadata):
+        class UpperString(TypeDecorator):
+            impl = String(50)
+            cache_ok = True
+
+            def process_result_value(self, value, dialect):
+                return value.upper() if value is not None else None
+
+        Table(
+            "interim",
+            metadata,
+            Column("id", Integer, primary_key=True),
+            Column("plain", String(50)),
+            Column("upper", UpperString()),
+        )
+
+    @classmethod
+    def insert_data(cls, connection):
+        connection.execute(
+            cls.tables.interim.insert(),
+            [
+                {"id": 1, "plain": "p1", "upper": "u1"},
+                {"id": 2, "plain": "p2", "upper": None},
+            ],
+        )
+
+    def test_processors_applied(self, connection):
+        """rows are plain tuples with result processors applied"""
+        t = self.tables.interim
+        result = connection.execute(select(t).order_by(t.c.id))
+        rows = result._raw_all_tuples()
+        eq_(list(rows), [(1, "p1", "U1"), (2, "p2", None)])
+        for r in rows:
+            is_(type(r), tuple)
+
+    def test_no_processors(self, connection):
+        """rows with no result processors in play are plain tuples"""
+        t = self.tables.interim
+        result = connection.execute(select(t.c.id, t.c.plain).order_by(t.c.id))
+        rows = result._raw_all_tuples()
+        eq_(list(rows), [(1, "p1"), (2, "p2")])
+        for r in rows:
+            is_(type(r), tuple)
+
+    def test_empty_result(self, connection):
+        t = self.tables.interim
+        result = connection.execute(select(t).where(t.c.id == -1))
+        eq_(list(result._raw_all_tuples()), [])
+
+    def test_rejects_unique_filter(self, connection):
+        """_raw_all_tuples() does not apply uniquing, so asserts it's not
+        present"""
+        t = self.tables.interim
+        result = connection.execute(select(t).order_by(t.c.id))
+        with expect_raises(AssertionError):
+            result.unique()._raw_all_tuples()
+        result.close()
+
+    def test_rejects_post_creational_filter(self, connection):
+        """_raw_all_tuples() does not apply post-creational filters such as
+        the one used by mappings(), so asserts it's not present"""
+        t = self.tables.interim
+        result = connection.execute(select(t).order_by(t.c.id))
+        with expect_raises(AssertionError):
+            result.mappings()._raw_all_tuples()
+        result.close()
+
+    def test_rejects_scalar_source(self):
+        """_raw_all_tuples() asserts that interim_rows is not None, which
+        excludes results whose source delivers scalars without row
+        construction"""
+        result = IteratorResult(
+            SimpleResultMetaData(["a"]),
+            iter([(1,), (2,)]),
+            _source_supports_scalars=True,
+        )
+        result._generate_rows = False
+        with expect_raises(AssertionError):
+            result._raw_all_tuples()
+
+    def test_row_logging_falls_back_to_rows(self, debug_logging_engine):
+        """with debug-level engine logging established, Row objects are
+        built so that each row can be logged"""
+        t = self.tables.interim
+        testing_engine, buf = debug_logging_engine
+
+        engine = testing_engine(echo="debug")
+        with engine.connect() as conn:
+            result = conn.execute(select(t).order_by(t.c.id))
+            rows = result._raw_all_tuples()
+
+        eq_(len(rows), 2)
+        for r in rows:
+            is_true(isinstance(r, Row))
+        eq_(tuple(rows[0]), (1, "p1", "U1"))
+
+        row_messages = [
+            rec.getMessage()
+            for rec in buf.buffer
+            if rec.getMessage().startswith("Row ")
+        ]
+        eq_(
+            row_messages,
+            ["Row (1, 'p1', 'U1')", "Row (2, 'p2', None)"],
+        )