Skip to content

Commit ff9da4d

Browse files
committed
Support column-specific output converters
Closes #161
1 parent cfe0575 commit ff9da4d

8 files changed

Lines changed: 451 additions & 4 deletions

File tree

src/cursor.cpp

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,9 @@ static bool free_results(Cursor* self, int flags)
338338

339339
if (self->colinfos)
340340
{
341+
size_t n = (size_t)PyTuple_GET_SIZE(self->description);
342+
for (size_t i = 0; i < n; ++i)
343+
Py_XDECREF(self->colinfos[i].converter);
341344
PyMem_Free(self->colinfos);
342345
self->colinfos = 0;
343346
}
@@ -495,6 +498,7 @@ bool InitColumnInfo(Cursor* cursor, SQLUSMALLINT iCol, ColumnInfo* pinfo)
495498

496499
pinfo->sql_type = DataType;
497500
pinfo->column_size = ColumnSize;
501+
pinfo->converter = 0;
498502

499503
if (cursor->cnxn->hdbc == SQL_NULL_HANDLE)
500504
{
@@ -1134,7 +1138,7 @@ static PyObject* Cursor_setinputsizes(PyObject* self, PyObject* sizes)
11341138
PyErr_SetString(ProgrammingError, "Invalid cursor object.");
11351139
return 0;
11361140
}
1137-
1141+
11381142
Cursor *cur = (Cursor*)self;
11391143
if (Py_None == sizes)
11401144
{
@@ -2382,6 +2386,71 @@ static PyObject* Cursor_exit(PyObject* self, PyObject* args)
23822386
Py_RETURN_NONE;
23832387
}
23842388

2389+
static char setconv_doc[] =
2390+
"set_column_converter(column, converter) -> None\n\n"
2391+
"Register an output converter function or method that will be called\n"
2392+
"for the specified column of the results set created by the statement\n"
2393+
"most recently executed by the Cursor.\n\n"
2394+
"After executing a batch containing multiple statements, be sure to\n"
2395+
"use this function after calling nextset() if the results set for\n"
2396+
"which you need to register a converter is not the first statement\n"
2397+
"in the batch.\n\n"
2398+
"If no results set is active a RuntimeError exception will be raised.\n\n"
2399+
"column\n"
2400+
" The zero-based integer index of the column for which the converter\n"
2401+
" is to be registered. An IndexError exception will be raised if the\n"
2402+
" value is lower than zero or greater than or equal to the number of\n"
2403+
" columns in the results set.\n\n"
2404+
"converter\n"
2405+
" The converter function or method which will be called with a single\n"
2406+
" argument for the raw value retrieved from the database, and should\n"
2407+
" return the appropriately converted value. If the database value is\n"
2408+
" NULL, the argument given to the converter will be None. Otherwise\n"
2409+
" it will be a bytes object. If converter is None any existing con-\n"
2410+
" verter registered for the column is removed. If converter is not a\n"
2411+
" callable object a TypeError exception will be raised."
2412+
;
2413+
static PyObject* Cursor_setconv(PyObject* self, PyObject* args)
2414+
{
2415+
// Make sure we have a results set.
2416+
Cursor *cur = (Cursor*)self;
2417+
if (!PySequence_Check(cur->description))
2418+
{
2419+
PyErr_SetString(PyExc_RuntimeError, "no results set is active");
2420+
return 0;
2421+
}
2422+
2423+
// Extract the function arguments.
2424+
int pos;
2425+
PyObject* func;
2426+
if (!PyArg_ParseTuple(args, "iO", &pos, &func))
2427+
return 0;
2428+
2429+
// Check the converter's type.
2430+
if (!PyCallable_Check(func) && func != Py_None)
2431+
{
2432+
PyErr_SetString(PyExc_TypeError, "converter is not callable");
2433+
return 0;
2434+
}
2435+
2436+
// Make sure this converter has a home.
2437+
int count = (int)PyTuple_GET_SIZE(cur->description);
2438+
if (pos < 0 || pos >= count)
2439+
{
2440+
PyErr_Format(PyExc_IndexError, "index %d out of range", pos);
2441+
return 0;
2442+
}
2443+
2444+
// Free up any previous converter and install the new value.
2445+
Py_XDECREF(cur->colinfos[pos].converter);
2446+
if (func == Py_None)
2447+
cur->colinfos[pos].converter = NULL;
2448+
else
2449+
cur->colinfos[pos].converter = func;
2450+
Py_XINCREF(cur->colinfos[pos].converter);
2451+
2452+
Py_RETURN_NONE;
2453+
}
23852454

