]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
perf tuning of `BaseRow`
authorAlbert N <anamaev263@gmail.com>
Sat, 25 Oct 2025 17:45:57 +0000 (13:45 -0400)
committerFederico Caselli <cfederico87@gmail.com>
Thu, 13 Nov 2025 22:59:59 +0000 (23:59 +0100)
common cases are a bit faster overall

Python version
|                      | python opt | python main | py opt / main |
| -------------------- | ---------- | ----------- | ------------- |
| base_row_new         | 0.84959    | 0.86417     | 0.9831283197  |
| row_new              | 0.85698    | 0.85543     | 1.001811954   |
| base_row_new_proc    | 2.77489    | 2.81795     | 0.9847193882  |
| row_new_proc         | 2.77881    | 2.86206     | 0.9709125595  |
| brow_new_proc_none   | 1.6311     | 1.65773     | 0.9839358641  |
| row_new_proc_none    | 1.65048    | 1.69896     | 0.9714648962  |
| row_dumps            | 0.15279    | 0.14519     | 1.052345203   |
| row_loads            | 0.91471    | 0.91358     | 1.001236892   |
| row_values_impl      | 0.1818     | 0.17773     | 1.022899904   |
| row_iter             | 0.44042    | 0.4554      | 0.967105841   |
| row_len              | 0.13858    | 0.13079     | 1.059561129   |
| row_hash             | 0.28614    | 0.29172     | 0.9808720691  |
| getitem              | 0.23159    | 0.23138     | 1.000907598   |
| getitem_slice        | 0.38393    | 0.38765     | 0.9904037147  |
| get_by_key           | 0.33692    | 0.33913     | 0.993483325   |
| get_by_key2          | 0.35105    | 0.33915     | 1.035087719   |
| getattr              | 0.46809    | 0.46911     | 0.9978256699  |
| get_by_key_recreate  | 1.08137    | 1.18224     | 0.9146789146  |
| get_by_key_recreate2 | 0.98543    | 0.99272     | 0.9926565396  |
| getattr_recreate     | 0.74       | 0.7407      | 0.999054948   |
| contains             | 0.49312    | 0.74477     | 0.6621104502  |

Cython version
|                      | cython opt | cython main | cy opt / main |
| -------------------- | ---------- | ----------- | ------------- |
| base_row_new         | 0.12933    | 0.1347      | 0.9601336303  |
| row_new              | 0.15755    | 0.16489     | 0.9554854752  |
| base_row_new_proc    | 0.87398    | 1.08483     | 0.8056377497  |
| row_new_proc         | 0.88592    | 1.13255     | 0.7822347799  |
| brow_new_proc_none   | 0.2664     | 0.41703     | 0.6388029638  |
| row_new_proc_none    | 0.27526    | 0.42483     | 0.6479297601  |
| row_dumps            | 0.37526    | 0.20093     | 1.867615588   |
| row_loads            | 0.53037    | 0.3607      | 1.470390907   |
| row_values_impl      | 0.36886    | 0.22218     | 1.660185435   |
| row_iter             | 0.25249    | 0.25553     | 0.9881031581  |
| row_len              | 0.05194    | 0.05412     | 0.9597191426  |
| row_hash             | 0.18864    | 0.18726     | 1.007369433   |
| getitem              | 0.13876    | 0.13934     | 0.9958375197  |
| getitem_slice        | 0.23181    | 0.2318      | 1.000043141   |
| get_by_key           | 0.17031    | 0.17857     | 0.9537436299  |
| get_by_key2          | 0.58172    | 0.31651     | 1.837919813   |
| getattr              | 0.21275    | 1.16669     | 0.1823534958  |
| get_by_key_recreate  | 0.80161    | 0.8836      | 0.9072091444  |
| get_by_key_recreate2 | 1.14649    | 0.98709     | 1.161484768   |
| getattr_recreate     | 0.55927    | 1.09921     | 0.5087926784  |
| contains             | 0.35338    | 0.5494      | 0.6432107754  |

Closes: #12919
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/12919
Pull-request-sha: b13204b7416083ccff09a45291b11fd606ff0fbd

