Skip to content

Commit 2d9faf7

Browse files
committed
Copilot review comment
1 parent 4ca4969 commit 2d9faf7

1 file changed

Lines changed: 123 additions & 27 deletions

File tree

‎mssql_python/cursor.py‎

Lines changed: 123 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2455,28 +2455,97 @@ def _bulkcopy(self, table_name: str, data, **kwargs):
24552455
"""
24562456
Perform bulk copy operation using Rust-based implementation.
24572457
2458+
This method uses a separate connection to the database via the Rust library
2459+
(mssql_py_core) for optimized bulk data transfer. The connection parameters
2460+
are extracted from the current Python connection's connection string.
2461+
2462+
Important: Transaction Isolation
2463+
The bulk copy operation creates its own connection and does NOT participate
2464+
in the current Python connection's transaction context. This means:
2465+
- Bulk copied data is committed independently of any open transaction
2466+
- Rolling back the Python connection will NOT rollback bulk copied data
2467+
- The bulk copy operation is essentially auto-committed
2468+
2469+
If you need transactional bulk operations, consider using executemany()
2470+
or batched INSERT statements within your transaction instead.
2471+
24582472
Args:
2459-
table_name: Target table name
2460-
data: Iterable of tuples/lists containing row data
2473+
table_name: Target table name (can include schema, e.g., 'dbo.MyTable').
2474+
The table must exist and the user must have INSERT permissions.
2475+
2476+
data: Iterable of tuples or lists containing row data to be inserted.
2477+
2478+
Data Format Requirements:
2479+
- Each element in the iterable represents one row
2480+
- Each row should be a tuple or list of column values
2481+
- Column order must match the target table's column order (by ordinal
2482+
position), unless column_mappings is specified
2483+
- The number of values in each row must match the number of columns
2484+
in the target table
2485+
2486+
Supported Python Types and SQL Server Mappings:
2487+
- None → NULL (for any nullable column)
2488+
- int → INT, BIGINT, SMALLINT, TINYINT
2489+
- float → FLOAT, REAL
2490+
- str → VARCHAR, NVARCHAR, CHAR, NCHAR, TEXT, NTEXT
2491+
- bool → BIT
2492+
- datetime.date → DATE
2493+
- datetime.datetime → DATETIME, DATETIME2, SMALLDATETIME
2494+
- datetime.time → TIME
2495+
- decimal.Decimal → DECIMAL, NUMERIC, MONEY, SMALLMONEY
2496+
- bytes → BINARY, VARBINARY, IMAGE
2497+
- uuid.UUID → UNIQUEIDENTIFIER
2498+
2499+
NULL Values:
2500+
- Use Python's None to represent SQL NULL values
2501+
- Empty strings ('') are NOT treated as NULL
2502+
2503+
Example data formats:
2504+
# Simple list of tuples
2505+
data = [(1, 'Alice', None), (2, 'Bob', 25)]
2506+
2507+
# Generator for memory-efficient large datasets
2508+
def generate_data():
2509+
for i in range(1000000):
2510+
yield (i, f'Name_{i}', datetime.date.today())
2511+
24612512
**kwargs: Additional options passed to the Rust bulkcopy method
2462-
- batch_size: Number of rows per batch (default: 1000)
2463-
- timeout: Timeout in seconds (default: 30)
2464-
- column_mappings: List of tuples mapping source column index to target column name
2513+
- column_mappings: List of tuples mapping source column index to
2514+
target column name, e.g., [(0, 'id'), (1, 'name')]
24652515
24662516
Returns:
2467-
Dictionary with rows_copied, batch_count, and elapsed_time
2517+
Dictionary with bulk copy results including:
2518+
- rows_copied: Number of rows successfully copied
2519+
- batch_count: Number of batches processed
2520+
- elapsed_time: Time taken for the operation
24682521
24692522
Raises:
2470-
ImportError: If mssql_py_core is not installed
2471-
ValueError: If parameters are invalid
2523+
ImportError: If mssql_py_core Rust library is not installed
2524+
ValueError: If table_name is empty or parameters are invalid
24722525
RuntimeError: If connection string is not available
2526+
2527+
Example:
2528+
>>> # Basic usage with list of tuples
2529+
>>> data = [(1, 'Alice', None), (2, 'Bob', 25), (3, 'Charlie', 30)]
2530+
>>> result = cursor._bulkcopy('users', data)
2531+
>>> print(f"Copied {result['rows_copied']} rows")
2532+
2533+
>>> # Using a generator for large datasets
2534+
>>> from decimal import Decimal
2535+
>>> from datetime import date
2536+
>>> def generate_orders():
2537+
... for i in range(10000):
2538+
... yield (i, Decimal('99.99'), date.today(), None)
2539+
>>> result = cursor._bulkcopy('orders', generate_orders())
24732540
"""
24742541
try:
24752542
import mssql_py_core
24762543
except ImportError as exc:
24772544
raise ImportError(
2478-
"Bulk copy requires mssql_py_core Rust library. "
2479-
"Install from BCPRustWheel directory."
2545+
"Bulk copy requires the mssql_py_core Rust library which is not installed. "
2546+
"To install, run: pip install mssql_py_core "
2547+
"or install from the wheel file in the BCPRustWheel directory of the mssql-python repository: "
2548+
"pip install BCPRustWheel/mssql_py_core-<version>-<platform>.whl"
24802549
) from exc
24812550

