Skip to content

FIX: Allowing access using column name and index #75

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,33 @@
import os
import decimal

setup_logging('stdout')
# setup_logging('stdout')

conn_str = os.getenv("DB_CONNECTION_STRING")
conn = connect(conn_str)

# conn.autocommit = True

cursor = conn.cursor()
cursor.execute("SELECT database_id, name from sys.databases;")
rows = cursor.fetchall()
rows = cursor.fetchone()

# Debug: Print the type and content of rows
print(f"Type of rows: {type(rows)}")
print(f"Value of rows: {rows}")

for row in rows:
print(f"Database ID: {row[0]}, Name: {row[1]}")
# Only try to access properties if rows is not None
if rows is not None:
try:
# Try different ways to access the data
print(f"First column by index: {rows[0]}")

# Access by attribute name (these should now work)
print(f"First column by name: {rows.database_id}")
print(f"Second column by name: {rows.name}")

# Print all available attributes
print(f"Available attributes: {dir(rows)}")
except Exception as e:
print(f"Exception accessing row data: {e}")

cursor.close()
conn.close()
163 changes: 138 additions & 25 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import ctypes
import decimal
import uuid
import collections
import datetime
from typing import List, Union
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const
Expand Down Expand Up @@ -69,6 +70,10 @@ def __init__(self, connection) -> None:
# Hence, we can't pass around bools by reference & modify them.
# Therefore, it must be a list with exactly one bool element.

# Cache for the named tuple class for the current result set
self._row_namedtuple_class = None
self._row_field_names = None

def _is_unicode_string(self, param):
"""
Check if a string contains non-ASCII characters.
Expand Down Expand Up @@ -553,6 +558,10 @@ def execute(
if reset_cursor:
self._reset_cursor()

# Reset the named tuple class cache when executing a new query
self._row_namedtuple_class = None
self._row_field_names = None

param_info = ddbc_bindings.ParamInfo
parameters_type = []

Expand Down Expand Up @@ -648,73 +657,141 @@ def fetchone(self) -> Union[None, tuple]:
Fetch the next row of a query result set.

Returns:
Single sequence or None if no more data is available.
If data is available:
- A named tuple if column names are valid Python identifiers
- A regular tuple otherwise
None if no more data is available

Named tuples allow access by attribute name (row.column_name)
in addition to index access (row[0]).

Raises:
Error: If the previous call to execute did not produce any result set.

Note:
Valid Python identifiers cannot start with numbers and can only
contain alphanumeric characters and underscores.
"""
self._check_closed() # Check if the cursor is closed

row = []
ret = ddbc_bindings.DDBCSQLFetchOne(self.hstmt, row)
# Use a list to receive the row data
row_list = []
ret = ddbc_bindings.DDBCSQLFetchOne(self.hstmt, row_list)
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
if ret == ddbc_sql_const.SQL_NO_DATA.value:

if ret == ddbc_sql_const.SQL_NO_DATA.value or not row_list:
return None
return list(row)

# Get field names from the description attribute
field_names = [desc[0] for desc in self.description]

# Get or create the named tuple class
RowRecord = self._get_row_namedtuple_class(field_names)

if RowRecord:
# Use the cached named tuple class
return RowRecord(*row_list)
else:
# Fall back to a regular tuple
return tuple(row_list)

def fetchmany(self, size: int = None) -> List[tuple]:
def fetchmany(self, size: int = None) -> list:
"""
Fetch the next set of rows of a query result.
Fetch the next set of rows of a query result, returning a list of tuples.
An empty list is returned when no more rows are available.

Args:
size: Number of rows to fetch at a time.
size (int): The number of rows to fetch. If not provided, the cursor's arraysize
is used.

Returns:
Sequence of sequences (e.g. list of tuples).

A list of row objects where each row is:
- A named tuple if column names are valid Python identifiers
- A regular tuple otherwise

Named tuples allow access by attribute name (row.column_name)
in addition to index access (row[0]).

Raises:
Error: If the previous call to execute did not produce any result set.

Note:
Valid Python identifiers cannot start with numbers and can only
contain alphanumeric characters and underscores.
"""
self._check_closed() # Check if the cursor is closed

if size is None:
size = self.arraysize

# Fetch the next set of rows

rows = []
ret = ddbc_bindings.DDBCSQLFetchMany(self.hstmt, rows, size)
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
if ret == ddbc_sql_const.SQL_NO_DATA.value:
return []
return rows

if not rows:
return rows

# Get field names from the description attribute
field_names = [desc[0] for desc in self.description]

# Get or create the named tuple class
RowRecord = self._get_row_namedtuple_class(field_names)

if RowRecord:
# Convert each row to a named tuple
return [RowRecord(*row) for row in rows]
else:
# Return rows as regular tuples
return [tuple(row) for row in rows]

def fetchall(self) -> List[tuple]:
def fetchall(self) -> list:
"""
Fetch all (remaining) rows of a query result.
Fetch all (remaining) rows of a query result, returning a list of tuples.
An empty list is returned when no more rows are available.

Returns:
Sequence of sequences (e.g. list of tuples).
A list of row objects where each row is:
- A named tuple if column names are valid Python identifiers
- A regular tuple otherwise

Named tuples allow access by attribute name (row.column_name)
in addition to index access (row[0]).

Raises:
Error: If the previous call to execute did not produce any result set.

Note:
Valid Python identifiers cannot start with numbers and can only
contain alphanumeric characters and underscores.
"""
self._check_closed() # Check if the cursor is closed