Change-Id: I5ff2b951f9840e99abdad3ae286ecb8becc70e4b

lib/sqlalchemy/engine/_row_cy.py
lib/sqlalchemy/engine/row.py
lib/sqlalchemy/util/cython.py
setup.py
test/aaa_profiling/test_resultset.py
test/perf/compiled_extensions/row.py
test/profiles.txt
test/sql/test_resultset.py

index 87cf5bfa39ce94a139f2478c4d6076a5939b111b..29d8f6548c4afcf48223c8a37e27b9a72bdbb7d3 100644 (file)
@@ -4,13 +4,15 @@
 #
 # This module is part of SQLAlchemy and is released under
 # the MIT License: https://www.opensource.org/licenses/mit-license.php
-# mypy: disable-error-code="misc"
+# mypy: disable-error-code="misc,no-redef,valid-type,no-untyped-call"
+# mypy: disable-error-code="index,no-any-return,arg-type,assignment"
 from __future__ import annotations
 
 from typing import Any
 from typing import Dict
 from typing import Iterator
 from typing import List
+from typing import NoReturn
 from typing import Optional
 from typing import Sequence
 from typing import Tuple
@@ -60,13 +62,15 @@ class BaseRow:
         data: Sequence[Any],
     ) -> None:
         """Row objects are constructed by CursorResult objects."""
