Skip to content
Merged
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
38 changes: 36 additions & 2 deletions mssql_python/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ class Connection:
ProgrammingError = ProgrammingError
NotSupportedError = NotSupportedError

def __init__(self, connection_str: str = "", autocommit: bool = False, attrs_before: dict = None, **kwargs) -> None:
def __init__(self, connection_str: str = "", autocommit: bool = False, attrs_before: dict = None, timeout: int = 0, **kwargs) -> None:
"""
Initialize the connection object with the specified connection string and parameters.

Expand Down Expand Up @@ -180,6 +180,7 @@ def __init__(self, connection_str: str = "", autocommit: bool = False, attrs_bef
self._attrs_before.update(connection_result[1])

self._closed = False
self._timeout = timeout

# Using WeakSet which automatically removes cursors when they are no longer in use
# It is a set that holds weak references to its elements.
Expand Down Expand Up @@ -236,6 +237,39 @@ def _construct_connection_string(self, connection_str: str = "", **kwargs) -> st

return conn_str

@property
def timeout(self) -> int:
"""
Get the current query timeout setting in seconds.

Returns:
int: The timeout value in seconds. Zero means no timeout (wait indefinitely).
"""
return self._timeout

@timeout.setter
def timeout(self, value: int) -> None:
"""
Set the query timeout for all operations performed by this connection.

Args:
value (int): The timeout value in seconds. Zero means no timeout.

Returns:
None

Note:
This timeout applies to all cursors created from this connection.
It cannot be changed for individual cursors or SQL statements.
If a query timeout occurs, an OperationalError exception will be raised.
"""
if not isinstance(value, int):
raise TypeError("Timeout must be an integer")
if value < 0:
raise ValueError("Timeout cannot be negative")
self._timeout = value
log('info', f"Query timeout set to {value} seconds")

@property
def autocommit(self) -> bool:
"""
Expand Down Expand Up @@ -533,7 +567,7 @@ def cursor(self) -> Cursor:
ddbc_error="Cannot create cursor on closed connection",
)

cursor = Cursor(self)
cursor = Cursor(self, timeout=self._timeout)
self._cursors.add(cursor) # Track the cursor
return cursor

Expand Down
1 change: 1 addition & 0 deletions mssql_python/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class ConstantsDDBC(Enum):
SQL_C_WCHAR = -8
SQL_NULLABLE = 1
SQL_MAX_NUMERIC_LEN = 16
SQL_ATTR_QUERY_TIMEOUT = 2

SQL_FETCH_NEXT = 1
SQL_FETCH_FIRST = 2
Expand Down
31 changes: 30 additions & 1 deletion mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ class Cursor:
setoutputsize(size, column=None) -> None.
"""

def __init__(self, connection) -> None:
def __init__(self, connection, timeout: int = 0) -> None:
"""
Initialize the cursor with a database connection.

Args:
connection: Database connection object.
"""
self._connection = connection # Store as private attribute
self._timeout = timeout
# self.connection.autocommit = False
self.hstmt = None
self._initialize_cursor()
Expand Down Expand Up @@ -778,6 +779,20 @@ def execute(
# Clear any previous messages
self.messages = []

# Apply timeout if set (non-zero)
if self._timeout > 0:
try:
timeout_value = int(self._timeout)
ret = ddbc_bindings.DDBCSQLSetStmtAttr(
self.hstmt,
ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
timeout_value
)
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
log('debug', f"Set query timeout to {timeout_value} seconds")
except Exception as e:
log('warning', f"Failed to set query timeout: {e}")

param_info = ddbc_bindings.ParamInfo
parameters_type = []

Expand Down Expand Up @@ -932,6 +947,20 @@ def executemany(self, operation: str, seq_of_parameters: list) -> None:
if not seq_of_parameters:
self.rowcount = 0
return

# Apply timeout if set (non-zero)
if self._timeout > 0:
try:
timeout_value = int(self._timeout)
ret = ddbc_bindings.DDBCSQLSetStmtAttr(
self.hstmt,
ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
timeout_value
)
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
log('debug', f"Set query timeout to {self._timeout} seconds")
except Exception as e:
log('warning', f"Failed to set query timeout: {e}")

param_info = ddbc_bindings.ParamInfo
param_count = len(seq_of_parameters[0])
Expand Down
4 changes: 2 additions & 2 deletions mssql_python/db_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""
from mssql_python.connection import Connection

def connect(connection_str: str = "", autocommit: bool = False, attrs_before: dict = None, **kwargs) -> Connection:
def connect(connection_str: str = "", autocommit: bool = False, attrs_before: dict = None, timeout: int = 0, **kwargs) -> Connection:
"""
Constructor for creating a connection to the database.

