Skip to content

Commit a68fbae

Browse files
committed
[CONPY-386] named_tuple: rename a column the row type cannot carry instead of refusing the result set
A member name is kept when collections.namedtuple accepts it: a valid identifier, not a keyword, no leading underscore, not already used. Any other name: a duplicate, "COUNT(*)", "def", "_foo", one that is not valid UTF-8 - becomes column_<index>, with _1, _2, ... appended should that name itself be taken. (One rule for the C and the pure-Python implementation) cursor.description keeps the names as the server sent them.
1 parent ea6db18 commit a68fbae

7 files changed

Lines changed: 448 additions & 156 deletions

File tree

include/mariadb_python.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,8 @@ extern PyObject *Mariadb_NotSupportedError;
387387
extern PyObject *Mariadb_Warning;
388388

389389
extern PyObject *decimal_module,
390-
*decimal_type;
390+
*decimal_type,
391+
*keyword_iskeyword;
391392

392393
/* Object types */
393394
extern PyTypeObject MrdbPool_Type;

mariadb/mariadb.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ extern int cursor_datetime_init(void);
4646

4747
PyObject *decimal_module= NULL,
4848
*decimal_type= NULL,
49+
*keyword_iskeyword= NULL,
4950
*socket_module= NULL,
5051
*indicator_module= NULL;
5152
extern uint16_t max_pool_size;
@@ -151,6 +152,21 @@ PyMODINIT_FUNC PyInit__mariadb(void)
151152
goto error;
152153
}
153154

155+
{
156+
PyObject *keyword_module;
157+
158+
if (!(keyword_module= PyImport_ImportModule("keyword")))
159+
{
160+
goto error;
161+
}
162+
keyword_iskeyword= PyObject_GetAttrString(keyword_module, "iskeyword");
163+
Py_DECREF(keyword_module);
164+
if (!keyword_iskeyword)
165+
{
166+
goto error;
167+
}
168+
}
169+
154170
Py_SET_TYPE(&MrdbCursor_Type, &PyType_Type);
155171
if (PyType_Ready(&MrdbCursor_Type) == -1)
156172
{

mariadb/mariadb_cursor.c

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,94 @@ static void MrdbCursor_finalize(MrdbCursor *self)
579579
}
580580
/* }}} */
581581

