@@ -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