Expand Down Expand Up @@ -33,5 +33,5 @@ def connect(connection_str: str = "", autocommit: bool = False, attrs_before: di
be used to perform database operations such as executing queries, committing
transactions, and closing the connection.
"""
conn = Connection(connection_str, autocommit=autocommit, attrs_before=attrs_before, **kwargs)
conn = Connection(connection_str, autocommit=autocommit, attrs_before=attrs_before, timeout=timeout, **kwargs)
return conn
3 changes: 3 additions & 0 deletions mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3151,6 +3151,9 @@ PYBIND11_MODULE(ddbc_bindings, m) {
m.def("DDBCSQLFetchScroll", &SQLFetchScroll_wrap,
"Scroll to a specific position in the result set and optionally fetch data");
m.def("DDBCSetDecimalSeparator", &DDBCSetDecimalSeparator, "Set the decimal separator character");
m.def("DDBCSQLSetStmtAttr", [](SqlHandlePtr stmt, SQLINTEGER attr, SQLPOINTER value) {
return SQLSetStmtAttr_ptr(stmt->get(), attr, value, 0);
}, "Set statement attributes");


// Add a version attribute
Expand Down
191 changes: 188 additions & 3 deletions tests/test_003_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,50 @@
- test_context_manager_connection_closes: Test that context manager closes the connection.
"""

from mssql_python.exceptions import InterfaceError, ProgrammingError
import mssql_python
import pytest
import time
from mssql_python import connect, Connection, pooling, SQL_CHAR, SQL_WCHAR
from contextlib import closing
import threading
# Import all exception classes for testing
from mssql_python.exceptions import (
Warning,
Error,
InterfaceError,
DatabaseError,
DataError,
OperationalError,
IntegrityError,
InternalError,
ProgrammingError,
NotSupportedError,
)
import struct
from datetime import datetime, timedelta, timezone
from mssql_python.constants import ConstantsDDBC

@pytest.fixture(autouse=True)
def clean_connection_state(db_connection):
"""Ensure connection is in a clean state before each test"""
# Create a cursor and clear any active results
try:
cleanup_cursor = db_connection.cursor()
cleanup_cursor.execute("SELECT 1") # Simple query to reset state
cleanup_cursor.fetchall() # Consume all results
cleanup_cursor.close()
except Exception:
pass # Ignore errors during cleanup

yield # Run the test

# Clean up after the test
try:
cleanup_cursor = db_connection.cursor()
cleanup_cursor.execute("SELECT 1") # Simple query to reset state
cleanup_cursor.fetchall() # Consume all results
cleanup_cursor.close()
except Exception:
pass # Ignore errors during cleanup

# Import all exception classes for testing
from mssql_python.exceptions import (
Expand Down Expand Up @@ -4075,4 +4112,152 @@ def faulty_converter(value):

finally:
# Clean up
db_connection.clear_output_converters()
db_connection.clear_output_converters()

def test_timeout_default(db_connection):
"""Test that the default timeout value is 0 (no timeout)"""
assert hasattr(db_connection, 'timeout'), "Connection should have a timeout attribute"
assert db_connection.timeout == 0, "Default timeout should be 0"

def test_timeout_setter(db_connection):
"""Test setting and getting the timeout value"""
# Set a non-zero timeout
db_connection.timeout = 30
assert db_connection.timeout == 30, "Timeout should be set to 30"

# Test that timeout can be reset to zero
db_connection.timeout = 0
assert db_connection.timeout == 0, "Timeout should be reset to 0"

# Test setting invalid timeout values
with pytest.raises(ValueError):
db_connection.timeout = -1

with pytest.raises(TypeError):
db_connection.timeout = "30"

# Reset timeout to default for other tests
db_connection.timeout = 0

def test_timeout_from_constructor(conn_str):
"""Test setting timeout in the connection constructor"""
# Create a connection with timeout set
conn = connect(conn_str, timeout=45)
try:
assert conn.timeout == 45, "Timeout should be set to 45 from constructor"

# Create a cursor and verify it inherits the timeout
cursor = conn.cursor()
# Execute a quick query to ensure the timeout doesn't interfere
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result[0] == 1, "Query execution should succeed with timeout set"
finally:
# Clean up
conn.close()

def test_timeout_long_query(db_connection):
"""Test that a query exceeding the timeout raises an exception if supported by driver"""
import time
import pytest

cursor = db_connection.cursor()

try:
# First execute a simple query to check if we can run tests
cursor.execute("SELECT 1")
cursor.fetchall()
except Exception as e:
pytest.skip(f"Skipping timeout test due to connection issue: {e}")

# Set a short timeout
original_timeout = db_connection.timeout
db_connection.timeout = 2 # 2 seconds

try:
# Try several different approaches to test timeout
start_time = time.perf_counter()
try:
# Method 1: CPU-intensive query with REPLICATE and large result set
cpu_intensive_query = """
WITH numbers AS (
SELECT TOP 1000000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.objects a CROSS JOIN sys.objects b
)
SELECT COUNT(*) FROM numbers WHERE n % 2 = 0
"""
cursor.execute(cpu_intensive_query)
cursor.fetchall()

elapsed_time = time.perf_counter() - start_time

# If we get here without an exception, try a different approach
if elapsed_time < 4.5:

# Method 2: Try with WAITFOR
start_time = time.perf_counter()
cursor.execute("WAITFOR DELAY '00:00:05'")
cursor.fetchall()
elapsed_time = time.perf_counter() - start_time

# If we still get here, try one more approach
if elapsed_time < 4.5:

# Method 3: Try with a join that generates many rows
start_time = time.perf_counter()
cursor.execute("""
SELECT COUNT(*) FROM sys.objects a, sys.objects b, sys.objects c
WHERE a.object_id = b.object_id * c.object_id
""")
cursor.fetchall()
elapsed_time = time.perf_counter() - start_time

# If we still get here without an exception
if elapsed_time < 4.5:
pytest.skip("Timeout feature not enforced by database driver")

except Exception as e:
# Verify this is a timeout exception
elapsed_time = time.perf_counter() - start_time
assert elapsed_time < 4.5, "Exception occurred but after expected timeout"
error_text = str(e).lower()

# Check for various error messages that might indicate timeout
timeout_indicators = [
"timeout", "timed out", "hyt00", "hyt01", "cancel",
"operation canceled", "execution terminated", "query limit"
]

assert any(indicator in error_text for indicator in timeout_indicators), \
f"Exception occurred but doesn't appear to be a timeout error: {e}"
finally:
# Reset timeout for other tests
db_connection.timeout = original_timeout

def test_timeout_affects_all_cursors(db_connection):
"""Test that changing timeout on connection affects all new cursors"""
# Create a cursor with default timeout
cursor1 = db_connection.cursor()

# Change the connection timeout
original_timeout = db_connection.timeout
db_connection.timeout = 10

# Create a new cursor
cursor2 = db_connection.cursor()

try:
# Execute quick queries to ensure both cursors work
cursor1.execute("SELECT 1")
result1 = cursor1.fetchone()
assert result1[0] == 1, "Query with first cursor failed"

cursor2.execute("SELECT 2")
result2 = cursor2.fetchone()
assert result2[0] == 2, "Query with second cursor failed"

# No direct way to check cursor timeout, but both should succeed
# with the current timeout setting
finally:
# Reset timeout
db_connection.timeout = original_timeout
Loading