-
-        data_tuple: Tuple[Any, ...] = (
-            _apply_processors(processors, data)
-            if processors is not None
-            else tuple(data)
+        self._set_attrs(
+            parent,
+            key_to_index,
+            (
+                _apply_processors(processors, data)
+                if processors is not None
+                else data if isinstance(data, tuple) else tuple(data)
+            ),
         )
-        self._set_attrs(parent, key_to_index, data_tuple)
 
     @cython.cfunc
     @cython.inline
@@ -121,34 +125,97 @@ class BaseRow:
         return self._get_by_key_impl(key, False)
 
     @cython.cfunc
+    @cython.inline
     def _get_by_key_impl(self, key: _KeyType, attr_err: cython.bint) -> object:
-        index: Optional[int] = self._key_to_index.get(key)
+        # NOTE: don't type index since there is no advantage in making cython
+        # do a type check
+        index = self._key_to_index.get(key)
         if index is not None:
             return self._data[index]
         self._parent._key_not_found(key, attr_err)
 
+    if cython.compiled:
+
+        @cython.annotation_typing(False)
+        def __getattribute__(self, name: str) -> Any:
+            # this optimizes getattr access on cython, that's otherwise
+            # quite slow compared with python. The assumption is that
+            # most columns will not start with _. If they do they will
+            # fallback on __getattr__ in any case.
+            if name != "" and name[0] != "_":
+                # inline of _get_by_key_impl. Attribute on the class
+                # take precedence over column names.
+                index = self._key_to_index.get(name)
+                if index is not None and not hasattr(type(self), name):
+                    return self._data[index]
+
+            return object.__getattribute__(self, name)
+
     @cython.annotation_typing(False)
     def __getattr__(self, name: str) -> Any:
         return self._get_by_key_impl(name, True)
 
+    def __setattr__(self, name: str, value: Any) -> NoReturn:
+        raise AttributeError("can't set attribute")
+
+    def __delattr__(self, name: str) -> NoReturn:
+        raise AttributeError("can't delete attribute")
+
     def _to_tuple_instance(self) -> Tuple[Any, ...]:
         return self._data
 
+    def __contains__(self, key: Any) -> cython.bint:
+        return key in self._data
+
+
+if cython.compiled:
 
-@cython.inline
-@cython.cfunc
-def _apply_processors(
-    proc: _ProcessorsType, data: Sequence[Any]
-) -> Tuple[Any, ...]:
-    res: List[Any] = list(data)
-    proc_size: cython.Py_ssize_t = len(proc)
-    # TODO: would be nice to do this only on the fist row
-    assert len(res) == proc_size
-    for i in range(proc_size):
-        p = proc[i]
-        if p is not None:
-            res[i] = p(res[i])
-    return tuple(res)
+    from cython.cimports.cpython import PyTuple_New
+    from cython.cimports.cpython import Py_INCREF
+    from cython.cimports.cpython import PyTuple_SET_ITEM
+
+    @cython.inline
+    @cython.cfunc
+    @cython.wraparound(False)
+    @cython.boundscheck(False)
+    @cython.locals(
+        res=tuple,
+        proc_size=cython.Py_ssize_t,
+        i=cython.Py_ssize_t,
+        p=object,
+        value=object,
+    )
+    def _apply_processors(
+        proc: Sequence[Any], data: Sequence[Any]
+    ) -> Tuple[Any, ...]:
+        proc_size = len(proc)
+        # TODO: would be nice to do this only on the fist row
+        assert len(data) == proc_size
+        res = PyTuple_New(proc_size)
+        for i in range(proc_size):
+            p = proc[i]
+            if p is not None:
+                value = p(data[i])
+            else:
+                value = data[i]
+            Py_INCREF(value)
+            PyTuple_SET_ITEM(res, i, value)
+        return res
+
+else:
+
+    def _apply_processors(
+        proc: _ProcessorsType, data: Sequence[Any]
+    ) -> Tuple[Any, ...]:
+        res: List[Any] = list(data)
+        proc_size = len(proc)
+        # TODO: would be nice to do this only on the fist row
+        assert len(res) == proc_size
+        for i in range(proc_size):
+            p = proc[i]
+            if p is not None:
+                res[i] = p(res[i])
+        return tuple(res)
 
 
 # This reconstructor is necessary so that pickles with the Cy extension or
index 6c5db5b49d86fbdd41b78479422a661e0d2e5c7b..0744569f831cdc0a6f49468fc52730a00778ef3d 100644 (file)
@@ -18,9 +18,7 @@ from typing import Callable
 from typing import Dict
 from typing import Generic
 from typing import Iterator
-from typing import List
 from typing import Mapping
-from typing import NoReturn
 from typing import Optional
 from typing import Sequence
 from typing import Tuple
@@ -75,12 +73,6 @@ class Row(BaseRow, _RowBase[Unpack[_Ts]], Generic[Unpack[_Ts]]):
 
     __slots__ = ()
 
-    def __setattr__(self, name: str, value: Any) -> NoReturn:
-        raise AttributeError("can't set attribute")
-
-    def __delattr__(self, name: str) -> NoReturn:
-        raise AttributeError("can't delete attribute")
-
     @deprecated(
         "2.1.0",
         "The :meth:`.Row._tuple` method is deprecated, :class:`.Row` "
@@ -222,9 +214,6 @@ class Row(BaseRow, _RowBase[Unpack[_Ts]], Generic[Unpack[_Ts]]):
         count = _special_name_accessor("count")
         index = _special_name_accessor("index")
 
-    def __contains__(self, key: Any) -> bool:
-        return key in self._data
-
     def _op(self, other: Any, op: Callable[[Any, Any], bool]) -> bool:
         return (
             op(self._to_tuple_instance(), other._to_tuple_instance())
@@ -374,15 +363,9 @@ class RowMapping(BaseRow, typing.Mapping["_KeyType", Any]):
     else:
         __getitem__ = BaseRow._get_by_key_impl_mapping
 
-    def _values_impl(self) -> List[Any]:
-        return list(self._data)
-
     def __iter__(self) -> Iterator[str]:
         return (k for k in self._parent.keys if k is not None)
 
-    def __len__(self) -> int:
-        return len(self._data)
-
     def __contains__(self, key: object) -> bool:
         return self._parent._has_key(key)
 
index 0d796313d84fa1a12828d3e4afa32f80f7eec993..288e497650a96dbc7210ef3cac766421f946ec9c 100644 (file)
@@ -30,6 +30,7 @@ uint = int
 float = float  # noqa: A001
 double = float
 void = Any
+NULL = None
 
 
 # functions
@@ -59,3 +60,19 @@ def exceptval(value: Any = None, *, check: bool = False) -> _NO_OP[_T]:
 
 def cast(type_: Type[_T], value: Any, *, typecheck: bool = False) -> _T:
     return value  # type: ignore[no-any-return]
+
+
+def returns(_: type) -> _NO_OP[_T]:
+    return _no_op
+
+
+def locals(**kwargs: Any) -> _NO_OP[_T]:  # noqa: A001
+    return _no_op
+
+
+def wraparound(value: bool) -> _NO_OP[_T]:
+    return _no_op
+
+
+def boundscheck(value: bool) -> _NO_OP[_T]:
+    return _no_op
index 4f73c4e58be8d2eb7820c6321b0ee71beba89f96..2adbae3222f0f76cdcac93d6452d6ec84b849020 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -46,9 +46,14 @@ CYTHON_MODULES = (
 if HAS_CYTHON and IS_CPYTHON and not DISABLE_EXTENSION:
     assert _cy_Extension is not None
     assert _cy_build_ext is not None
+    from Cython.Compiler import Options
+
+    Options.docstrings = False
+    Options.clear_to_none = False
 
     cython_directives: Dict[str, Any] = {
         "language_level": "3",
+        # "initializedcheck": False,
     }
 
     if sys.version_info >= (3, 13):
index 049e4a45e50064f352a21f57eab7322132e11103..5c6ef2b1dddc2e57fc03eaf0f2a1de198a44b40f 100644 (file)
@@ -266,4 +266,7 @@ class RowTest(fixtures.TestBase):
             def __iter__(self):
                 return iter(self.data)
 
+            def __len__(self):
+                return len(self.data)
+
         self._test_getitem_value_refcounts_new(CustomSeq)
index 227bc8915bc105c4d23bc46baa0f9022ddb7b49a..f739b6e6c2fa69fd6bd0c3ba7805957d334b655e 100644 (file)
@@ -111,8 +111,12 @@ class BaseRow(Case):
         class Row(self.impl):
             pass
 
+        class RowMap(self.impl):
+            __getitem__ = self.impl._get_by_key_impl_mapping
+
         self.Row = Row
-        self.row_sub = Row(*self.row_args)
+        self.row_map = RowMap(*self.row_args)
+        self.row_long_map = RowMap(*self.row_long_args)
 
         self.row_state = self.row.__getstate__()
         self.row_long_state = self.row_long.__getstate__()
@@ -223,6 +227,14 @@ class BaseRow(Case):
 
     @test_case
     def get_by_key(self):
+        self.row_map["a"]
+        self.row_map["b"]
+        self.row_long_map["s"]
+        self.row_long_map["a"]
+
+    @test_case
+    def get_by_key2(self):
+        # NOTE: this is not representative of real usage
         self.row._get_by_key_impl_mapping("a")
         self.row._get_by_key_impl_mapping("b")
         self.row_long._get_by_key_impl_mapping("s")
@@ -235,8 +247,26 @@ class BaseRow(Case):
         self.row_long.x
         self.row_long.y
 
-    @test_case(number=25_000)
+    @test_case(number=15_000)
     def get_by_key_recreate(self):
+        self.init_objects()
+        row = self.row_map
+        for _ in range(25):
+            row["a"]
+        l_row = self.row_long_map
+        for _ in range(25):
+            l_row["f"]
+            l_row["o"]
+            l_row["r"]
+            l_row["t"]
+            l_row["y"]
+            l_row["t"]
+            l_row["w"]
+            l_row["o"]
+
+    @test_case(number=15_000)
+    def get_by_key_recreate2(self):
+        # NOTE: this is not representative of real usage
         self.init_objects()
         row = self.row
         for _ in range(25):
@@ -268,3 +298,10 @@ class BaseRow(Case):
             l_row.t
             l_row.w
             l_row.o
+
+    @test_case
+    def contains(self):
+        1 in self.row
+        -1 in self.row
+        1 in self.row_long
+        -1 in self.row_long
index 50da808a172c03d6721ce04255fbd71a6eb1c9f1..a40db06e4efb6351ada9a37fd8731abbc244a368 100644 (file)
@@ -528,7 +528,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_6
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_cextensions 1632
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_nocextensions 35643
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 1598
-test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 35609
+test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 37599
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_cextensions 1648
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_nocextensions 35650
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_mssql_pyodbc_dbapiunicode_cextensions 1652
@@ -538,7 +538,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_6
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 1632
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 35634
 test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 1598
-test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 35609
+test.aaa_profiling.test_resultset.ResultSetTest.test_fetch_by_key_mappings x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 37598
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-0]
 
@@ -574,7 +574,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_cextensions 14
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_nocextensions 16
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 14
-test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 16
+test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 17
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_cextensions 18
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_nocextensions 20
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_mssql_pyodbc_dbapiunicode_cextensions 14
@@ -584,7 +584,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 14
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 16
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 14
-test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 16
+test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-1] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 17
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2]
 
@@ -597,7 +597,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_cextensions 14
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.13_postgresql_psycopg2_dbapiunicode_nocextensions 16
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_cextensions 14
-test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 16
+test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.13_sqlite_pysqlite_dbapiunicode_nocextensions 17
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_cextensions 18
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_mariadb_mysqldb_dbapiunicode_nocextensions 20
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_mssql_pyodbc_dbapiunicode_cextensions 14
@@ -607,7 +607,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 14
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 16
 test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 14
-test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 16
+test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[False-2] x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 17
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_one_or_none[True-1]
 
@@ -653,7 +653,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpy
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 299
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 4301
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 271
-test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 4273
+test.aaa_profiling.test_resultset.ResultSetTest.test_raw_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 5264
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode
 
@@ -676,7 +676,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cp
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 299
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 4301
 test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 271
-test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 4273
+test.aaa_profiling.test_resultset.ResultSetTest.test_raw_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 5264
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_string
 
@@ -699,7 +699,7 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython
 test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 623
 test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 4625
 test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 589
-test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 4600
+test.aaa_profiling.test_resultset.ResultSetTest.test_string x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 5591
 
 # TEST: test.aaa_profiling.test_resultset.ResultSetTest.test_unicode
 
@@ -722,4 +722,4 @@ test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpytho
 test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_cextensions 623
 test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpython_3.14_postgresql_psycopg2_dbapiunicode_nocextensions 4625
 test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_cextensions 589
-test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 4600
+test.aaa_profiling.test_resultset.ResultSetTest.test_unicode x86_64_linux_cpython_3.14_sqlite_pysqlite_dbapiunicode_nocextensions 5591
index ea9ce57a1f97f4c0d4f2fbce9ea799c7739aff28..1acf10c615476f4e1f44f6f392580b3261dddcd9 100644 (file)
@@ -1905,6 +1905,7 @@ class CursorResultTest(fixtures.TablesTest):
         eq_(row.key, "kv")
         eq_(row.count, "cv")
         eq_(row.index, "iv")
+        eq_(row.foo, "f")
 
         eq_(row._mapping["foo"], "f")
         eq_(row._mapping["count"], "cv")
@@ -1927,6 +1928,27 @@ class CursorResultTest(fixtures.TablesTest):
         eq_(row.count("cv"), 1)
         eq_(row.count("x"), 0)
 
+    def test_row_precedence_normal_names(self):
+        f = ("_fields", "_asdict", "_mapping", "as_tuple")
+        v = ["ff", "ad", "mm", "at"]
+        metadata = SimpleResultMetaData(f)
+
+        class SubRow(Row):
+            # use subclass to ensure there is always a public method
+            @property
+            def as_tuple(self):
+                return tuple(self)
+
+        row = SubRow(metadata, None, metadata._key_to_index, v)
+
+        eq_(row._fields, f)
+        eq_(row._asdict(), dict(zip(f, v)))
+        eq_(row._mapping, dict(zip(f, v)))
+        eq_(row.as_tuple, tuple(v))
+
+        with expect_raises(AttributeError):
+            getattr(row, "")  # test cython getattr edge case
+
     def test_new_row_no_dict_behaviors(self):
         """This mode is not used currently but will be once we are in 2.0."""
         metadata = SimpleResultMetaData(["a", "b", "count"])