24822551
# Validate inputs
@@ -2496,53 +2565,80 @@ def _bulkcopy(self, table_name: str, data, **kwargs):
24962565
if not hasattr(self.connection, "connection_str"):
24972566
raise RuntimeError("Connection string not available for bulk copy")
24982567

2499-
params = {
2500-
k.strip().lower(): v.strip()
2501-
for pair in self.connection.connection_str.split(";")
2502-
if "=" in pair
2503-
for k, v in [pair.split("=", 1)]
2504-
}
2568+
# Use the proper connection string parser that handles braced values
2569+
from mssql_python.connection_string_parser import _ConnectionStringParser
2570+
parser = _ConnectionStringParser(validate_keywords=False)
2571+
params = parser._parse(self.connection.connection_str)
25052572

25062573
if not params.get("server"):
25072574
raise ValueError("SERVER parameter is required in connection string")
25082575

25092576
# Build connection context for Rust library
2577+
# Note: Password is extracted separately to avoid storing it in the main context
2578+
# dict that could be accidentally logged or exposed in error messages.
25102579
trust_cert = params.get("trustservercertificate", "yes").lower() in ("yes", "true")
2580+
2581+
# Parse encryption setting from connection string
2582+
encrypt_param = params.get("encrypt")
2583+
if encrypt_param is not None:
2584+
encrypt_value = encrypt_param.strip().lower()
2585+
if encrypt_value in ("yes", "true", "mandatory", "required"):
2586+
encryption = "Required"
2587+
elif encrypt_value in ("no", "false", "optional"):
2588+
encryption = "Optional"
2589+
else:
2590+
# Pass through unrecognized values (e.g., "Strict") to the underlying driver
2591+
encryption = encrypt_param
2592+
else:
2593+
encryption = "Optional"
2594+
25112595
context = {
25122596
"server": params.get("server", "localhost"),
25132597
"database": params.get("database", "master"),
25142598
"user_name": params.get("uid", ""),
2515-
"password": params.get("pwd", ""),
25162599
"trust_server_certificate": trust_cert,
2517-
"encryption": "Optional",
2600+
"encryption": encryption,
25182601
}
25192602

2520-
logger.debug("Bulk copy connecting to %s/%s", context["server"], context["database"])
2603+
# Extract password separately to avoid storing it in generic context that may be logged
2604+
password = params.get("pwd", "")
2605+
rust_context = dict(context)
2606+
rust_context["password"] = password
25212607

25222608
rust_connection = None
25232609
rust_cursor = None
25242610
try:
2525-
rust_connection = mssql_py_core.PyCoreConnection(context)
2611+
rust_connection = mssql_py_core.PyCoreConnection(rust_context)
25262612
rust_cursor = rust_connection.cursor()
25272613

2528-
logger.debug("Bulk copy to '%s' - batch_size=%d", table_name, batch_size)
2529-
result = rust_cursor.bulkcopy(table_name, iter(data), kwargs=kwargs)
2614+
result = rust_cursor.bulkcopy(table_name, iter(data), **kwargs)
25302615

2531-
logger.debug("Bulk copy completed - rows=%d", result.get("rows_copied", 0))
25322616
return result
25332617

25342618
except Exception as e:
2535-
logger.error("Bulk copy failed: %s - %s", type(e).__name__, str(e))
2536-
raise
2619+
# Re-raise without exposing connection context in the error chain
2620+
# to prevent credential leakage in stack traces
2621+
raise type(e)(str(e)) from None
25372622

25382623
finally:
2624+
# Clear sensitive data to minimize memory exposure
2625+
password = ""
2626+
if rust_context:
2627+
rust_context["password"] = ""
2628+
rust_context["user_name"] = ""
25392629
# Clean up Rust resources
25402630
for resource in (rust_cursor, rust_connection):
25412631
if resource and hasattr(resource, "close"):
25422632
try:
25432633
resource.close()
2544-
except Exception:
2545-
pass
2634+
except Exception as cleanup_error:
2635+
# Log cleanup errors at debug level to aid troubleshooting
2636+
# without masking the original exception
2637+
logger.debug(
2638+
"Failed to close bulk copy resource %s: %s",
2639+
type(resource).__name__,
2640+
cleanup_error
2641+
)
25462642

25472643
def __enter__(self):
25482644
"""

0 commit comments

Comments
 (0)