23862455
static PyMethodDef Cursor_methods[] =
23872456
{
@@ -2408,6 +2477,7 @@ static PyMethodDef Cursor_methods[] =
24082477
{ "skip", (PyCFunction)Cursor_skip, METH_VARARGS, skip_doc },
24092478
{ "commit", (PyCFunction)Cursor_commit, METH_NOARGS, commit_doc },
24102479
{ "rollback", (PyCFunction)Cursor_rollback, METH_NOARGS, rollback_doc },
2480+
{ "set_column_converter", (PyCFunction)Cursor_setconv, METH_VARARGS, setconv_doc },
24112481
{"cancel", (PyCFunction)Cursor_cancel, METH_NOARGS, cancel_doc},
24122482
{"__enter__", Cursor_enter, METH_NOARGS, enter_doc },
24132483
{"__exit__", Cursor_exit, METH_VARARGS, exit_doc },

src/cursor.h

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ struct ColumnInfo
3131
// of the integer types are the same size whether signed and unsigned, so we can allocate memory ahead of time
3232
// without knowing this. We use this during the fetch when converting to a Python integer or long.
3333
bool is_unsigned;
34+
35+
// If not NULL, this represents a user-supplied function registered to perform custom
36+
// transformation of the column's values.
37+
PyObject* converter;
3438
};
3539

3640
struct ParamInfo
@@ -117,10 +121,10 @@ struct Cursor
117121

118122
// Parameter set array (used with executemany)
119123
unsigned char *paramArray;
120-
124+
121125
// Whether to use fast executemany with parameter arrays and other optimisations
122126
char fastexecmany;
123-
127+
124128
// The list of information for setinputsizes().
125129
PyObject *inputsizes;
126130

src/getdata.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,9 @@ PyObject* GetData(Cursor* cur, Py_ssize_t iCol)
692692
ColumnInfo* pinfo = &cur->colinfos[iCol];
693693

