mssql-python 1.16.0__cp315-cp315-win_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation.
3
+ Licensed under the MIT license.
4
+
5
+ Parameter style conversion helpers for mssql-python.
6
+
7
+ Supports both qmark (?) and pyformat (%(name)s) parameter styles.
8
+ Includes context-aware scanning for qmark and pyformat detection,
9
+ skipping characters inside bracketed identifiers, string literals,
10
+ quoted identifiers, and SQL comments.
11
+
12
+ Reference: https://www.python.org/dev/peps/pep-0249/#paramstyle
13
+ """
14
+
15
+ from typing import Dict, List, Tuple, Any, Union
16
+ from mssql_python.logging import logger
17
+
18
+ # Distinctive marker for escaped percent signs during pyformat conversion
19
+ # Uses a unique prefix/suffix that's extremely unlikely to appear in real SQL
20
+ _ESCAPED_PERCENT_MARKER = "__MSSQL_PYFORMAT_ESCAPED_PERCENT_PLACEHOLDER__"
21
+
22
+
23
+ def _skip_quoted_context(sql: str, i: int, length: int) -> int:
24
+ """
25
+ If position i starts a SQL quoted context, skip past it and return the new position.
26
+ Returns -1 if no quoted context starts at position i.
27
+
28
+ Handles:
29
+ - Single-line comments: -- ... (to end of line)
30
+ - Multi-line comments: /* ... */ (to closing delimiter)
31
+ - Single-quoted string literals: '...' (with '' escape handling)
32
+ - Double-quoted identifiers: "..."
33
+ - Bracketed identifiers: [...]
34
+
35
+ Args:
36
+ sql: Full SQL query string
37
+ i: Current scan position
38
+ length: Length of sql (len(sql))
39
+
40
+ Returns:
41
+ New position after the quoted context, or -1 if position i
42
+ does not start a quoted context.
43
+ """
44
+ ch = sql[i]
45
+
46
+ # Single-line comment: skip to end of line
47
+ if ch == "-" and i + 1 < length and sql[i + 1] == "-":
48
+ i += 2
49
+ while i < length and sql[i] != "\n":
50
+ i += 1
51
+ return i
52
+
53
+ # Multi-line comment: skip to closing */
54
+ # SQL Server supports nested block comments, so we track nesting depth.
55
+ if ch == "/" and i + 1 < length and sql[i + 1] == "*":
56
+ i += 2
57
+ depth = 1
58
+ while i < length and depth > 0:
59
+ if i + 1 < length and sql[i] == "/" and sql[i + 1] == "*":
60
+ depth += 1
61
+ i += 2
62
+ elif i + 1 < length and sql[i] == "*" and sql[i + 1] == "/":
63
+ depth -= 1
64
+ i += 2
65
+ else:
66
+ i += 1
67
+ return min(i, length) # already past final */, or at end if unterminated
68
+
69
+ # Single-quoted string literal: skip to closing '
70
+ # Handles escaped quotes ('') inside strings
71
+ if ch == "'":
72
+ i += 1
73
+ while i < length:
74
+ if sql[i] == "'":
75
+ if i + 1 < length and sql[i + 1] == "'":
76
+ i += 2 # skip escaped quote
77
+ continue
78
+ break
79
+ i += 1
80
+ return min(i + 1, length) # skip closing quote
81
+
82
+ # Double-quoted identifier: skip to closing "
83
+ # Handles escaped quotes ("") inside identifiers
84
+ if ch == '"':
85
+ i += 1
86
+ while i < length:
87
+ if sql[i] == '"':
88
+ if i + 1 < length and sql[i + 1] == '"':
89
+ i += 2 # skip escaped quote
90
+ continue
91
+ break
92
+ i += 1
93
+ return min(i + 1, length) # skip closing quote
94
+
95
+ # Bracketed identifier: skip to closing ]
96
+ # Handles escaped brackets (]]) inside identifiers
97
+ if ch == "[":
98
+ i += 1
99
+ while i < length:
100
+ if sql[i] == "]":
101
+ if i + 1 < length and sql[i + 1] == "]":
102
+ i += 2 # skip escaped bracket
103
+ continue
104
+ break
105
+ i += 1
106
+ return min(i + 1, length) # skip closing bracket
107
+
108
+ return -1
109
+
110
+
111
+ def _has_unquoted_question_marks(sql: str) -> bool:
112
+ """
113
+ Check if SQL contains ? characters that are actual qmark parameter placeholders.
114
+
115
+ Uses _skip_quoted_context to skip ? characters that appear inside
116
+ bracketed identifiers, string literals, quoted identifiers, and comments.
117
+
118
+ Args:
119
+ sql: SQL query string to check
120
+
121
+ Returns:
122
+ True if the SQL contains at least one unquoted/unbracketed ? character
123
+
124
+ Examples:
125
+ >>> _has_unquoted_question_marks("SELECT * FROM t WHERE id = ?")
126
+ True
127
+
128
+ >>> _has_unquoted_question_marks("SELECT [q?marks] FROM t")
129
+ False
130
+
131
+ >>> _has_unquoted_question_marks("SELECT 'what?' FROM t")
132
+ False
133
+ """
134
+ i = 0
135
+ length = len(sql)
136
+
137
+ while i < length:
138
+ # Skip any quoted context (brackets, strings, comments)
139
+ skipped = _skip_quoted_context(sql, i, length)
140
+ if skipped >= 0:
141
+ i = skipped
142
+ continue
143
+
144
+ # Unquoted question mark — this is a real placeholder
145
+ if sql[i] == "?":
146
+ return True
147
+
148
+ i += 1
149
+
150
+ return False
151
+
152
+
153
+ def parse_pyformat_params(sql: str) -> List[str]:
154
+ """
155
+ Extract %(name)s parameter names from SQL string.
156
+
157
+ Uses context-aware scanning to skip %(name)s patterns inside SQL
158
+ string literals, quoted identifiers, bracketed identifiers, and comments.
159
+ Only %(name)s patterns in executable SQL are detected as parameters.
160
+
161
+ Args:
162
+ sql: SQL query string with %(name)s placeholders
163
+
164
+ Returns:
165
+ List of parameter names in order of appearance (with duplicates if reused)
166
+
167
+ Examples:
168
+ >>> parse_pyformat_params("SELECT * FROM users WHERE id = %(id)s")
169
+ ['id']
170
+
171
+ >>> parse_pyformat_params("WHERE name = %(name)s OR email = %(name)s")
172
+ ['name', 'name']
173
+
174
+ >>> parse_pyformat_params("SELECT * FROM %(table)s WHERE id = %(id)s")
175
+ ['table', 'id']
176
+ """
177
+ logger.debug(
178
+ "parse_pyformat_params: Starting parse - sql_length=%d, sql_preview=%s",
179
+ len(sql),
180
+ sql[:100] if len(sql) > 100 else sql,
181
+ )
182
+ params = []
183
+ i = 0
184
+ length = len(sql)
185
+
186
+ while i < length:
187
+ # Skip any quoted context (brackets, strings, comments)
188
+ skipped = _skip_quoted_context(sql, i, length)
189
+ if skipped >= 0:
190
+ i = skipped
191
+ continue
192
+
193
+ # Look for %(
194
+ if i + 2 < length and sql[i] == "%" and sql[i + 1] == "(":
195
+ # Find the closing )
196
+ j = i + 2
197
+ while j < length and sql[j] != ")":
198
+ j += 1
199
+
200
+ # Check if we found ) and it's followed by 's'
201
+ if j < length and sql[j] == ")":
202
+ if j + 1 < length and sql[j + 1] == "s":
203
+ # Extract parameter name
204
+ param_name = sql[i + 2 : j]
205
+ params.append(param_name)
206
+ logger.debug(
207
+ "parse_pyformat_params: Found parameter '%s' at position %d",
208
+ param_name,
209
+ i,
210
+ )
211
+ i = j + 2
212
+ continue
213
+
214
+ i += 1
215
+
216
+ logger.debug(
217
+ "parse_pyformat_params: Completed - found %d parameters: %s",
218
+ len(params),
219
+ params,
220
+ )
221
+ return params
222
+
223
+
224
+ def convert_pyformat_to_qmark(sql: str, param_dict: Dict[str, Any]) -> Tuple[str, Tuple[Any, ...]]:
225
+ """
226
+ Convert pyformat-style query to qmark-style for ODBC execution.
227
+
228
+ Validates that all required parameters are present and builds a positional
229
+ parameter tuple. Supports parameter reuse (same parameter appearing multiple times).
230
+
231
+ Args:
232
+ sql: SQL query with %(name)s placeholders
233
+ param_dict: Dictionary of parameter values
234
+
235
+ Returns:
236
+ Tuple of (rewritten_sql_with_?, positional_params_tuple)
237
+
238
+ Raises:
239
+ KeyError: If required parameter is missing from param_dict
240
+
241
+ Examples:
242
+ >>> convert_pyformat_to_qmark(
243
+ ... "SELECT * FROM users WHERE id = %(id)s",
244
+ ... {"id": 42}
245
+ ... )
246
+ ("SELECT * FROM users WHERE id = ?", (42,))
247
+
248
+ >>> convert_pyformat_to_qmark(
249
+ ... "WHERE name = %(name)s OR email = %(name)s",
250
+ ... {"name": "alice"}
251
+ ... )
252
+ ("WHERE name = ? OR email = ?", ("alice", "alice"))
253
+ """
254
+ logger.debug(
255
+ "convert_pyformat_to_qmark: Starting conversion - sql_length=%d, param_count=%d",
256
+ len(sql),
257
+ len(param_dict),
258
+ )
259
+ logger.debug(
260
+ "convert_pyformat_to_qmark: SQL preview: %s",
261
+ sql[:200] if len(sql) > 200 else sql,
262
+ )
263
+ logger.debug(
264
+ "convert_pyformat_to_qmark: Parameters provided: %s",
265
+ list(param_dict.keys()),
266
+ )
267
+
268
+ # Support %% escaping - replace %% with a placeholder before parsing
269
+ # This allows users to have literal % in their SQL
270
+ escaped_sql = sql.replace("%%", _ESCAPED_PERCENT_MARKER)
271
+
272
+ if "%%" in sql:
273
+ logger.debug(
274
+ "convert_pyformat_to_qmark: Detected %d escaped percent sequences (%%%%)",
275
+ sql.count("%%"),
276
+ )
277
+
278
+ # Extract parameter names in order
279
+ param_names = parse_pyformat_params(escaped_sql)
280
+
281
+ if not param_names:
282
+ logger.debug(
283
+ "convert_pyformat_to_qmark: No pyformat parameters found - returning SQL as-is"
284
+ )
285
+ # No parameters found - restore escaped %% and return as-is
286
+ restored_sql = escaped_sql.replace(_ESCAPED_PERCENT_MARKER, "%")
287
+ return restored_sql, ()
288
+
289
+ logger.debug(
290
+ "convert_pyformat_to_qmark: Extracted %d parameter references (with duplicates): %s",
291
+ len(param_names),
292
+ param_names,
293
+ )
294
+ logger.debug(
295
+ "convert_pyformat_to_qmark: Unique parameters needed: %s",
296
+ sorted(set(param_names)),
297
+ )
298
+
299
+ # Validate all required parameters are present
300
+ missing = set(param_names) - set(param_dict.keys())
301
+ if missing:
302
+ # Provide helpful error message
303
+ missing_list = sorted(missing)
304
+ required_list = sorted(set(param_names))
305
+ provided_list = sorted(param_dict.keys())
306
+
307
+ logger.error(
308
+ "convert_pyformat_to_qmark: Missing parameters - required=%s, provided=%s, missing=%s",
309
+ required_list,
310
+ provided_list,
311
+ missing_list,
312
+ )
313
+
314
+ error_msg = (
315
+ f"Missing required parameter(s): {', '.join(repr(p) for p in missing_list)}. "
316
+ f"Query requires: {required_list}, provided: {provided_list}"
317
+ )
318
+ raise KeyError(error_msg)
319
+
320
+ # Build positional parameter tuple (with duplicates if param reused)
321
+ positional_params = tuple(param_dict[name] for name in param_names)
322
+
323
+ logger.debug(
324
+ "convert_pyformat_to_qmark: Built positional params tuple - length=%d",
325
+ len(positional_params),
326
+ )
327
+
328
+ # Replace %(name)s with ? using simple string replacement
329
+ # We replace each unique parameter name to avoid issues with overlapping names
330
+ rewritten_sql = escaped_sql
331
+ unique_params = set(param_names)
332
+ logger.debug(
333
+ "convert_pyformat_to_qmark: Replacing %d unique parameter placeholders with ?",
334
+ len(unique_params),
335
+ )
336
+
337
+ for param_name in unique_params: # Use set to avoid duplicate replacements
338
+ pattern = f"%({param_name})s"
339
+ occurrences = rewritten_sql.count(pattern)
340
+ rewritten_sql = rewritten_sql.replace(pattern, "?")
341
+ logger.debug(
342
+ "convert_pyformat_to_qmark: Replaced parameter '%s' (%d occurrences)",
343
+ param_name,
344
+ occurrences,
345
+ )
346
+
347
+ # Restore escaped %% back to %
348
+ if _ESCAPED_PERCENT_MARKER in rewritten_sql:
349
+ marker_count = rewritten_sql.count(_ESCAPED_PERCENT_MARKER)
350
+ rewritten_sql = rewritten_sql.replace(_ESCAPED_PERCENT_MARKER, "%")
351
+ logger.debug(
352
+ "convert_pyformat_to_qmark: Restored %d escaped percent markers to %%",
353
+ marker_count,
354
+ )
355
+
356
+ logger.debug(
357
+ "convert_pyformat_to_qmark: Conversion complete - result_sql_length=%d, param_count=%d",
358
+ len(rewritten_sql),
359
+ len(positional_params),
360
+ )
361
+ logger.debug(
362
+ "convert_pyformat_to_qmark: Result SQL preview: %s",
363
+ rewritten_sql[:200] if len(rewritten_sql) > 200 else rewritten_sql,
364
+ )
365
+
366
+ logger.debug(
367
+ "Converted pyformat to qmark: params=%s, positional=%s",
368
+ list(param_dict.keys()),
369
+ positional_params,
370
+ )
371
+
372
+ return rewritten_sql, positional_params
373
+
374
+
375
+ def detect_and_convert_parameters(
376
+ sql: str, parameters: Union[None, Tuple, List, Dict]
377
+ ) -> Tuple[str, Union[None, Tuple, List]]:
378
+ """
379
+ Auto-detect parameter style and convert to qmark if needed.
380
+
381
+ Detects parameter style based on the type of parameters:
382
+ - None: No parameters
383
+ - Tuple/List: qmark style (?) - pass through unchanged
384
+ - Dict: pyformat style (%(name)s) - convert to qmark
385
+
386
+ Args:
387
+ sql: SQL query string
388
+ parameters: Parameters in any supported format
389
+
390
+ Returns:
391
+ Tuple of (sql, parameters) where parameters are in qmark format
392
+
393
+ Raises:
394
+ TypeError: If parameters type doesn't match placeholders in SQL
395
+ KeyError: If required pyformat parameter is missing
396
+
397
+ Examples:
398
+ >>> detect_and_convert_parameters(
399
+ ... "SELECT * FROM users WHERE id = ?",
400
+ ... (42,)
401
+ ... )
402
+ ("SELECT * FROM users WHERE id = ?", (42,))
403
+
404
+ >>> detect_and_convert_parameters(
405
+ ... "SELECT * FROM users WHERE id = %(id)s",
406
+ ... {"id": 42}
407
+ ... )
408
+ ("SELECT * FROM users WHERE id = ?", (42,))
409
+ """
410
+ logger.debug(
411
+ "detect_and_convert_parameters: Starting - sql_length=%d, parameters_type=%s",
412
+ len(sql),
413
+ type(parameters).__name__ if parameters is not None else "None",
414
+ )
415
+
416
+ # No parameters
417
+ if parameters is None:
418
+ logger.debug("detect_and_convert_parameters: No parameters provided - returning as-is")
419
+ return sql, None
420
+
421
+ # Qmark style - tuple or list
422
+ if isinstance(parameters, (tuple, list)):
423
+ logger.debug(
424
+ "detect_and_convert_parameters: Detected qmark-style parameters (%s) - count=%d",
425
+ type(parameters).__name__,
426
+ len(parameters),
427
+ )
428
+
429
+ # Check if SQL has pyformat placeholders
430
+ param_names = parse_pyformat_params(sql)
431
+ if param_names:
432
+ logger.error(
433
+ "detect_and_convert_parameters: Parameter style mismatch - SQL has pyformat placeholders %s but received %s",
434
+ param_names,
435
+ type(parameters).__name__,
436
+ )
437
+ # SQL has %(name)s but user passed tuple/list
438
+ raise TypeError(
439
+ f"Parameter style mismatch: query uses named placeholders (%(name)s), "
440
+ f"but {type(parameters).__name__} was provided. "
441
+ f"Use dict for named parameters. Example: "
442
+ f'cursor.execute(sql, {{"param1": value1, "param2": value2}})'
443
+ )
444
+
445
+ # Valid qmark style - pass through
446
+ logger.debug("detect_and_convert_parameters: Valid qmark style - passing through unchanged")
447
+ return sql, parameters
448
+
449
+ # Pyformat style - dict
450
+ if isinstance(parameters, dict):
451
+ logger.debug(
452
+ "detect_and_convert_parameters: Detected pyformat-style parameters (dict) - count=%d, keys=%s",
453
+ len(parameters),
454
+ list(parameters.keys()),
455
+ )
456
+
457
+ # Check if SQL appears to have qmark placeholders
458
+ # Fast short-circuit: skip the O(n) context-aware scan if no ? exists at all
459
+ # Then use context-aware check that ignores ? inside brackets, quotes, and comments
460
+ if "?" in sql and _has_unquoted_question_marks(sql) and not parse_pyformat_params(sql):
461
+ logger.error(
462
+ "detect_and_convert_parameters: Parameter style mismatch - SQL has ? placeholders but received dict"
463
+ )
464
+ # SQL has ? but user passed dict and no %(name)s found
465
+ raise TypeError(
466
+ f"Parameter style mismatch: query uses positional placeholders (?), "
467
+ f"but dict was provided. "
468
+ f"Use tuple/list for positional parameters. Example: "
469
+ f"cursor.execute(sql, (value1, value2))"
470
+ )
471
+
472
+ logger.debug("detect_and_convert_parameters: Valid pyformat style - converting to qmark")
473
+ # Convert pyformat to qmark
474
+ converted_sql, qmark_params = convert_pyformat_to_qmark(sql, parameters)
475
+ logger.debug(
476
+ "detect_and_convert_parameters: Conversion complete - qmark_param_count=%d",
477
+ len(qmark_params) if qmark_params else 0,
478
+ )
479
+ return converted_sql, qmark_params
480
+
481
+ # Unsupported type
482
+ logger.error(
483
+ "detect_and_convert_parameters: Unsupported parameter type - %s",
484
+ type(parameters).__name__,
485
+ )
486
+ raise TypeError(
487
+ f"Parameters must be tuple, list, dict, or None. " f"Got {type(parameters).__name__}"
488
+ )