Skip to content

Commit fba0e63

Browse files
authored
FEAT: Complex Data Type Support - DATETIMEOFFSET (#243)
### 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#34944](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34944) <!-- External contributors: GitHub Issue --> > GitHub Issue: #<ISSUE_NUMBER> ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request adds comprehensive support for the SQL Server `DATETIMEOFFSET` type in the driver, including full round-trip handling of timezone-aware Python `datetime` objects. The changes span the C++ bindings, Python type mapping, and test coverage to ensure correct reading and writing of `DATETIMEOFFSET` values, as well as validation for timezone information. **DATETIMEOFFSET support in C++ bindings:** * Defined new constants and a `DateTimeOffset` struct in `ddbc_bindings.cpp` to represent and handle the SQL Server `DATETIMEOFFSET` type and its C type mapping (`SQL_SS_TIMESTAMPOFFSET`, `SQL_C_SS_TIMESTAMPOFFSET`). [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L15-R22) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R97-R110) * Implemented logic in parameter binding to convert Python timezone-aware `datetime` objects into the `DateTimeOffset` struct, including extracting and validating timezone offsets. Naive datetimes are rejected for this type. * Added logic to fetch `DATETIMEOFFSET` values from the database and convert them back into Python `datetime` objects with the correct timezone information. **Python type mapping and constants:** * Added `SQL_DATETIMEOFFSET` and `SQL_C_SS_TIMESTAMPOFFSET` to the driver's constants in `constants.py`. * Updated the cursor's type mapping logic to use `DATETIMEOFFSET` for timezone-aware `datetime` objects, and `TIMESTAMP` for naive ones. **Testing improvements:** * Introduced a new test, `test_datetimeoffset_read_write`, which verifies correct round-trip handling of `DATETIMEOFFSET` values, including various timezone offsets and microsecond precision, and ensures that naive datetimes are rejected. * Added necessary imports for timezone handling in the test module. These changes ensure that the driver now robustly supports SQL Server's `DATETIMEOFFSET` type, providing accurate and safe handling of timezone-aware datetimes between Python and the database. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent c1debd6 commit fba0e63

File tree

4 files changed

+293
-12
lines changed

4 files changed

+293
-12
lines changed

mssql_python/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ class ConstantsDDBC(Enum):
124124
SQL_FETCH_ABSOLUTE = 5
125125
SQL_FETCH_RELATIVE = 6
126126
SQL_FETCH_BOOKMARK = 8
127+
SQL_DATETIMEOFFSET = -155
128+
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
127129
SQL_SCOPE_CURROW = 0
128130
SQL_BEST_ROWID = 1
129131
SQL_ROWVER = 2

mssql_python/cursor.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -467,13 +467,24 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None):
467467
)
468468

469469
if isinstance(param, datetime.datetime):
470-
return (
471-
ddbc_sql_const.SQL_TIMESTAMP.value,
472-
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
473-
26,
474-
6,
475-
False,
476-
)
470+
if param.tzinfo is not None:
471+
# Timezone-aware datetime -> DATETIMEOFFSET
472+
return (
473+
ddbc_sql_const.SQL_DATETIMEOFFSET.value,
474+
ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
475+
34,
476+
7,
477+
False,
478+
)
479+
else:
480+
# Naive datetime -> TIMESTAMP
481+
return (
482+
ddbc_sql_const.SQL_TIMESTAMP.value,
483+
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
484+
26,
485+
6,
486+
False,
487+
)
477488

