Skip to content

Commit aa3a705

Browse files
authored
FEAT: Adding execute in connection class (#189)
### 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#34912](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34912) ------------------------------------------------------------------- ### Summary This pull request introduces a new convenience method, `execute`, to the `Connection` class in `mssql_python/connection.py`, allowing users to execute SQL statements directly on the connection without manually creating a cursor. Comprehensive tests have been added to ensure the correctness, error handling, and versatility of this new method, covering a wide range of scenarios including parameter passing, transaction management, and comparison with traditional cursor usage. **New feature: Connection-level SQL execution** - Added a new `execute` method to the `Connection` class, which creates a new cursor, executes the provided SQL statement (with optional parameters), and returns the cursor. This simplifies executing single SQL statements without explicitly managing cursors. **Testing and validation for the new method** - Introduced multiple test cases in `tests/test_003_connection.py` to verify the behavior of the new `execute` method, including: - Basic execution and parameterized queries - Proper cursor tracking and resource management - Error handling for invalid SQL statements - Handling of empty result sets - Support for various parameter data types (integer, float, string, binary, boolean, NULL) - Transactional behavior, including commit and rollback scenarios - Comparison between `connection.execute` and `cursor.execute` usage patterns - Execution with a large number of parameters --------- Co-authored-by: Jahnvi Thakkar <[email protected]>
1 parent 2b7f886 commit aa3a705

File tree

5 files changed

+2210
-96
lines changed

5 files changed

+2210
-96
lines changed

mssql_python/connection.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import weakref
1414
import re
1515
import codecs
16+
from typing import Any
1617
from mssql_python.cursor import Cursor
1718
from mssql_python.helpers import add_driver_to_connection_str, sanitize_connection_string, sanitize_user_input, log
1819
from mssql_python import ddbc_bindings
@@ -531,6 +532,174 @@ def cursor(self) -> Cursor:
531532
self._cursors.add(cursor) # Track the cursor
532533
return cursor
533534

535+
def execute(self, sql: str, *args: Any) -> Cursor:
536+
"""
537+
Creates a new Cursor object, calls its execute method, and returns the new cursor.
538+
539+
This is a convenience method that is not part of the DB API. Since a new Cursor
540+
is allocated by each call, this should not be used if more than one SQL statement
541+
needs to be executed on the connection.
542+
543+
Note on cursor lifecycle management:
544+
- Each call creates a new cursor that is tracked by the connection's internal WeakSet
545+
- Cursors are automatically dereferenced/closed when they go out of scope
546+
- For long-running applications or loops, explicitly call cursor.close() when done
547+
to release resources immediately rather than waiting for garbage collection
548+
549+
Args:
550+
sql (str): The SQL query to execute.
551+
*args: Parameters to be passed to the query.
552+
553+
Returns:
554+
Cursor: A new cursor with the executed query.
555+
556+
Raises:
557+
DatabaseError: If there is an error executing the query.
558+
InterfaceError: If the connection is closed.
559+
560+
Example:
561+
# Automatic cleanup (cursor goes out of scope after the operation)
562+
row = connection.execute("SELECT name FROM users WHERE id = ?", 123).fetchone()
563+
564+
# Manual cleanup for more explicit resource management
565+
cursor = connection.execute("SELECT * FROM large_table")
566+
try:
567+
# Use cursor...
568+
rows = cursor.fetchall()
569+
finally:
570+
cursor.close() # Explicitly release resources
571+
"""
572+
cursor = self.cursor()
573+
try:
574+
# Add the cursor to our tracking set BEFORE execution
575+
# This ensures it's tracked even if execution fails
576+
self._cursors.add(cursor)
577+
578+
# Now execute the query
579+
cursor.execute(sql, *args)
580+
return cursor
581+
except Exception:
582+
# If execution fails, close the cursor to avoid leaking resources
583+
cursor.close()
584+
raise
585+
586+
def batch_execute(self, statements, params=None, reuse_cursor=None, auto_close=False):
587+
"""
588+
Execute multiple SQL statements efficiently using a single cursor.
589+
590+
This method allows executing multiple SQL statements in sequence using a single
591+
cursor, which is more efficient than creating a new cursor for each statement.
592+
593+
Args:
594+
statements (list): List of SQL statements to execute
595+
params (list, optional): List of parameter sets corresponding to statements.
596+
Each item can be None, a single parameter, or a sequence of parameters.
597+
If None, no parameters will be used for any statement.
598+
reuse_cursor (Cursor, optional): Existing cursor to reuse instead of creating a new one.
599+
If None, a new cursor will be created.
600+
auto_close (bool): Whether to close the cursor after execution if a new one was created.
601+
Defaults to False. Has no effect if reuse_cursor is provided.
602+
603+
Returns:
604+
tuple: (results, cursor) where:
605+
- results is a list of execution results, one for each statement
606+
- cursor is the cursor used for execution (useful if you want to keep using it)
607+
608+
Raises:
609+
TypeError: If statements is not a list or if params is provided but not a list
610+
ValueError: If params is provided but has different length than statements
611+
DatabaseError: If there is an error executing any of the statements
612+
InterfaceError: If the connection is closed
613+
614+
Example:
615+
# Execute multiple statements with a single cursor
616+
results, _ = conn.batch_execute([
617+
"INSERT INTO users VALUES (?, ?)",
618+
"UPDATE stats SET count = count + 1",
619+
"SELECT * FROM users"
620+
], [
621+
(1, "user1"),
622+
None,
623+
None
624+
])
625+
626+
# Last result contains the SELECT results
627+
for row in results[-1]:
628+
print(row)
629+
630+
# Reuse an existing cursor
631+
my_cursor = conn.cursor()
632+
results, _ = conn.batch_execute([
633+
"SELECT * FROM table1",
634+
"SELECT * FROM table2"
635+
], reuse_cursor=my_cursor)
636+
637+
# Cursor remains open for further use
638+
my_cursor.execute("SELECT * FROM table3")
639+
"""
640+
# Validate inputs
641+
if not isinstance(statements, list):
642+
raise TypeError("statements must be a list of SQL statements")
643+
644+
if params is not None:
645+
if not isinstance(params, list):
646+
raise TypeError("params must be a list of parameter sets")
647+
if len(params) != len(statements):
648+
raise ValueError("params list must have the same length as statements list")
649+
else:
650+
# Create a list of None values with the same length as statements
651+
params = [None] * len(statements)
652+
653+
# Determine which cursor to use
654+
is_new_cursor = reuse_cursor is None
655+
cursor = self.cursor() if is_new_cursor else reuse_cursor
656+
657+
# Execute statements and collect results
658+
results = []
659+
try:
660+
for i, (stmt, param) in enumerate(zip(statements, params)):
661+
try:
662+
# Execute the statement with parameters if provided
663+
if param is not None:
664+
cursor.execute(stmt, param)
665+
else:
666+
cursor.execute(stmt)
667+
668+
# For SELECT statements, fetch all rows
669+
# For other statements, get the row count
670+
if cursor.description is not None:
671+
# This is a SELECT statement or similar that returns rows
672+
results.append(cursor.fetchall())
673+
else:
674+
# This is an INSERT, UPDATE, DELETE or similar that doesn't return rows
675+
results.append(cursor.rowcount)
676+
677+
log('debug', f"Executed batch statement {i+1}/{len(statements)}")
678+
679+
except Exception as e:
680+
# If a statement fails, include statement context in the error
681+
log('error', f"Error executing statement {i+1}/{len(statements)}: {e}")
682+
raise
683+
684+
except Exception as e:
685+
# If an error occurs and auto_close is True, close the cursor
686+
if auto_close:
687+
try:
688+
# Close the cursor regardless of whether it's reused or new
689+
cursor.close()
690+
log('debug', "Automatically closed cursor after batch execution error")
691+
except Exception as close_err:
692+
log('warning', f"Error closing cursor after execution failure: {close_err}")
693+
# Re-raise the original exception
694+
raise
695+
696+
# Close the cursor if requested and we created a new one
697+
if is_new_cursor and auto_close:
698+
cursor.close()
699+
log('debug', "Automatically closed cursor after batch execution")
700+
701+
return results, cursor
702+
534703
def commit(self) -> None:
535704
"""
536705
Commit the current transaction.
@@ -541,8 +710,16 @@ def commit(self) -> None:
541710
that the changes are saved.
542711
543712
Raises:
713+
InterfaceError: If the connection is closed.
544714
DatabaseError: If there is an error while committing the transaction.
545715
"""
716+
# Check if connection is closed
717+
if self._closed or self._conn is None:
718+
raise InterfaceError(
719+
driver_error="Cannot commit on a closed connection",
720+
ddbc_error="Cannot commit on a closed connection",
721+
)
722+
546723
# Commit the current transaction
547724
self._conn.commit()
548725
log('info', "Transaction committed successfully.")
@@ -556,8 +733,16 @@ def rollback(self) -> None:
556733
transaction or if the changes should not be saved.
557734
558735
Raises:
736+
InterfaceError: If the connection is closed.
559737
DatabaseError: If there is an error while rolling back the transaction.
560738
"""
739+
# Check if connection is closed
740+
if self._closed or self._conn is None:
741+
raise InterfaceError(
742+
driver_error="Cannot rollback on a closed connection",
743+
ddbc_error="Cannot rollback on a closed connection",
744+
)
745+
561746
# Roll back the current transaction
562747
self._conn.rollback()
563748
log('info', "Transaction rolled back successfully.")
@@ -623,6 +808,21 @@ def close(self) -> None:
623808
self._closed = True
624809

625810
log('info', "Connection closed successfully.")
811+
812+
def _remove_cursor(self, cursor):
813+
"""
814+
Remove a cursor from the connection's tracking.
815+
816+
This method is called when a cursor is closed to ensure proper cleanup.
817+
818+
Args:
819+
cursor: The cursor to remove from tracking.
820+
"""
821+
if hasattr(self, '_cursors'):
822+
try:
823+
self._cursors.discard(cursor)
824+
except Exception:
825+
pass # Ignore errors during cleanup
626826

627827
def __enter__(self) -> 'Connection':
628828
"""

mssql_python/cursor.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,13 @@ def close(self) -> None:
501501
# Clear messages per DBAPI
502502
self.messages = []
503503

504+
# Remove this cursor from the connection's tracking
505+
if hasattr(self, 'connection') and self.connection and hasattr(self.connection, '_cursors'):
506+
try:
507+
self.connection._cursors.discard(self)
508+
except Exception as e:
509+
log('warning', "Error removing cursor from connection tracking: %s", e)
510+
504511
if self.hstmt:
505512
self.hstmt.free()
506513
self.hstmt = None

tests/test_001_globals.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ def test_lowercase():
3232
# Check if lowercase has the expected default value
3333
assert lowercase is False, "lowercase should default to False"
3434

35+
def test_decimal_separator():
36+
"""Test decimal separator functionality"""
37+
38+
# Check default value
39+
assert getDecimalSeparator() == '.', "Default decimal separator should be '.'"
40+
41+
try:
42+
# Test setting a new value
43+
setDecimalSeparator(',')
44+
assert getDecimalSeparator() == ',', "Decimal separator should be ',' after setting"
45+
46+
# Test invalid input
47+
with pytest.raises(ValueError):
48+
setDecimalSeparator('too long')
49+
50+
with pytest.raises(ValueError):
51+
setDecimalSeparator('')
52+
53+
with pytest.raises(ValueError):
54+
setDecimalSeparator(123) # Non-string input
55+
56+
finally:
57+
# Restore default value
58+
setDecimalSeparator('.')
59+
assert getDecimalSeparator() == '.', "Decimal separator should be restored to '.'"
60+
3561
def test_lowercase_thread_safety_no_db():
3662
"""
3763
Tests concurrent modifications to mssql_python.lowercase without database interaction.
@@ -152,32 +178,6 @@ def test_lowercase():
152178
# Check if lowercase has the expected default value
153179
assert lowercase is False, "lowercase should default to False"
154180

155-
def test_decimal_separator():
156-
"""Test decimal separator functionality"""
157-
158-
# Check default value
159-
assert getDecimalSeparator() == '.', "Default decimal separator should be '.'"
160-
161-
try:
162-
# Test setting a new value
163-
setDecimalSeparator(',')
164-
assert getDecimalSeparator() == ',', "Decimal separator should be ',' after setting"
165-
166-
# Test invalid input
167-
with pytest.raises(ValueError):
168-
setDecimalSeparator('too long')
169-
170-
with pytest.raises(ValueError):
171-
setDecimalSeparator('')
172-
173-
with pytest.raises(ValueError):
174-
setDecimalSeparator(123) # Non-string input
175-
176-
finally:
177-
# Restore default value
178-
setDecimalSeparator('.')
179-
assert getDecimalSeparator() == '.', "Decimal separator should be restored to '.'"
180-
181181
def test_decimal_separator_edge_cases():
182182
"""Test decimal separator edge cases and boundary conditions"""
183183
import decimal

0 commit comments

Comments
 (0)