Skip to content

Commit 2741d4b

Browse files
bklinemkleehammer
andauthored
Support returning rows as dictionaries (#1487)
Closes #171 Co-authored-by: Michael Kleehammer <michael@kleehammer.com>
1 parent 2aec4a4 commit 2741d4b

6 files changed

Lines changed: 173 additions & 0 deletions

File tree

src/cursor.cpp

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,6 +1264,34 @@ static PyObject* Cursor_fetch(Cursor* cur)
12641264
apValues[i] = value;
12651265
}
12661266

1267+
// Return a dict instead of a Row if so requested.
1268+
// See https://github.com/mkleehammer/pyodbc/issues/171,
1269+
if (cur->rows_as_dicts)
1270+
{
1271+
PyObject* dict = PyDict_New();
1272+
if (!dict)
1273+
{
1274+
FreeRowValues(field_count, apValues);
1275+
return 0;
1276+
}
1277+
1278+
PyObject* name;
1279+
PyObject* index;
1280+
Py_ssize_t pos = 0;
1281+
while (PyDict_Next(cur->map_name_to_index, &pos, &name, &index))
1282+
{
1283+
Py_ssize_t i = PyNumber_AsSsize_t(index, PyExc_IndexError);
1284+
if (PyDict_SetItem(dict, name, apValues[i]) == -1)
1285+
{
1286+
Py_DECREF(dict);
1287+
FreeRowValues(field_count, apValues);
1288+
return 0;
1289+
}
1290+
}
1291+
FreeRowValues(field_count, apValues);
1292+
return dict;
1293+
}
1294+
12671295
return (PyObject*)Row_InternalNew(cur->description, cur->map_name_to_index, field_count, apValues);
12681296
}
12691297

@@ -2481,6 +2509,8 @@ static char messages_doc[] =
24812509
"This read-only attribute is a list of all the diagnostic messages in the\n" \
24822510
"current result set.";
24832511

2512+
static char rowsasdicts_doc[] = "If True, rows are returned as dicts instead of Row objects.";
2513+
24842514
static PyMemberDef Cursor_members[] =
24852515
{
24862516
{"rowcount", T_INT, offsetof(Cursor, rowcount), READONLY, rowcount_doc },
@@ -2489,6 +2519,7 @@ static PyMemberDef Cursor_members[] =
24892519
{"connection", T_OBJECT_EX, offsetof(Cursor, cnxn), READONLY, connection_doc },
24902520
{"fast_executemany",T_BOOL, offsetof(Cursor, fastexecmany), 0, fastexecmany_doc },
24912521
{"messages", T_OBJECT_EX, offsetof(Cursor, messages), READONLY, messages_doc },
2522+
{"rows_as_dicts", T_BOOL, offsetof(Cursor, rows_as_dicts), 0, rowsasdicts_doc },
24922523
{ 0 }
24932524
};
24942525

@@ -2792,6 +2823,7 @@ Cursor_New(Connection* cnxn)
27922823
cur->rowcount = -1;
27932824
cur->map_name_to_index = 0;
27942825
cur->fastexecmany = 0;
2826+
cur->rows_as_dicts = 0;
27952827
cur->messages = Py_None;
27962828

27972829
Py_INCREF(cnxn);

src/cursor.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ struct Cursor
135135
// Result Information
136136
//
137137

138+
// If true, return rows as dicts instead of Row objects.
139+
char rows_as_dicts;
140+
138141
// An array of ColumnInfos, allocated via malloc. This will be zero when closed or when there are no query
139142
// results.
140143
ColumnInfo* colinfos;

src/pyodbc.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,15 @@ class Cursor:
610610
def fast_executemany(self, value: bool) -> None:
611611
...
612612

613+
@property
614+
def rows_as_dicts(self) -> bool:
615+
"""If True, rows are returned as dicts instead of Row objects."""
616+
...
617+
618+
@rows_as_dicts.setter
619+
def rows_as_dicts(self, value: bool) -> None:
620+
...
621+
613622
@property
614623
def hstmt(self) -> ctypes.c_void_p | None:
615624
"""ODBC handle for the statement."""

tests/postgresql_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,49 @@ def _test():
645645
assert count_after == count_before
646646

647647

648+
def test_rows_as_dicts(cursor: pyodbc.Cursor):
649+
"""Test enhancement for ticket #171"""
650+
651+
# Create and populate a test table.
652+
cursor.execute("create table t1 (id int, name varchar(20))")
653+
cursor.execute("insert into t1 values (42, 'Kathleen')")
654+
655+
# Verify the default behavior
656+
assert cursor.rows_as_dicts is False
657+
row = cursor.execute("select * from t1").fetchone()
658+
assert not isinstance(row, dict)
659+
assert isinstance(row, pyodbc.Row)
660+
assert isinstance(row[0], int)
661+
assert isinstance(row[1], str)
662+
assert len(row) == 2
663+
with pytest.raises(TypeError, match="row indices must be integers"):
664+
print(row["name"])
665+
666+
# Test the dict option
667+
cursor.rows_as_dicts = True
668+
row = cursor.execute("select * from t1").fetchone()
669+
assert not isinstance(row, pyodbc.Row)
670+
assert isinstance(row, dict)
671+
assert row == {"id": 42, "name": "Kathleen"}
672+
assert isinstance(row["id"], int)
673+
assert isinstance(row["name"], str)
674+
assert len(row) == 2
675+
with pytest.raises(KeyError):
676+
print(row[1])
677+
678+
# Test aliasing
679+
row = cursor.execute("select name as n1, name as n2 from t1").fetchone()
680+
assert len(row) == 2
681+
assert row == {"n1": "Kathleen", "n2": "Kathleen"}
682+
with pytest.raises(KeyError):
683+
print(row["name"])
684+
685+
# Test with a duplicate name
686+
row = cursor.execute("select name, name from t1").fetchone()
687+
assert len(row) == 1
688+
assert row == {"name": "Kathleen"}
689+
690+
648691
def test_handles(cursor: pyodbc.Cursor):
649692
"""Test the exposed native ODBC handles"""
650693