478489
if isinstance(param, datetime.date):
479490
return (

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
#include <iostream>
1313
#include <utility> // std::forward
1414
#include <filesystem>
15-
1615
//-------------------------------------------------------------------------------------------------
1716
// Macro definitions
1817
//-------------------------------------------------------------------------------------------------
1918

2019
// This constant is not exposed via sql.h, hence define it here
2120
#define SQL_SS_TIME2 (-154)
22-
21+
#define SQL_SS_TIMESTAMPOFFSET (-155)
22+
#define SQL_C_SS_TIMESTAMPOFFSET (0x4001)
2323
#define MAX_DIGITS_IN_NUMERIC 64
2424

2525
#define STRINGIFY_FOR_CASE(x) \
@@ -94,6 +94,20 @@ struct ColumnBuffers {
9494
indicators(numCols, std::vector<SQLLEN>(fetchSize)) {}
9595
};
9696

97+
// Struct to hold the DateTimeOffset structure
98+
struct DateTimeOffset
99+
{
100+
SQLSMALLINT year;
101+
SQLUSMALLINT month;
102+
SQLUSMALLINT day;
103+
SQLUSMALLINT hour;
104+
SQLUSMALLINT minute;
105+
SQLUSMALLINT second;
106+
SQLUINTEGER fraction; // Nanoseconds
107+
SQLSMALLINT timezone_hour; // Offset hours from UTC
108+
SQLSMALLINT timezone_minute; // Offset minutes from UTC
109+
};
110+
97111
//-------------------------------------------------------------------------------------------------
98112
// Function pointer initialization
99113
//-------------------------------------------------------------------------------------------------
@@ -463,6 +477,49 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
463477
dataPtr = static_cast<void*>(sqlTimePtr);
464478
break;
465479
}
480+
case SQL_C_SS_TIMESTAMPOFFSET: {
481+
py::object datetimeType = py::module_::import("datetime").attr("datetime");
482+
if (!py::isinstance(param, datetimeType)) {
483+
ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
484+
}
485+
// Checking if the object has a timezone
486+
py::object tzinfo = param.attr("tzinfo");
487+
if (tzinfo.is_none()) {
488+
ThrowStdException("Datetime object must have tzinfo for SQL_C_SS_TIMESTAMPOFFSET at paramIndex " + std::to_string(paramIndex));
489+
}
490+
491+
DateTimeOffset* dtoPtr = AllocateParamBuffer<DateTimeOffset>(paramBuffers);
492+
493+
dtoPtr->year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
494+
dtoPtr->month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
495+
dtoPtr->day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
496+
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
497+
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
498+
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
499+
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
500+
501+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
502+
if (utcoffset.is_none()) {
503+
ThrowStdException("Datetime object's tzinfo.utcoffset() returned None at paramIndex " + std::to_string(paramIndex));
504+
}
505+
506+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
507+
const int MAX_OFFSET = 14 * 3600;
508+
const int MIN_OFFSET = -14 * 3600;
509+
510+
if (total_seconds > MAX_OFFSET || total_seconds < MIN_OFFSET) {
511+
ThrowStdException("Datetimeoffset tz offset out of SQL Server range (-14h to +14h) at paramIndex " + std::to_string(paramIndex));
512+
}
513+
std::div_t div_result = std::div(total_seconds, 3600);
514+
dtoPtr->timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
515+
dtoPtr->timezone_minute = static_cast<SQLSMALLINT>(div(div_result.rem, 60).quot);
516+
517+
dataPtr = static_cast<void*>(dtoPtr);
518+
bufferLength = sizeof(DateTimeOffset);
519+
strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
520+
*strLenOrIndPtr = bufferLength;
521+
break;
522+
}
466523
case SQL_C_TYPE_TIMESTAMP: {
467524
py::object datetimeType = py::module_::import("datetime").attr("datetime");
468525
if (!py::isinstance(param, datetimeType)) {
@@ -540,7 +597,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
540597
}
541598
}
542599
assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr);
543-
544600
RETCODE rc = SQLBindParameter_ptr(
545601
hStmt,
546602
static_cast<SQLUSMALLINT>(paramIndex + 1), /* 1-based indexing */
@@ -2511,6 +2567,55 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
25112567
}
25122568
break;
25132569
}
2570+
case SQL_SS_TIMESTAMPOFFSET: {
2571+
DateTimeOffset dtoValue;
2572+
SQLLEN indicator;
2573+
ret = SQLGetData_ptr(
2574+
hStmt,
2575+
i, SQL_C_SS_TIMESTAMPOFFSET,
2576+
&dtoValue,
2577+
sizeof(dtoValue),
2578+
&indicator
2579+
);
2580+
if (SQL_SUCCEEDED(ret) && indicator != SQL_NULL_DATA) {
2581+
LOG("[Fetch] Retrieved DTO: {}-{}-{} {}:{}:{}, fraction(ns)={}, tz_hour={}, tz_minute={}",
2582+
dtoValue.year, dtoValue.month, dtoValue.day,
2583+
dtoValue.hour, dtoValue.minute, dtoValue.second,
2584+
dtoValue.fraction,
2585+
dtoValue.timezone_hour, dtoValue.timezone_minute
2586+
);
2587+
2588+
int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute;
2589+
// Validating offset
2590+
if (totalMinutes < -24 * 60 || totalMinutes > 24 * 60) {
2591+
std::ostringstream oss;
2592+
oss << "Invalid timezone offset from SQL_SS_TIMESTAMPOFFSET_STRUCT: "
2593+
<< totalMinutes << " minutes for column " << i;
2594+
ThrowStdException(oss.str());
2595+
}
2596+
// Convert fraction from ns to µs
2597+
int microseconds = dtoValue.fraction / 1000;
2598+
py::object datetime = py::module_::import("datetime");
2599+
py::object tzinfo = datetime.attr("timezone")(
2600+
datetime.attr("timedelta")(py::arg("minutes") = totalMinutes)
2601+
);
2602+
py::object py_dt = datetime.attr("datetime")(
2603+
dtoValue.year,
2604+
dtoValue.month,
2605+
dtoValue.day,
2606+
dtoValue.hour,
2607+
dtoValue.minute,
2608+
dtoValue.second,
2609+
microseconds,
2610+
tzinfo
2611+
);
2612+
row.append(py_dt);
2613+
} else {
2614+
LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret);
2615+
row.append(py::none());
2616+
}
2617+
break;
2618+
}
25142619
case SQL_BINARY:
25152620
case SQL_VARBINARY:
25162621
case SQL_LONGVARBINARY: {

tests/test_004_cursor.py

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010

1111
import pytest
12-
from datetime import datetime, date, time
12+
from datetime import datetime, date, time, timedelta, timezone
1313
import time as time_module
1414
import decimal
1515
from contextlib import closing
@@ -6472,7 +6472,7 @@ def test_only_null_and_empty_binary(cursor, db_connection):
64726472
finally:
64736473
drop_table_if_exists(cursor, "#pytest_null_empty_binary")
64746474
db_connection.commit()
6475-
6475+
64766476
# ---------------------- VARCHAR(MAX) ----------------------
64776477

64786478
def test_varcharmax_short_fetch(cursor, db_connection):
@@ -7560,6 +7560,169 @@ def test_decimal_separator_calculations(cursor, db_connection):
75607560
cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test")
75617561
db_connection.commit()
75627562

7563+
def test_datetimeoffset_read_write(cursor, db_connection):
7564+
"""Test reading and writing timezone-aware DATETIMEOFFSET values."""
7565+
try:
7566+
test_cases = [
7567+
# Valid timezone-aware datetimes
7568+
datetime(2023, 10, 26, 10, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))),
7569+
datetime(2023, 10, 27, 15, 45, 10, 123456, tzinfo=timezone(timedelta(hours=-8))),
7570+
datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone.utc)
7571+
]
7572+
7573+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7574+
db_connection.commit()
7575+
7576+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);"
7577+
for i, dt in enumerate(test_cases):
7578+
cursor.execute(insert_stmt, i, dt)
7579+
db_connection.commit()
7580+
7581+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;")
7582+
for i, dt in enumerate(test_cases):
7583+
row = cursor.fetchone()
7584+
assert row is not None
7585+
fetched_id, fetched_dt = row
7586+
assert fetched_dt.tzinfo is not None
7587+
expected_utc = dt.astimezone(timezone.utc)
7588+
fetched_utc = fetched_dt.astimezone(timezone.utc)
7589+
# Ignore sub-microsecond differences
7590+
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
7591+
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
7592+
assert fetched_utc == expected_utc
7593+
finally:
7594+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
7595+
db_connection.commit()
7596+
7597+
def test_datetimeoffset_max_min_offsets(cursor, db_connection):
7598+
"""
7599+
Test inserting and retrieving DATETIMEOFFSET with maximum and minimum allowed offsets (+14:00 and -14:00).
7600+
Uses fetchone() for retrieval.
7601+
"""
7602+
try:
7603+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7604+
db_connection.commit()
7605+
7606+
test_cases = [
7607+
(1, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=14)))), # max offset
7608+
(2, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=-14)))), # min offset
7609+
]
7610+
7611+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);"
7612+
for row_id, dt in test_cases:
7613+
cursor.execute(insert_stmt, row_id, dt)
7614+
db_connection.commit()
7615+
7616+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;")
7617+
7618+
for expected_id, expected_dt in test_cases:
7619+
row = cursor.fetchone()
7620+
assert row is not None, f"No row fetched for id {expected_id}."
7621+
fetched_id, fetched_dt = row
7622+
7623+
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
7624+
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
7625+
7626+
# Compare in UTC to avoid offset differences
7627+
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7628+
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7629+
assert fetched_utc == expected_utc, (
7630+
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7631+
)
7632+
7633+
finally:
7634+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
7635+
db_connection.commit()
7636+
7637+
def test_datetimeoffset_invalid_offsets(cursor, db_connection):
7638+
"""Verify driver rejects offsets beyond ±14 hours."""
7639+
try:
7640+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_invalid_offsets (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7641+
db_connection.commit()
7642+
7643+
with pytest.raises(Exception):
7644+
cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);",
7645+
1, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=15))))
7646+
7647+
with pytest.raises(Exception):
7648+
cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);",
7649+
2, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=-15))))
7650+
finally:
7651+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_invalid_offsets;")
7652+
db_connection.commit()
7653+
7654+
def test_datetimeoffset_dst_transitions(cursor, db_connection):
7655+
"""
7656+
Test inserting and retrieving DATETIMEOFFSET values around DST transitions.
7657+
Ensures that driver handles DST correctly and does not crash.
7658+
"""
7659+
try:
7660+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_dst_transitions (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7661+
db_connection.commit()
7662+
7663+
# Example DST transition dates (replace with actual region offset if needed)
7664+
dst_test_cases = [
7665+
(1, datetime(2025, 3, 9, 1, 59, 59, tzinfo=timezone(timedelta(hours=-5)))), # Just before spring forward
7666+
(2, datetime(2025, 3, 9, 3, 0, 0, tzinfo=timezone(timedelta(hours=-4)))), # Just after spring forward
7667+
(3, datetime(2025, 11, 2, 1, 59, 59, tzinfo=timezone(timedelta(hours=-4)))), # Just before fall back
7668+
(4, datetime(2025, 11, 2, 1, 0, 0, tzinfo=timezone(timedelta(hours=-5)))), # Just after fall back
7669+
]
7670+
7671+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_dst_transitions (id, dto_column) VALUES (?, ?);"
7672+
for row_id, dt in dst_test_cases:
7673+
cursor.execute(insert_stmt, row_id, dt)
7674+
db_connection.commit()
7675+
7676+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_dst_transitions ORDER BY id;")
7677+
7678+
for expected_id, expected_dt in dst_test_cases:
7679+
row = cursor.fetchone()
7680+
assert row is not None, f"No row fetched for id {expected_id}."
7681+
fetched_id, fetched_dt = row
7682+
7683+
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
7684+
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
7685+
7686+
# Compare UTC time to avoid issues due to offsets changing in DST
7687+
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7688+
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7689+
assert fetched_utc == expected_utc, (
7690+
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7691+
)
7692+
7693+
finally:
7694+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;")
7695+
db_connection.commit()
7696+
7697+
def test_datetimeoffset_leap_second(cursor, db_connection):
7698+
"""Ensure driver handles leap-second-like microsecond edge cases without crashing."""
7699+
try:
7700+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_leap_second (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7701+
db_connection.commit()
7702+
7703+
leap_second_sim = datetime(2023, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc)
7704+
cursor.execute("INSERT INTO #pytest_datetimeoffset_leap_second (id, dto_column) VALUES (?, ?);", 1, leap_second_sim)
7705+
db_connection.commit()
7706+
7707+
row = cursor.execute("SELECT dto_column FROM #pytest_datetimeoffset_leap_second;").fetchone()
7708+
assert row[0].tzinfo is not None
7709+
finally:
7710+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_leap_second;")
7711+
db_connection.commit()
7712+
7713+
def test_datetimeoffset_malformed_input(cursor, db_connection):
7714+
"""Verify driver raises error for invalid datetimeoffset strings."""
7715+
try:
7716+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_malformed_input (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7717+
db_connection.commit()
7718+
7719+
with pytest.raises(Exception):
7720+
cursor.execute("INSERT INTO #pytest_datetimeoffset_malformed_input (id, dto_column) VALUES (?, ?);",
7721+
1, "2023-13-45 25:61:00 +99:99") # invalid string
7722+
finally:
7723+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;")
7724+
db_connection.commit()
7725+
75637726
def test_lowercase_attribute(cursor, db_connection):
75647727
"""Test that the lowercase attribute properly converts column names to lowercase"""
75657728

0 commit comments

Comments
 (0)