694694
// First see if there is a user-defined conversion.
695-
695+
if (pinfo->converter) {
696+
return GetDataUser(cur, iCol, pinfo->converter);
697+
}
696698
if (cur->cnxn->map_sqltype_to_converter) {
697699
PyObject* func = Connection_GetConverter(cur->cnxn, pinfo->sql_type);
698700
if (func) {

src/pyodbc.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,15 @@ class Cursor:
646646
"""
647647
...
648648

649+
def set_column_converter(self, column: int, converter: Callable | None, /) -> None:
650+
"""Register an output converter for a column's value.
651+
652+
Args:
653+
column: The zero-based integer index of the column
654+
converter: function/method to be registered (None to remove)
655+
"""
656+
...
657+
649658
def fetchone(self) -> Row | None:
650659
"""Retrieve the next row in the current result set for the query.
651660

tests/mysql_test.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,96 @@ def test_emoticons_as_literal(cursor: pyodbc.Cursor):
455455
assert result == v
456456

457457

458+
def test_column_converters():
459+
"""Test the behavior of column-specific converters."""
460+
461+
# Create some converters.
462+
def ucconv(raw): return raw.decode().upper()
463+
class Converters:
464+
now = datetime.now()
465+
def __init__(self, name): self.name = name
466+
def add_name(self, raw): return f"{raw.decode()} {self.name}"
467+
@staticmethod
468+
def show_raw(raw): return raw
469+
@classmethod
470+
def dt(cls, raw): return cls.now if b"e" in raw else None
471+
object1 = Converters("Smith")
472+
object2 = Converters("Čermák")
473+
474+
# Create & populate a table with the test data.
475+
names = "Leoš", "Kathy", "Renée", "Abdul", "George"
476+
values = [[v] for v in names]
477+
conn = connect()
478+
cursor1 = conn.cursor()
479+
cursor2 = conn.cursor()
480+
cursor1.execute("drop table if exists t1")
481+
conn.commit()
482+
cursor1.execute("create table t1(v varchar(50))")
483+
cursor1.executemany("insert into t1 values (?)", values)
484+
485+
# Confirm that the documented exceptions get raised.
486+
with pytest.raises(RuntimeError):
487+
cursor1.set_column_converter(0, ucconv)
488+
cursor1.execute("select v, v, v, v, v from t1")
489+
cursor2.execute("select v, v, v, v, v from t1")
490+
with pytest.raises(IndexError):
491+
cursor1.set_column_converter(-1, ucconv)
492+
with pytest.raises(IndexError):
493+
cursor1.set_column_converter(5, ucconv)
494+
with pytest.raises(TypeError):
495+
cursor1.set_column_converter(0, "not callable")
496+
497+
# Register initial conversions.
498+
cursor1.set_column_converter(0, Converters.dt)
499+
cursor1.set_column_converter(1, ucconv)
500+
cursor1.set_column_converter(2, object1.add_name)
501+
cursor1.set_column_converter(3, Converters.show_raw)
502+
cursor2.set_column_converter(0, ucconv)
503+
cursor2.set_column_converter(1, Converters.dt)
504+
cursor2.set_column_converter(3, Converters.show_raw)
505+
cursor2.set_column_converter(4, object2.add_name)
506+
507+
# Create the validation tests.
508+
expected_values = {
509+
"cursor1": (
510+
(Converters.now, 'LEOŠ', 'Leoš Smith', 'Leoš'.encode(), 'Leoš'),
511+
('Kathy', 'KATHY', 'Kathy Smith', b'Kathy', 'Kathy'),
512+
('Renée', 'RENÉE', 'Renée Smith', 'Renée'.encode(), 'Renée'),
513+
('Abdul', None, 'Abdul Smith', b'Abdul', 'Abdul'),
514+
('George', 'George', 'George', 'George', 'George'),
515+
),
516+
"cursor2": (
517+
('LEOŠ', Converters.now, 'Leoš', 'Leoš'.encode(), 'Leoš Čermák'),
518+
('KATHY', None, 'Kathy', b'Kathy', 'Kathy Čermák'),
519+
('RENÉE', Converters.now, 'Renée'.encode(), 'Renée', 'Renée Čermák'),
520+
('ABDUL', 'ABDUL', b'Abdul', 'Abdul', 'Abdul Čermák'),
521+
('GEORGE', 'GEORGE', b'George', 'George', 'George Čermák'),
522+
),
523+
}
524+
def check_row(cursor_name, index, row):
525+
expected = expected_values[cursor_name][index]
526+
assert tuple(row) == expected
527+
528+
# Fetch a row at a time interleaving the cursors and modifying converter registrations as we go.
529+
check_row("cursor1", 0, cursor1.fetchone())
530+
check_row("cursor2", 0, cursor2.fetchone())
531+
cursor1.set_column_converter(0, None)
532+
check_row("cursor1", 1, cursor1.fetchone())
533+
check_row("cursor2", 1, cursor2.fetchone())
534+
cursor2.set_column_converter(2, Converters.show_raw)
535+
cursor2.set_column_converter(3, None)
536+
check_row("cursor1", 2, cursor1.fetchone())
537+
check_row("cursor2", 2, cursor2.fetchone())
538+
cursor1.set_column_converter(1, Converters.dt)
539+
cursor2.set_column_converter(1, ucconv)
540+
check_row("cursor1", 3, cursor1.fetchone())
541+
check_row("cursor2", 3, cursor2.fetchone())
542+
for i in range(len(cursor1.description)):
543+
cursor1.set_column_converter(i, None)
544+
check_row("cursor1", 4, cursor1.fetchone())
545+
check_row("cursor2", 4, cursor2.fetchone())
546+
547+
458548
@lru_cache
459549
def _generate_str(length, encoding=None):
460550
"""

tests/postgresql_test.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# -*- coding: utf-8 -*-
55

66
import os, uuid
7+
from datetime import datetime
78
from decimal import Decimal
89
from typing import Iterator
910

@@ -595,6 +596,96 @@ def convert(value):
595596
assert value == '123.45'
596597

597598

599+
def test_column_converters():
600+
"""Test the behavior of column-specific converters."""
601+
602+
# Create some converters.
603+
def ucconv(raw): return raw.decode().upper()
604+
class Converters:
605+
now = datetime.now()
606+
def __init__(self, name): self.name = name
607+
def add_name(self, raw): return f"{raw.decode()} {self.name}"
608+
@staticmethod
609+
def show_raw(raw): return raw
610+
@classmethod
611+
def dt(cls, raw): return cls.now if b"e" in raw else None
612+
object1 = Converters("Smith")
613+
object2 = Converters("Čermák")
614+
615+
# Create & populate a table with the test values.
616+
names = "Leoš", "Kathy", "Renée", "Abdul", "George"
617+
values = [[v] for v in names]
618+
conn = connect()
619+
cursor1 = conn.cursor()
620+
cursor2 = conn.cursor()
621+
cursor1.execute("drop table if exists t1")
622+
conn.commit()
623+
cursor1.execute("create table t1(v varchar(50))")
624+
cursor1.executemany("insert into t1 values (?)", values)
625+
626+
# Confirm that the documented exceptions get raised.
627+
with pytest.raises(RuntimeError):
628+
cursor1.set_column_converter(0, ucconv)
629+
cursor1.execute("select v, v, v, v, v from t1")
630+
cursor2.execute("select v, v, v, v, v from t1")
631+
with pytest.raises(IndexError):
632+
cursor1.set_column_converter(-1, ucconv)
633+
with pytest.raises(IndexError):
634+
cursor1.set_column_converter(5, ucconv)
635+
with pytest.raises(TypeError):
636+
cursor1.set_column_converter(0, "not callable")
637+
638+
# Register initial conversions.
639+
cursor1.set_column_converter(0, Converters.dt)
640+
cursor1.set_column_converter(1, ucconv)
641+
cursor1.set_column_converter(2, object1.add_name)
642+
cursor1.set_column_converter(3, Converters.show_raw)
643+
cursor2.set_column_converter(0, ucconv)
644+
cursor2.set_column_converter(1, Converters.dt)
645+
cursor2.set_column_converter(3, Converters.show_raw)
646+
cursor2.set_column_converter(4, object2.add_name)
647+
648+
# Create the validation tests.
649+
expected_values = {
650+
"cursor1": (
651+
(Converters.now, 'LEOŠ', 'Leoš Smith', 'Leoš'.encode(), 'Leoš'),
652+
('Kathy', 'KATHY', 'Kathy Smith', b'Kathy', 'Kathy'),
653+
('Renée', 'RENÉE', 'Renée Smith', 'Renée'.encode(), 'Renée'),
654+
('Abdul', None, 'Abdul Smith', b'Abdul', 'Abdul'),
655+
('George', 'George', 'George', 'George', 'George'),
656+
),
657+
"cursor2": (
658+
('LEOŠ', Converters.now, 'Leoš', 'Leoš'.encode(), 'Leoš Čermák'),
659+
('KATHY', None, 'Kathy', b'Kathy', 'Kathy Čermák'),
660+
('RENÉE', Converters.now, 'Renée'.encode(), 'Renée', 'Renée Čermák'),
661+
('ABDUL', 'ABDUL', b'Abdul', 'Abdul', 'Abdul Čermák'),
662+
('GEORGE', 'GEORGE', b'George', 'George', 'George Čermák'),
663+
),
664+
}
665+
def check_row(cursor_name, index, row):
666+
expected = expected_values[cursor_name][index]
667+
assert tuple(row) == expected
668+
669+
# Fetch a row at a time interleaving the cursors and modifying converter registrations as we go.
670+
check_row("cursor1", 0, cursor1.fetchone())
671+
check_row("cursor2", 0, cursor2.fetchone())
672+
cursor1.set_column_converter(0, None)
673+
check_row("cursor1", 1, cursor1.fetchone())
674+
check_row("cursor2", 1, cursor2.fetchone())
675+
cursor2.set_column_converter(2, Converters.show_raw)
676+
cursor2.set_column_converter(3, None)
677+
check_row("cursor1", 2, cursor1.fetchone())
678+
check_row("cursor2", 2, cursor2.fetchone())
679+
cursor1.set_column_converter(1, Converters.dt)
680+
cursor2.set_column_converter(1, ucconv)
681+
check_row("cursor1", 3, cursor1.fetchone())
682+
check_row("cursor2", 3, cursor2.fetchone())
683+
for i in range(len(cursor1.description)):
684+
cursor1.set_column_converter(i, None)
685+
check_row("cursor1", 4, cursor1.fetchone())
686+
check_row("cursor2", 4, cursor2.fetchone())
687+
688+
598689
def test_refcount_encoding():
599690
"""
600691
Ensure we handle the reference count to `encoding` properly. In the past we freed a

0 commit comments

Comments
 (0)