Skip to content

Commit 5fe06f5

Browse files
authored
FEAT: Adding lowercase for global variable (#187)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#34905](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34905) ------------------------------------------------------------------- ### Summary This pull request introduces a new global `lowercase` flag to the `mssql_python` package, allowing users to control whether column names in query results are automatically converted to lowercase. The changes include updates to the global settings infrastructure, the `Cursor` and `Row` classes to support case-insensitive access, and comprehensive tests to ensure correct behavior. **Global lowercase flag and settings:** * Added a global `lowercase` flag and supporting infrastructure to `mssql_python/__init__.py`, including a `Settings` class and a `get_settings()` accessor. The default value is `False`. * Exported the `lowercase` flag for external use and updated tests to check its default value. **Cursor and Row class enhancements:** * Modified the `Cursor` class to read the global `lowercase` setting and pass it to `Row` objects. The `_initialize_description` method now lowercases column names in the description if the flag is set. * Updated the `Row` class to support attribute access by column name, respecting the `lowercase` flag for case-insensitive lookup. * Adjusted cursor fetch methods to pass the `Cursor` instance to `Row` objects for correct lowercase handling. **Testing improvements:** * Added a comprehensive test for the `lowercase` flag in `tests/test_004_cursor.py`, verifying that column names in the cursor description and row attribute access are correctly lowercased when the flag is enabled. * Ensured proper cleanup and restoration of global state in the new test. --------- Co-authored-by: Jahnvi Thakkar <[email protected]>
1 parent e275782 commit 5fe06f5

File tree

5 files changed

+453
-107
lines changed

5 files changed

+453
-107
lines changed

mssql_python/__init__.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,37 @@
33
Licensed under the MIT license.
44
This module initializes the mssql_python package.
55
"""
6-
6+
import threading
77
# Exceptions
88
# https://www.python.org/dev/peps/pep-0249/#exceptions
9+
10+
# GLOBALS
11+
# Read-Only
12+
apilevel = "2.0"
13+
paramstyle = "qmark"
14+
threadsafety = 1
15+
16+
_settings_lock = threading.Lock()
17+
18+
# Create a settings object to hold configuration
19+
class Settings:
20+
def __init__(self):
21+
self.lowercase = False
22+
23+
# Create a global settings instance
24+
_settings = Settings()
25+
26+
# Define the get_settings function for internal use
27+
def get_settings():
28+
"""Return the global settings object"""
29+
with _settings_lock:
30+
_settings.lowercase = lowercase
31+
return _settings
32+
33+
# Expose lowercase as a regular module variable that users can access and set
34+
lowercase = _settings.lowercase
35+
36+
# Import necessary modules
937
from .exceptions import (
1038
Warning,
1139
Error,
@@ -52,12 +80,6 @@
5280
SQL_WCHAR = ConstantsDDBC.SQL_WCHAR.value
5381
SQL_WMETADATA = -99
5482

55-
# GLOBALS
56-
# Read-Only
57-
apilevel = "2.0"
58-
paramstyle = "qmark"
59-
threadsafety = 1
60-
6183
from .pooling import PoolingManager
6284
def pooling(max_size=100, idle_timeout=600, enabled=True):
6385
# """
@@ -76,3 +98,18 @@ def pooling(max_size=100, idle_timeout=600, enabled=True):
7698
PoolingManager.disable()
7799
else:
78100
PoolingManager.enable(max_size, idle_timeout)
101+
102+
import sys
103+
_original_module_setattr = sys.modules[__name__].__setattr__
104+
105+
def _custom_setattr(name, value):
106+
if name == 'lowercase':
107+
with _settings_lock:
108+
_settings.lowercase = bool(value)
109+
# Update the module's lowercase variable
110+
_original_module_setattr(name, _settings.lowercase)
111+
else:
112+
_original_module_setattr(name, value)
113+
114+
# Replace the module's __setattr__ with our custom version
115+
sys.modules[__name__].__setattr__ = _custom_setattr

mssql_python/cursor.py

Lines changed: 113 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from mssql_python.helpers import check_error, log
1717
from mssql_python import ddbc_bindings
1818
from mssql_python.exceptions import InterfaceError, NotSupportedError, ProgrammingError
19-
from .row import Row
19+
from mssql_python.row import Row
20+
from mssql_python import get_settings
2021

2122
# Constants for string handling
2223
MAX_INLINE_CHAR = 4000 # NVARCHAR/VARCHAR inline limit; this triggers NVARCHAR(MAX)/VARCHAR(MAX) + DAE
@@ -543,26 +544,32 @@ def _create_parameter_types_list(self, parameter, param_info, parameters_list, i
543544

544545
return paraminfo
545546

546-
def _initialize_description(self):
547-
"""
548-
Initialize the description attribute using SQLDescribeCol.
549-
"""
550-
col_metadata = []
551-
ret = ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, col_metadata)
552-
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
547+
def _initialize_description(self, column_metadata=None):
548+
"""Initialize the description attribute from column metadata."""
549+
if not column_metadata:
550+
self.description = None
551+
return
553552

554-
self.description = [
555-
(
556-
col["ColumnName"],
557-
self._map_data_type(col["DataType"]),
558-
None,
559-
col["ColumnSize"],
560-
col["ColumnSize"],
561-
col["DecimalDigits"],
562-
col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value,
563-
)
564-
for col in col_metadata
565-
]
553+
description = []
554+
for i, col in enumerate(column_metadata):
555+
# Get column name - lowercase it if the lowercase flag is set
556+
column_name = col["ColumnName"]
557+
558+
# Use the current global setting to ensure tests pass correctly
559+
if get_settings().lowercase:
560+
column_name = column_name.lower()
561+
562+
# Add to description tuple (7 elements as per PEP-249)
563+
description.append((
564+
column_name, # name
565+
self._map_data_type(col["DataType"]), # type_code
566+
None, # display_size
567+
col["ColumnSize"], # internal_size
568+
col["ColumnSize"], # precision - should match ColumnSize
569+
col["DecimalDigits"], # scale
570+
col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value, # null_ok
571+
))
572+
self.description = description
566573

567574
def _map_data_type(self, sql_type):
568575
"""
@@ -746,6 +753,16 @@ def execute(
746753
use_prepare: Whether to use SQLPrepareW (default) or SQLExecDirectW.
747754
reset_cursor: Whether to reset the cursor before execution.
748755
"""
756+
757+
# Restore original fetch methods if they exist
758+
if hasattr(self, '_original_fetchone'):
759+
self.fetchone = self._original_fetchone
760+
self.fetchmany = self._original_fetchmany
761+
self.fetchall = self._original_fetchall
762+
del self._original_fetchone
763+
del self._original_fetchmany
764+
del self._original_fetchall
765+
749766
self._check_closed() # Check if the cursor is closed
750767
if reset_cursor:
751768
self._reset_cursor()
@@ -822,7 +839,14 @@ def execute(
822839
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
823840

824841
# Initialize description after execution
825-
self._initialize_description()
842+
# After successful execution, initialize description if there are results
843+
column_metadata = []
844+
try:
845+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
846+
self._initialize_description(column_metadata)
847+
except Exception as e:
848+
# If describe fails, it's likely there are no results (e.g., for INSERT)
849+
self.description = None
826850

827851
# Reset rownumber for new result set (only for SELECT statements)
828852
if self.description: # If we have column descriptions, it's likely a SELECT
@@ -975,7 +999,7 @@ def fetchone(self) -> Union[None, Row]:
975999

9761000
# Create and return a Row object, passing column name map if available
9771001
column_map = getattr(self, '_column_name_map', None)
978-
return Row(row_data, self.description, column_map)
1002+
return Row(self, self.description, row_data, column_map)
9791003
except Exception as e:
9801004
# On error, don't increment rownumber - rethrow the error
9811005
raise e
@@ -1017,7 +1041,7 @@ def fetchmany(self, size: int = None) -> List[Row]:
10171041

10181042
# Convert raw data to Row objects
10191043
column_map = getattr(self, '_column_name_map', None)
1020-
return [Row(row_data, self.description, column_map) for row_data in rows_data]
1044+
return [Row(self, self.description, row_data, column_map) for row_data in rows_data]
10211045
except Exception as e:
10221046
# On error, don't increment rownumber - rethrow the error
10231047
raise e
@@ -1049,7 +1073,7 @@ def fetchall(self) -> List[Row]:
10491073

10501074
# Convert raw data to Row objects
10511075
column_map = getattr(self, '_column_name_map', None)
1052-
return [Row(row_data, self.description, column_map) for row_data in rows_data]
1076+
return [Row(self, self.description, row_data, column_map) for row_data in rows_data]
10531077
except Exception as e:
10541078
# On error, don't increment rownumber - rethrow the error
10551079
raise e
@@ -1363,30 +1387,20 @@ def tables(self, table=None, catalog=None, schema=None, tableType=None):
13631387
Example: "TABLE" or ["TABLE", "VIEW"]
13641388
13651389
Returns:
1366-
list: A list of Row objects containing table information with these columns:
1367-
- table_cat: Catalog name
1368-
- table_schem: Schema name
1369-
- table_name: Table name
1370-
- table_type: Table type (e.g., "TABLE", "VIEW")
1371-
- remarks: Comments about the table
1372-
1373-
Notes:
1374-
This method only processes the standard five columns as defined in the ODBC
1375-
specification. Any additional columns that might be returned by specific ODBC
1376-
drivers are not included in the result set.
1377-
1390+
Cursor: The cursor object itself for method chaining with fetch methods.
1391+
13781392
Example:
13791393
# Get all tables in the database
1380-
tables = cursor.tables()
1394+
tables = cursor.tables().fetchall()
13811395
13821396
# Get all tables in schema 'dbo'
1383-
tables = cursor.tables(schema='dbo')
1397+
tables = cursor.tables(schema='dbo').fetchall()
13841398
13851399
# Get table named 'Customers'
1386-
tables = cursor.tables(table='Customers')
1400+
tables = cursor.tables(table='Customers').fetchone()
13871401
1388-
# Get all views
1389-
tables = cursor.tables(tableType='VIEW')
1402+
# Get all views with fetchmany
1403+
tables = cursor.tables(tableType='VIEW').fetchmany(10)
13901404
"""
13911405
self._check_closed()
13921406

@@ -1418,7 +1432,13 @@ def tables(self, table=None, catalog=None, schema=None, tableType=None):
14181432
try:
14191433
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
14201434
self._initialize_description(column_metadata)
1421-
except Exception:
1435+
except InterfaceError as e:
1436+
log('error', f"Driver interface error during metadata retrieval: {e}")
1437+
except Exception as e:
1438+
# Log the exception with appropriate context
1439+
log('error', f"Failed to retrieve column metadata: {e}. Using standard ODBC column definitions instead.")
1440+
1441+
if not self.description:
14221442
# If describe fails, create a manual description for the standard columns
14231443
column_types = [str, str, str, str, str]
14241444
self.description = [
@@ -1428,23 +1448,54 @@ def tables(self, table=None, catalog=None, schema=None, tableType=None):
14281448
("table_type", column_types[3], None, 128, 128, 0, False),
14291449
("remarks", column_types[4], None, 254, 254, 0, True)
14301450
]
1431-
1432-
# Define column names in ODBC standard order
1433-
column_names = [
1434-
"table_cat", "table_schem", "table_name", "table_type", "remarks"
1435-
]
1436-
1437-
# Fetch all rows
1438-
rows_data = []
1439-
ddbc_bindings.DDBCSQLFetchAll(self.hstmt, rows_data)
1440-
1441-
# Create a column map for attribute access
1442-
column_map = {name: i for i, name in enumerate(column_names)}
1443-
1444-
# Create Row objects with the column map
1445-
result_rows = []
1446-
for row_data in rows_data:
1447-
row = Row(row_data, self.description, column_map)
1448-
result_rows.append(row)
1449-
1450-
return result_rows
1451+
1452+
# Store the column mappings for this specific tables() call
1453+
column_names = [desc[0] for desc in self.description]
1454+
1455+
# Create a specialized column map for this result set
1456+
columns_map = {}
1457+
for i, name in enumerate(column_names):
1458+
columns_map[name] = i
1459+
columns_map[name.lower()] = i
1460+
1461+
# Define wrapped fetch methods that preserve existing column mapping
1462+
# but add our specialized mapping just for column results
1463+
def fetchone_with_columns_mapping():
1464+
row = self._original_fetchone()
1465+
if row is not None:
1466+
# Create a merged map with columns result taking precedence
1467+
merged_map = getattr(row, '_column_map', {}).copy()
1468+
merged_map.update(columns_map)
1469+
row._column_map = merged_map
1470+
return row
1471+
1472+
def fetchmany_with_columns_mapping(size=None):
1473+
rows = self._original_fetchmany(size)
1474+
for row in rows:
1475+
# Create a merged map with columns result taking precedence
1476+
merged_map = getattr(row, '_column_map', {}).copy()
1477+
merged_map.update(columns_map)
1478+
row._column_map = merged_map
1479+
return rows
1480+
1481+
def fetchall_with_columns_mapping():
1482+
rows = self._original_fetchall()
1483+
for row in rows:
1484+
# Create a merged map with columns result taking precedence
1485+
merged_map = getattr(row, '_column_map', {}).copy()
1486+
merged_map.update(columns_map)
1487+
row._column_map = merged_map
1488+
return rows
1489+
1490+
# Save original fetch methods
1491+
if not hasattr(self, '_original_fetchone'):
1492+
self._original_fetchone = self.fetchone
1493+
self._original_fetchmany = self.fetchmany
1494+
self._original_fetchall = self.fetchall
1495+
1496+
# Override fetch methods with our wrapped versions
1497+
self.fetchone = fetchone_with_columns_mapping
1498+
self.fetchmany = fetchmany_with_columns_mapping
1499+
self.fetchall = fetchall_with_columns_mapping
1500+
1501+
return self

0 commit comments

Comments
 (0)