582+
static PyObject *ma_named_tuple_names(MrdbCursor *self)
583+
{
584+
PyObject *names, *seen= NULL;
585+
unsigned int i;
586+
587+
if (!(names= PyList_New(self->field_count)) ||
588+
!(seen= PySet_New(NULL)))
589+
{
590+
goto error;
591+
}
592+
593+
for (i=0; i < self->field_count; i++)
594+
{
595+
PyObject *name, *res;
596+
int keep;
597+
598+
if (!(name= PyUnicode_DecodeUTF8(self->fields[i].name,
599+
self->fields[i].name_length, NULL)))
600+
{
601+
if (!PyErr_ExceptionMatches(PyExc_UnicodeDecodeError))
602+
{
603+
goto error;
604+
}
605+
PyErr_Clear();
606+
keep= 0;
607+
}
608+
else if ((keep= PyUnicode_IsIdentifier(name)) > 0)
609+
{
610+
keep= PyUnicode_AsUTF8(name)[0] != '_';
611+
}
612+
if (keep > 0)
613+
{
614+
if (!(res= PyObject_CallFunctionObjArgs(keyword_iskeyword, name,
615+
NULL)))
616+
{
617+
Py_DECREF(name);
618+
goto error;
619+
}
620+
keep= PyObject_Not(res);
621+
Py_DECREF(res);
622+
}
623+
if (keep > 0)
624+
{
625+
/* already taken by an earlier column? */
626+
if ((keep= PySet_Contains(seen, name)) >= 0)
627+
{
628+
keep= !keep;
629+
}
630+
}
631+
if (keep < 0)
632+
{
633+
Py_XDECREF(name);
634+
goto error;
635+
}
636+
if (!keep)
637+
{
638+
unsigned int counter= 0;
639+
int taken;
640+
641+
Py_XDECREF(name);
642+
name= PyUnicode_FromFormat("column_%u", i);
643+
while (name && (taken= PySet_Contains(seen, name)) > 0)
644+
{
645+
Py_DECREF(name);
646+
name= PyUnicode_FromFormat("column_%u_%u", i, ++counter);
647+
}
648+
if (!name || taken < 0)
649+
{
650+
Py_XDECREF(name);
651+
goto error;
652+
}
653+
}
654+
if (PySet_Add(seen, name) < 0)
655+
{
656+
Py_DECREF(name);
657+
goto error;
658+
}
659+
PyList_SET_ITEM(names, i, name);
660+
}
661+
Py_DECREF(seen);
662+
return names;
663+
664+
error:
665+
Py_XDECREF(seen);
666+
Py_XDECREF(names);
667+
return NULL;
668+
}
669+
582670
static int Mrdb_GetFieldInfo(MrdbCursor *self)
583671
{
584672
self->row_number= 0;
@@ -613,61 +701,41 @@ static int Mrdb_GetFieldInfo(MrdbCursor *self)
613701
unsigned int i;
614702
PyStructSequence_Desc sequence_desc;
615703
PyObject *field_names;
616-
PyObject *seen;
704+
PyObject *names;
617705
char *p;
618706
size_t names_size= 0;
619707

620-
/* All column names are known now: a struct sequence can't carry
621-
the same member twice, the second one would silently shadow
622-
the first. */
623-
if (!(seen= PySet_New(NULL)))
708+
/* All column names are known now; the member names are derived
709+
from them (see ma_named_tuple_names) and copied into one
710+
buffer owned by the row type. */
711+
if (!(names= ma_named_tuple_names(self)))
624712
{
625713
return 1;
626714
}
627715
for (i=0; i < self->field_count; i++)
628716
{
629-
PyObject *name;
630-
int found;
717+
Py_ssize_t len;
631718

632-
if (!(name= PyUnicode_FromString(self->fields[i].name)))
633-
{
634-
Py_DECREF(seen);
635-
return 1;
636-
}
637-
found= PySet_Contains(seen, name);
638-
if (found == 0)
639-
{
640-
found= PySet_Add(seen, name) < 0 ? -1 : 0;
641-
}
642-
Py_DECREF(name);
643-
if (found)
719+
if (!PyUnicode_AsUTF8AndSize(PyList_GET_ITEM(names, i), &len))
644720
{
645-
Py_DECREF(seen);
646-
if (found > 0)
647-
{
648-
mariadb_throw_exception(NULL, Mariadb_ProgrammingError,
649-
0, "Duplicate column name '%s' in result set: a "
650-
"named_tuple cursor requires unique column names",
651-
self->fields[i].name);
652-
}
721+
Py_DECREF(names);
653722
return 1;
654723
}
655-
}
656-
Py_DECREF(seen);
657-
658-
for (i=0; i < self->field_count; i++)
659-
{
660-
names_size+= strlen(self->fields[i].name) + 1;
724+
names_size+= (size_t)len + 1;
661725
}
662726

663727
if (!(field_names= PyBytes_FromStringAndSize(NULL,
664728
(Py_ssize_t)names_size)))
729+
{
730+
Py_DECREF(names);
665731
return 1;
732+
}
666733

667734
if (!(self->sequence_fields= (PyStructSequence_Field *)
668735
PyMem_RawCalloc(self->field_count + 1,
669736
sizeof(PyStructSequence_Field))))
670737
{
738+
Py_DECREF(names);
671739
Py_DECREF(field_names);
672740
return 1;
673741
}
@@ -680,12 +748,15 @@ static int Mrdb_GetFieldInfo(MrdbCursor *self)
680748
p= PyBytes_AS_STRING(field_names);
681749
for (i=0; i < self->field_count; i++)
682750
{
683-
size_t len= strlen(self->fields[i].name) + 1;
751+
Py_ssize_t len;
752+
const char *name= PyUnicode_AsUTF8AndSize(
753+
PyList_GET_ITEM(names, i), &len);
684754

685-
memcpy(p, self->fields[i].name, len);
755+
memcpy(p, name, (size_t)len + 1);
686756
self->sequence_fields[i].name= p;
687-
p+= len;
757+
p+= len + 1;
688758
}
759+
Py_DECREF(names);
689760
self->sequence_type= PyStructSequence_NewType(&sequence_desc);
690761
if (!self->sequence_type)
691762
{

testing/test/integration/test_cursor_invalid_field_name.py

Lines changed: 33 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
#!/usr/bin/env python -O
22
# -*- coding: utf-8 -*-
33

4-
"""Regression tests for CONPY-380"""
4+
"""Regression tests for CONPY-380: a column name that is not valid UTF-8.
5+
6+
Building the row type of a named_tuple cursor used to fail on such a name and,
7+
when the error was caught, a fetch walked a result set whose row buffer was
8+
never allocated and segfaulted. The name is now treated like any other name a
9+
named tuple cannot carry: the member is called after its position,
10+
"column_<index>",
11+
and the statement succeeds. The undecodable name is only reported when it is
12+
actually needed as text, i.e. from cursor.description.
13+
"""
514

615
import struct
716
import unittest
@@ -21,7 +30,8 @@
2130

2231
# query -> (columns, rows)
2332
_RESULTS = {
24-
BAD_QUERY: ([(BAD_NAME, MYSQL_TYPE_LONG)], [(1,)]),
33+
BAD_QUERY: ([(BAD_NAME, MYSQL_TYPE_LONG), ("fine", MYSQL_TYPE_LONG)],
34+
[(1, 2)]),
2535
GOOD_QUERY: ([("answer", MYSQL_TYPE_LONG),
2636
("status", MYSQL_TYPE_VAR_STRING)],
2737
[(42, "ok")]),
@@ -67,51 +77,33 @@ def tearDown(self):
6777
self.server.__exit__(None, None, None)
6878
self.assertIsNone(self.server.error)
6979

70-
def _undecodable_column(self, cursor: mariadb.Cursor) -> None:
71-
with self.assertRaises(Exception) as ctx:
72-
cursor.execute(BAD_QUERY)
73-
# a real error, not "returned a result with an exception set"
74-
self.assertNotIsInstance(ctx.exception, SystemError)
75-
76-
def test_fetch_after_failure_reports_no_result_set(self):
77-
"""Catching the error and fetching anyway used to segfault."""
80+
def test_undecodable_name_is_renamed(self):
81+
for binary in (False, True):
82+
with self.subTest(binary=binary):
83+
cursor = self.connection.cursor(named_tuple=True,
84+
binary=binary)
85+
cursor.execute(BAD_QUERY)
86+
row = cursor.fetchone()
87+
self.assertEqual((row.column_0, row.fine), (1, 2))
88+
self.assertIn("column_0=1", repr(row))
89+
cursor.close()
90+
91+
def test_description_reports_the_undecodable_name(self):
92+
"""The name is only decoded when it is needed as text."""
7893
cursor = self.connection.cursor(named_tuple=True)
79-
try:
80-
cursor.execute(BAD_QUERY)
81-
except Exception:
82-
pass
83-
84-
with self.assertRaises(Exception) as ctx:
85-
cursor.fetchall()
86-
self.assertIn("result set", str(ctx.exception))
94+
cursor.execute(BAD_QUERY)
95+
cursor.fetchall()
96+
with self.assertRaises((UnicodeDecodeError, mariadb.DataError)):
97+
cursor.description
8798
cursor.close()
8899

89-
def test_cursor_usable_after_failure(self):
90-
"""The cursor must recover for the next statement."""
100+
def test_cursor_and_connection_stay_usable(self):
91101
cursor = self.connection.cursor(named_tuple=True)
92-
self._undecodable_column(cursor)
93-
102+
cursor.execute(BAD_QUERY)
103+
cursor.fetchall()
94104
cursor.execute(GOOD_QUERY)
95105
row = cursor.fetchall()[0]
96-
self.assertEqual(row.answer, 42)
97-
self.assertEqual(row.status, "ok")
98-
cursor.close()
99-
100-
def test_cursor_usable_after_failure_binary(self):
101-
"""Same on the prepared statement path."""
102-
cursor = self.connection.cursor(named_tuple=True, binary=True)
103-
self._undecodable_column(cursor)
104-
105-
cursor.execute(GOOD_QUERY)
106-
row = cursor.fetchall()[0]
107-
self.assertEqual(row.answer, 42)
108-
self.assertEqual(row.status, "ok")
109-
cursor.close()
110-
111-
def test_connection_usable_after_failure(self):
112-
"""The pending result set must be drained, not left on the wire."""
113-
cursor = self.connection.cursor(named_tuple=True)
114-
self._undecodable_column(cursor)
106+
self.assertEqual((row.answer, row.status), (42, "ok"))
115107
cursor.close()
116108

117109
other = self.connection.cursor()

0 commit comments

Comments
 (0)