# Fetch all remaining rows

rows = []
ret = ddbc_bindings.DDBCSQLFetchAll(self.hstmt, rows)
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
return list(rows)

if not rows:
return rows

# Get field names from the description attribute
field_names = [desc[0] for desc in self.description]

# Get or create the named tuple class
RowRecord = self._get_row_namedtuple_class(field_names)

if RowRecord:
# Convert each row to a named tuple
return [RowRecord(*row) for row in rows]
else:
# Return rows as regular tuples
return [tuple(row) for row in rows]

def nextset(self) -> Union[bool, None]:
"""
Skip to the next available result set.

Returns:
True if there is another result set, None otherwise.

Raises:
Error: If the previous call to execute did not produce any result set.
"""
self._check_closed() # Check if the cursor is closed

Expand All @@ -724,3 +801,39 @@ def nextset(self) -> Union[bool, None]:
if ret == ddbc_sql_const.SQL_NO_DATA.value:
return False
return True

def _get_row_namedtuple_class(self, field_names):
"""
Get a cached named tuple class or create a new one if needed.

Args:
field_names: List of column names from the result set

Returns:
A named tuple class for the current result set's schema, or None if
the field names are not valid Python identifiers.
"""
# Check if field names are valid for namedtuple
invalid_fields = [name for name in field_names if not (isinstance(name, str) and name.isidentifier())]
if invalid_fields:
if ENABLE_LOGGING:
logger.debug("Cannot create named tuple due to invalid field names: %s", invalid_fields)
return None

# Check if we already have a cached class with these exact field names
if (self._row_namedtuple_class is not None and
self._row_field_names == field_names):
return self._row_namedtuple_class

# Create a new named tuple class and cache it
try:
self._row_namedtuple_class = collections.namedtuple('RowRecord', field_names, rename=True)
self._row_field_names = field_names
return self._row_namedtuple_class
except (TypeError, ValueError) as e:
# Log the exception for debugging purposes
if ENABLE_LOGGING:
logger.debug("Failed to create named tuple: %s", str(e))
self._row_namedtuple_class = None
self._row_field_names = None
return None
41 changes: 30 additions & 11 deletions mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1807,29 +1807,48 @@ SQLRETURN FetchAll_wrap(SqlHandlePtr StatementHandle, py::list& rows) {
// FetchOne_wrap - Fetches a single row of data from the result set.
//
// @param StatementHandle: Handle to the statement from which data is to be fetched.
// @param row: A Python list that will be populated with the fetched row data.
// @param row: A Python object reference that will be populated with a named tuple containing the fetched row data.
//
// @return SQLRETURN: SQL_SUCCESS or SQL_SUCCESS_WITH_INFO if data is fetched successfully,
// SQL_NO_DATA if there are no more rows to fetch,
// throws a runtime error if there is an error fetching data.
//
// This function assumes that the statement handle (hStmt) is already allocated and a query has been
// executed. It fetches the next row of data from the result set and populates the provided Python
// list with the row data. If there are no more rows to fetch, it returns SQL_NO_DATA. If an error
// occurs during fetching, it throws a runtime error.
SQLRETURN FetchOne_wrap(SqlHandlePtr StatementHandle, py::list& row) {
// object with a named tuple containing the row data. If there are no more rows to fetch, it returns
// SQL_NO_DATA. If an error occurs during fetching, it throws a runtime error.
SQLRETURN FetchOne_wrap(SqlHandlePtr StatementHandle, py::list& row_list) {
SQLRETURN ret;
SQLHSTMT hStmt = StatementHandle->get();

// Assume hStmt is already allocated and a query has been executed
if (!SQLFetch_ptr) {
LOG("Function pointer not initialized in FetchOne_wrap. Loading the driver.\n");
DriverLoader::getInstance().loadDriver();
}

ret = SQLFetch_ptr(hStmt);
if (SQL_SUCCEEDED(ret)) {
// Retrieve column count
SQLSMALLINT colCount = SQLNumResultCols_wrap(StatementHandle);
ret = SQLGetData_wrap(StatementHandle, colCount, row);
} else if (ret != SQL_NO_DATA) {
LOG("Error when fetching data");
if (ret == SQL_NO_DATA) {
return ret;
} else if (!SQL_SUCCEEDED(ret)) {
LOG("Error when fetching data: SQLFetch_ptr failed with retcode {}\n", ret);
return ret;
}

// Get column count - we don't need column names in C++ anymore
SQLSMALLINT colCount;
SQLRETURN colRet = SQLNumResultCols_ptr(hStmt, &colCount);
if (!SQL_SUCCEEDED(colRet)) {
LOG("Error when getting column count: SQLNumResultCols_ptr failed with retcode {}\n", colRet);
return colRet;
}

// Get row data into the list
ret = SQLGetData_wrap(StatementHandle, colCount, row_list);
if (!SQL_SUCCEEDED(ret)) {
LOG("Error when fetching data values: SQLGetData_wrap failed with retcode {}\n", ret);
return ret;
}

return ret;
}

Expand Down
Loading
Loading