Skip to content

Commit 6b9b547

Browse files
committed
Add time zone testing and handling for mssql
1 parent 79d52f1 commit 6b9b547

File tree

9 files changed

+480
-1
lines changed

9 files changed

+480
-1
lines changed

sqlit/db/adapters/mssql.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
from ...config import ConnectionConfig
1111

1212

13+
def _convert_datetimeoffset(value: bytes) -> str:
14+
"""Convert SQL Server datetimeoffset binary to ISO 8601 string.
15+
16+
The binary format is 20 bytes: year(2), month(2), day(2), hour(2),
17+
minute(2), second(2), nanoseconds(4), tz_hour(2), tz_minute(2).
18+
See: https://github.com/mkleehammer/pyodbc/issues/134
19+
"""
20+
import struct
21+
22+
tup = struct.unpack("<6hI2h", value)
23+
year, month, day, hour, minute, second, ns, tz_hour, tz_min = tup
24+
microseconds = ns // 1000
25+
tz_sign = "+" if tz_hour >= 0 else "-"
26+
return f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}.{microseconds:06d} {tz_sign}{abs(tz_hour):02d}:{abs(tz_min):02d}"
27+
28+
1329
class SQLServerAdapter(DatabaseAdapter):
1430
"""Adapter for Microsoft SQL Server using pyodbc."""
1531

@@ -178,7 +194,12 @@ def connect(self, config: ConnectionConfig) -> Any:
178194
raise MissingODBCDriverError(driver, installed)
179195

180196
conn_str = self._build_connection_string(config)
181-
return pyodbc.connect(conn_str, timeout=10)
197+
conn = pyodbc.connect(conn_str, timeout=10)
198+
199+
# Register converter for datetimeoffset (ODBC type -155) which pyodbc doesn't support natively
200+
conn.add_output_converter(-155, _convert_datetimeoffset)
201+
202+
return conn
182203