tests/sqlite_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,3 +756,46 @@ def test_handles(cursor: pyodbc.Cursor):
756756
conn.close()
757757
assert not isinstance(conn.hdbc, ctypes.c_void_p)
758758
assert conn.hdbc is None
759+
760+
761+
def test_rows_as_dicts(cursor: pyodbc.Cursor):
762+
"""Test enhancement for ticket #171"""
763+
764+
# Create and populate a test table.
765+
cursor.execute("create table t1 (id int, name varchar(20))")
766+
cursor.execute("insert into t1 values (42, 'Kathleen')")
767+
768+
# Verify the default behavior
769+
assert cursor.rows_as_dicts is False
770+
row = cursor.execute("select * from t1").fetchone()
771+
assert not isinstance(row, dict)
772+
assert isinstance(row, pyodbc.Row)
773+
assert isinstance(row[0], int)
774+
assert isinstance(row[1], str)
775+
assert len(row) == 2
776+
with pytest.raises(TypeError, match="row indices must be integers"):
777+
print(row["name"])
778+
779+
# Test the dict option
780+
cursor.rows_as_dicts = True
781+
row = cursor.execute("select * from t1").fetchone()
782+
assert not isinstance(row, pyodbc.Row)
783+
assert isinstance(row, dict)
784+
assert row == {"id": 42, "name": "Kathleen"}
785+
assert isinstance(row["id"], int)
786+
assert isinstance(row["name"], str)
787+
assert len(row) == 2
788+
with pytest.raises(KeyError):
789+
print(row[1])
790+
791+
# Test aliasing
792+
row = cursor.execute("select name as n1, name as n2 from t1").fetchone()
793+
assert len(row) == 2
794+
assert row == {"n1": "Kathleen", "n2": "Kathleen"}
795+
with pytest.raises(KeyError):
796+
print(row["name"])
797+
798+
# Test with a duplicate name
799+
row = cursor.execute("select name, name from t1").fetchone()
800+
assert len(row) == 1
801+
assert row == {"name": "Kathleen"}

tests/sqlserver_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,49 @@ def test_sql_variant(cursor: pyodbc.Cursor):
18031803
assert results[index] == expected_value
18041804

18051805

1806+
def test_rows_as_dicts(cursor: pyodbc.Cursor):
1807+
"""Test enhancement for ticket #171"""
1808+
1809+
# Create and populate a test table.
1810+
cursor.execute("create table t1 (id int, name varchar(20))")
1811+
cursor.execute("insert into t1 values (42, 'Kathleen')")
1812+
1813+
# Verify the default behavior
1814+
assert cursor.rows_as_dicts is False
1815+
row = cursor.execute("select * from t1").fetchone()
1816+
assert not isinstance(row, dict)
1817+
assert isinstance(row, pyodbc.Row)
1818+
assert isinstance(row[0], int)
1819+
assert isinstance(row[1], str)
1820+
assert len(row) == 2
1821+
with pytest.raises(TypeError, match="row indices must be integers"):
1822+
print(row["name"])
1823+
1824+
# Test the dict option
1825+
cursor.rows_as_dicts = True
1826+
row = cursor.execute("select * from t1").fetchone()
1827+
assert not isinstance(row, pyodbc.Row)
1828+
assert isinstance(row, dict)
1829+
assert row == {"id": 42, "name": "Kathleen"}
1830+
assert isinstance(row["id"], int)
1831+
assert isinstance(row["name"], str)
1832+
assert len(row) == 2
1833+
with pytest.raises(KeyError):
1834+
print(row[1])
1835+
1836+
# Test aliasing
1837+
row = cursor.execute("select name as n1, name as n2 from t1").fetchone()
1838+
assert len(row) == 2
1839+
assert row == {"n1": "Kathleen", "n2": "Kathleen"}
1840+
with pytest.raises(KeyError):
1841+
print(row["name"])
1842+
1843+
# Test with a duplicate name
1844+
row = cursor.execute("select name, name from t1").fetchone()
1845+
assert len(row) == 1
1846+
assert row == {"name": "Kathleen"}
1847+
1848+
18061849
def test_handles(cursor: pyodbc.Cursor):
18071850
"""Test the exposed native ODBC handles"""
18081851

0 commit comments

Comments
 (0)