183204
def get_databases(self, conn: Any) -> list[str]:
184205
"""Get list of databases from SQL Server."""
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Integration tests for SQL Server datetimeoffset support.
2+
3+
Tests the fix for pyodbc ODBC type -155 (datetimeoffset) which is not natively supported.
4+
See: https://github.com/mkleehammer/pyodbc/issues/134
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import pytest
11+
12+
13+
MSSQL_HOST = os.environ.get("MSSQL_HOST", "localhost")
14+
MSSQL_PORT = int(os.environ.get("MSSQL_PORT", "1433"))
15+
MSSQL_USER = os.environ.get("MSSQL_USER", "sa")
16+
MSSQL_PASSWORD = os.environ.get("MSSQL_PASSWORD", "YourStrong@Passw0rd")
17+
MSSQL_DATABASE = os.environ.get("MSSQL_DATABASE", "master")
18+
19+
20+
@pytest.fixture
21+
def mssql_adapter():
22+
"""Get MSSQL adapter instance."""
23+
from sqlit.db.adapters.mssql import SQLServerAdapter
24+
return SQLServerAdapter()
25+
26+
27+
@pytest.fixture
28+
def mssql_config():
29+
"""Get MSSQL connection config."""
30+
from sqlit.config import ConnectionConfig
31+
return ConnectionConfig(
32+
name="test-mssql-dto",
33+
db_type="mssql",
34+
server=MSSQL_HOST,
35+
port=str(MSSQL_PORT),
36+
database=MSSQL_DATABASE,
37+
username=MSSQL_USER,
38+
password=MSSQL_PASSWORD,
39+
options={"auth_type": "sql"},
40+
)
41+
42+
43+
def is_mssql_available() -> bool:
44+
"""Check if SQL Server is available."""
45+
import socket
46+
try:
47+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
48+
sock.settimeout(2)
49+
result = sock.connect_ex((MSSQL_HOST, MSSQL_PORT))
50+
sock.close()
51+
return result == 0
52+
except Exception:
53+
return False
54+
55+
56+
@pytest.mark.integration
57+
@pytest.mark.mssql
58+
class TestMSSQLDatetimeOffset:
59+
"""Integration tests for datetimeoffset column support."""
60+
61+
@pytest.fixture(autouse=True)
62+
def skip_if_unavailable(self):
63+
"""Skip tests if SQL Server is not available."""
64+
if not is_mssql_available():
65+
pytest.skip("SQL Server is not available")
66+
try:
67+
import pyodbc
68+
drivers = [d for d in pyodbc.drivers() if "SQL Server" in d]
69+
if not drivers:
70+
pytest.skip("No SQL Server ODBC driver installed")
71+
except ImportError:
72+
pytest.skip("pyodbc is not installed")
73+
74+
def test_query_datetimeoffset_column(self, mssql_adapter, mssql_config):
75+
"""Test that querying a table with datetimeoffset columns works.
76+
77+
Previously this would fail with:
78+
'ODBC SQL type -155 is not yet supported. column-index=N type=-155'
79+
"""
80+
conn = mssql_adapter.connect(mssql_config)
81+
82+
try:
83+
cursor = conn.cursor()
84+
85+
# Create table with datetimeoffset column
86+
cursor.execute("""
87+
IF OBJECT_ID('test_audit_log', 'U') IS NOT NULL
88+
DROP TABLE test_audit_log
89+
""")
90+
cursor.execute("""
91+
CREATE TABLE test_audit_log (
92+
id INT PRIMARY KEY,
93+
action NVARCHAR(100),
94+
created_at DATETIMEOFFSET NOT NULL,
95+
modified_at DATETIMEOFFSET
96+
)
97+
""")
98+
99+
# Insert test data with various timezone offsets
100+
cursor.execute("""
101+
INSERT INTO test_audit_log (id, action, created_at, modified_at) VALUES
102+
(1, 'INSERT', '2024-01-15 10:30:00.123456 -05:00', '2024-01-15 11:00:00.000000 -05:00'),
103+
(2, 'UPDATE', '2024-06-20 14:45:30.500000 +00:00', '2024-06-20 15:00:00.000000 +00:00'),
104+
(3, 'DELETE', '2024-12-01 08:15:45.999999 +05:30', NULL)
105+
""")
106+
conn.commit()
107+
108+
# Query the table - this would fail before the fix
109+
columns, rows, truncated = mssql_adapter.execute_query(
110+
conn, "SELECT * FROM test_audit_log ORDER BY id"
111+
)
112+
113+
# Verify we got results
114+
assert len(columns) == 4
115+
assert len(rows) == 3
116+
117+
# Verify column names
118+
assert columns == ["id", "action", "created_at", "modified_at"]
119+
120+
# Verify datetimeoffset values are returned as strings
121+
# Row 1: Eastern time (-05:00)
122+
assert rows[0][0] == 1
123+
assert rows[0][1] == "INSERT"
124+
assert "2024-01-15" in rows[0][2]
125+
assert "10:30:00" in rows[0][2]
126+
assert "-05:00" in rows[0][2]
127+
128+
# Row 2: UTC (+00:00)
129+
assert rows[1][0] == 2
130+
assert rows[1][1] == "UPDATE"
131+
assert "2024-06-20" in rows[1][2]
132+
assert "+00:00" in rows[1][2]
133+
134+
# Row 3: India time (+05:30)
135+
assert rows[2][0] == 3
136+
assert rows[2][1] == "DELETE"
137+
assert "2024-12-01" in rows[2][2]
138+
assert "+05:30" in rows[2][2]
139+
# NULL value should be None
140+
assert rows[2][3] is None
141+
142+
# Clean up
143+
cursor.execute("DROP TABLE test_audit_log")
144+
conn.commit()
145+
146+
finally:
147+
conn.close()
148+
149+
def test_select_star_with_audit_columns(self, mssql_adapter, mssql_config):
150+
"""Test SELECT * on a typical table with audit timestamp columns.
151+
152+
This is the most common use case - tables with CreatedAt/ModifiedAt columns.
153+
"""
154+
conn = mssql_adapter.connect(mssql_config)
155+
156+
try:
157+
cursor = conn.cursor()
158+
159+
cursor.execute("""
160+
IF OBJECT_ID('test_entities', 'U') IS NOT NULL
161+
DROP TABLE test_entities
162+
""")
163+
cursor.execute("""
164+
CREATE TABLE test_entities (
165+
id INT IDENTITY(1,1) PRIMARY KEY,
166+
name NVARCHAR(100) NOT NULL,
167+
description NVARCHAR(MAX),
168+
created_at DATETIMEOFFSET DEFAULT SYSDATETIMEOFFSET(),
169+
modified_at DATETIMEOFFSET DEFAULT SYSDATETIMEOFFSET()
170+
)
171+
""")
172+
173+
cursor.execute("""
174+
INSERT INTO test_entities (name, description) VALUES
175+
('Entity 1', 'First entity'),
176+
('Entity 2', 'Second entity')
177+
""")
178+
conn.commit()
179+
180+
# SELECT * should work without errors
181+
columns, rows, truncated = mssql_adapter.execute_query(
182+
conn, "SELECT * FROM test_entities"
183+
)
184+
185+
assert len(rows) == 2
186+
assert len(columns) == 5
187+
188+
# Verify the datetimeoffset columns are present and formatted
189+
for row in rows:
190+
created_at = row[3]
191+
modified_at = row[4]
192+
# Should be non-empty strings with date components
193+
assert isinstance(created_at, str)
194+
assert isinstance(modified_at, str)
195+
assert len(created_at) > 10 # Should have date + time + timezone
196+
assert len(modified_at) > 10
197+
198+
# Clean up
199+
cursor.execute("DROP TABLE test_entities")
200+
conn.commit()
201+
202+
finally:
203+
conn.close()

tests/test_cockroachdb.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def config(self) -> DatabaseTestConfig:
2020
connection_fixture="cockroachdb_connection",
2121
db_fixture="cockroachdb_db",
2222
create_connection_args=lambda: [], # Uses fixtures
23+
timezone_datetime_type="TIMESTAMPTZ",
2324
)
2425

2526
def test_create_cockroachdb_connection(self, cockroachdb_db, cli_runner):

tests/test_database_base.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ class DatabaseTestConfig:
2929
create_connection_args: Callable[..., list[str]]
3030
# Whether this DB uses LIMIT syntax (False for MSSQL TOP, Oracle FETCH FIRST)
3131
uses_limit: bool = True
32+
# Timezone-aware datetime type name (None if not supported)
33+
# e.g., "DATETIMEOFFSET" for MSSQL, "TIMESTAMPTZ" for PostgreSQL
34+
timezone_datetime_type: str | None = None
3235

3336

3437
class BaseDatabaseTests(ABC):
@@ -822,6 +825,136 @@ def test_get_index_definition(self, request):
822825
assert isinstance(info, dict), "get_index_definition should return a dict"
823826
assert "name" in info, "Index info should contain 'name'"
824827

828+
def test_timezone_aware_datetime(self, request):
829+
"""Test that timezone-aware datetime columns can be queried.
830+
831+
This tests that databases with timezone-aware datetime types (like
832+
DATETIMEOFFSET, TIMESTAMPTZ, TIMESTAMP WITH TIME ZONE) can be queried
833+
without errors. This is important because some drivers (like pyodbc)
834+
don't natively support these types and need custom converters.
835+
836+
See: https://github.com/mkleehammer/pyodbc/issues/134
837+
"""
838+
if self.config.timezone_datetime_type is None:
839+
pytest.skip(f"{self.config.display_name} does not have a timezone-aware datetime type")
840+
841+
from sqlit.config import load_connections
842+
from sqlit.db.adapters import get_adapter
843+
from sqlit.services.session import ConnectionSession
844+
845+
connection_name = request.getfixturevalue(self.config.connection_fixture)
846+
connections = load_connections()
847+
config = next((c for c in connections if c.name == connection_name), None)
848+
assert config is not None, f"Connection {connection_name} not found"
849+
850+
tz_type = self.config.timezone_datetime_type
851+
852+
with ConnectionSession.create(config, get_adapter) as session:
853+
conn = session.connection
854+
adapter = session.adapter
855+
856+
# Create a test table with timezone-aware datetime column
857+
# Use a unique table name to avoid conflicts
858+
table_name = "test_tz_datetime"
859+
860+
# Drop table if exists (database-specific syntax)
861+
try:
862+
if self.config.db_type == "mssql":
863+
adapter.execute_non_query(conn, f"""
864+
IF OBJECT_ID('{table_name}', 'U') IS NOT NULL
865+
DROP TABLE {table_name}
866+
""")
867+
elif self.config.db_type == "oracle":
868+
try:
869+
adapter.execute_non_query(conn, f"DROP TABLE {table_name}")
870+
except Exception:
871+
pass # Table doesn't exist
872+
else:
873+
adapter.execute_non_query(conn, f"DROP TABLE IF EXISTS {table_name}")
874+
except Exception:
875+
pass # Ignore errors from DROP
876+
877+
# Create table with timezone-aware datetime
878+
if self.config.db_type == "oracle":
879+
create_sql = f"""
880+
CREATE TABLE {table_name} (
881+
id NUMBER PRIMARY KEY,
882+
event_name VARCHAR2(100),
883+
event_time {tz_type}
884+
)
885+
"""
886+
else:
887+
create_sql = f"""
888+
CREATE TABLE {table_name} (
889+
id INT PRIMARY KEY,
890+
event_name VARCHAR(100),
891+
event_time {tz_type}
892+
)
893+
"""
894+
adapter.execute_non_query(conn, create_sql)
895+
896+
# Insert test data with timezone info
897+
if self.config.db_type == "mssql":
898+
insert_sql = f"""
899+
INSERT INTO {table_name} (id, event_name, event_time) VALUES
900+
(1, 'Event UTC', '2024-06-15 12:00:00.000000 +00:00'),
901+
(2, 'Event EST', '2024-06-15 08:00:00.000000 -04:00'),
902+
(3, 'Event IST', '2024-06-15 17:30:00.000000 +05:30')
903+
"""
904+
elif self.config.db_type == "oracle":
905+
# Oracle uses different syntax for TIMESTAMP WITH TIME ZONE
906+
adapter.execute_non_query(conn, f"""
907+
INSERT INTO {table_name} (id, event_name, event_time) VALUES
908+
(1, 'Event UTC', TIMESTAMP '2024-06-15 12:00:00 +00:00')
909+
""")
910+
adapter.execute_non_query(conn, f"""
911+
INSERT INTO {table_name} (id, event_name, event_time) VALUES
912+
(2, 'Event EST', TIMESTAMP '2024-06-15 08:00:00 -04:00')
913+
""")
914+
adapter.execute_non_query(conn, f"""
915+
INSERT INTO {table_name} (id, event_name, event_time) VALUES
916+
(3, 'Event IST', TIMESTAMP '2024-06-15 17:30:00 +05:30')
917+
""")
918+
insert_sql = None
919+
else:
920+
# PostgreSQL, CockroachDB, DuckDB use standard syntax
921+
insert_sql = f"""
922+
INSERT INTO {table_name} (id, event_name, event_time) VALUES
923+
(1, 'Event UTC', '2024-06-15 12:00:00+00'),
924+
(2, 'Event EST', '2024-06-15 08:00:00-04'),
925+
(3, 'Event IST', '2024-06-15 17:30:00+05:30')
926+
"""
927+
928+
if insert_sql:
929+
adapter.execute_non_query(conn, insert_sql)
930+
931+
# Query the table - this is where type -155 errors would occur
932+
columns, rows, truncated = adapter.execute_query(
933+
conn, f"SELECT * FROM {table_name} ORDER BY id"
934+
)
935+
936+
# Verify we got results
937+
assert len(columns) == 3, f"Expected 3 columns, got {len(columns)}"
938+
assert len(rows) == 3, f"Expected 3 rows, got {len(rows)}"
939+
940+
# Verify the data is readable (timezone info should be present in some form)
941+
for row in rows:
942+
event_time = row[2]
943+
assert event_time is not None, "event_time should not be None"
944+
# The value should be either a datetime object or a string representation
945+
event_time_str = str(event_time)
946+
assert "2024" in event_time_str, f"Expected year 2024 in {event_time_str}"
947+
948+
# Clean up
949+
try:
950+
if self.config.db_type == "oracle":
951+
adapter.execute_non_query(conn, f"DROP TABLE {table_name}")
952+
else:
953+
adapter.execute_non_query(conn, f"DROP TABLE IF EXISTS {table_name}")
954+
except Exception:
955+
pass
956+
957+
825958
class BaseDatabaseTestsWithLimit(BaseDatabaseTests):
826959
"""Base tests for databases that support LIMIT syntax."""
827960

0 commit comments

Comments
 (0)