From 69e9c49cef5c9490b90d121555dd2e83869d40d8 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 5 Oct 2026 13:39:50 +0530 Subject: [PATCH 1/4] REFACTOR: Deduplicate pytest coverage without dropping Binary edge cases Remove shadowed definitions and repeated scenarios, consolidate all unique Binary inputs, and guard against duplicate test names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_002_types.py | 1307 +-- tests/test_003_connection.py | 32 - tests/test_004_cursor.py | 7808 +++++++---------- tests/test_008_auth.py | 11 - tests/test_010_connection_string_parser.py | 7 +- .../test_012_connection_string_integration.py | 39 - tests/test_013_encoding_decoding.py | 28 - tests/test_014_ddbc_bindings_coverage.py | 347 - tests/test_015_pyformat_parameters.py | 34 - tests/test_test_definitions.py | 25 + 10 files changed, 3521 insertions(+), 6117 deletions(-) delete mode 100644 tests/test_014_ddbc_bindings_coverage.py create mode 100644 tests/test_test_definitions.py diff --git a/tests/test_002_types.py b/tests/test_002_types.py index 8d92882d3..7132feabb 100644 --- a/tests/test_002_types.py +++ b/tests/test_002_types.py @@ -1,7 +1,6 @@ import pytest import datetime import time -import os from mssql_python.type import ( STRING, BINARY, @@ -95,31 +94,186 @@ def test_timestamp_from_ticks(): ), "TimestampFromTicks returned incorrect timestamp" -def test_binary_constructor(): - binary = Binary("test".encode("utf-8")) - assert isinstance( - binary, (bytes, bytearray) - ), "Binary constructor did not return a bytes object" - assert binary == b"test", "Binary constructor returned incorrect bytes" - - def test_binary_string_encoding(): - """Test Binary() string encoding (Lines 134-135).""" - # Test basic string encoding - result = Binary("hello") - assert result == b"hello", "String should be encoded to UTF-8 bytes" - - # Test string with UTF-8 characters - result = Binary("café") - assert result == "café".encode("utf-8"), "UTF-8 string should be properly encoded" - - # Test empty string - result = Binary("") - assert result == b"", "Empty string should encode to empty bytes" + """Encode each distinct input from the former UTF-8 suites exactly once. - # Test string with special characters - result = Binary("Hello\nWorld\t!") - assert result == b"Hello\nWorld\t!", "String with special characters should encode properly" + Binary is a Python UTF-8 helper, not a native SQLWCHAR conversion entry point. + Native database encoding/decoding is covered in test_013_encoding_decoding.py. + """ + cases = [ + ("hello world", b"hello world"), + ("h\xe9llo w\xf8rld", b"h\xc3\xa9llo w\xc3\xb8rld"), + ("Hello \U0001f30d", b"Hello \xf0\x9f\x8c\x8d"), + ("", b""), + ("hello", b"hello"), + ("caf\xe9", b"caf\xc3\xa9"), + ("Hello\nWorld\t!", b"Hello\nWorld\t!"), + ("\x80", b"\xc2\x80"), + ("\xa9", b"\xc2\xa9"), + ("\xff", b"\xc3\xbf"), + ("\u07ff", b"\xdf\xbf"), + ("\u0800", b"\xe0\xa0\x80"), + ("\u4e2d", b"\xe4\xb8\xad"), + ("\u20ac", b"\xe2\x82\xac"), + ("\ud7ff", b"\xed\x9f\xbf"), + ("\ue000", b"\xee\x80\x80"), + ("\uffff", b"\xef\xbf\xbf"), + ("\U00010000", b"\xf0\x90\x80\x80"), + ("\U0001f600", b"\xf0\x9f\x98\x80"), + ("\U0001f601", b"\xf0\x9f\x98\x81"), + ("\U0001f30d", b"\xf0\x9f\x8c\x8d"), + ("\U000f0000", b"\xf3\xb0\x80\x80"), + ("\U0010ffff", b"\xf4\x8f\xbf\xbf"), + ("\x00", b"\x00"), + (" ", b" "), + ("A", b"A"), + ("Z", b"Z"), + ("a", b"a"), + ("z", b"z"), + ("\x7f", b"\x7f"), + ("Hello", b"Hello"), + ("0123456789", b"0123456789"), + ("!@#$%^&*()", b"!@#$%^&*()"), + ("\xe9", b"\xc3\xa9"), + ("\u03b1", b"\xce\xb1"), + ("\u0401", b"\xd0\x81"), + ("\u05d0", b"\xd7\x90"), + ( + "\u041f\u0440\u0438\u0432\u0435\u0442", + b"\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", + ), + ("\u65e5", b"\xe6\x97\xa5"), + ("\uac00", b"\xea\xb0\x80"), + ("\u2764", b"\xe2\x9d\xa4"), + ("\u4f60\u597d", b"\xe4\xbd\xa0\xe5\xa5\xbd"), + ( + "\u3053\u3093\u306b\u3061\u306f", + b"\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf", + ), + ("\U0001f44d", b"\xf0\x9f\x91\x8d"), + ("\U0001f525", b"\xf0\x9f\x94\xa5"), + ("\U0001d54a", b"\xf0\x9d\x95\x8a"), + ("\U00020000", b"\xf0\xa0\x80\x80"), + ("Hello \U0001f600", b"Hello \xf0\x9f\x98\x80"), + ("\U0001f525\U0001f4af", b"\xf0\x9f\x94\xa5\xf0\x9f\x92\xaf"), + ("A\xe9\u4e2d\U0001f600", b"A\xc3\xa9\xe4\xb8\xad\xf0\x9f\x98\x80"), + ("Test: \u20ac100 \U0001f4b0", b"Test: \xe2\x82\xac100 \xf0\x9f\x92\xb0"), + ("A\xa9\u20ac\U0001f600", b"A\xc2\xa9\xe2\x82\xac\xf0\x9f\x98\x80"), + ("Hello \xa9\u4e2d\U0001f600", b"Hello \xc2\xa9\xe4\xb8\xad\xf0\x9f\x98\x80"), + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + ("!@#$%^&*()_+-=[]{}|;:',.<>?/", b"!@#$%^&*()_+-=[]{}|;:',.<>?/"), + ("a" * 1000, b"a" * 1000), + ("r\xe9sum\xe9", b"r\xc3\xa9sum\xc3\xa9"), + ("na\xefve", b"na\xc3\xafve"), + ("\xc5ngstr\xf6m", b"\xc3\x85ngstr\xc3\xb6m"), + ( + "\u03b3\u03b5\u03b9\u03b1 \u03c3\u03bf\u03c5", + b"\xce\xb3\xce\xb5\xce\xb9\xce\xb1 \xcf\x83\xce\xbf\xcf\x85", + ), + ("\xa7\xa9\xae\u2122", b"\xc2\xa7\xc2\xa9\xc2\xae\xe2\x84\xa2"), + ("\u4f60\u597d\u4e16\u754c", b"\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c"), + ( + "\uc548\ub155\ud558\uc138\uc694", + b"\xec\x95\x88\xeb\x85\x95\xed\x95\x98\xec\x84\xb8\xec\x9a\x94", + ), + ("\u0645\u0631\u062d\u0628\u0627", b"\xd9\x85\xd8\xb1\xd8\xad\xd8\xa8\xd8\xa7"), + ("\u05e9\u05dc\u05d5\u05dd", b"\xd7\xa9\xd7\x9c\xd7\x95\xd7\x9d"), + ("\u0939\u0948\u0932\u094b", b"\xe0\xa4\xb9\xe0\xa5\x88\xe0\xa4\xb2\xe0\xa5\x8b"), + ("\u20ac\xa3\xa5", b"\xe2\x82\xac\xc2\xa3\xc2\xa5"), + ("\u2192\u21d2\u2194", b"\xe2\x86\x92\xe2\x87\x92\xe2\x86\x94"), + ( + "\U0001f600\U0001f603\U0001f604\U0001f601", + b"\xf0\x9f\x98\x80\xf0\x9f\x98\x83\xf0\x9f\x98\x84\xf0\x9f\x98\x81", + ), + ("\U0001f30d\U0001f30e\U0001f30f", b"\xf0\x9f\x8c\x8d\xf0\x9f\x8c\x8e\xf0\x9f\x8c\x8f"), + ( + "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466", + b"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", + ), + ("\U0001f525\U0001f4af\u2728", b"\xf0\x9f\x94\xa5\xf0\x9f\x92\xaf\xe2\x9c\xa8"), + ( + "\U0001d573\U0001d58a\U0001d591\U0001d591\U0001d594", + b"\xf0\x9d\x95\xb3\xf0\x9d\x96\x8a\xf0\x9d\x96\x91\xf0\x9d\x96\x91\xf0\x9d\x96\x94", + ), + ( + "\U0002070e\U00020731\U00020779\U00020c53", + b"\xf0\xa0\x9c\x8e\xf0\xa0\x9c\xb1\xf0\xa0\x9d\xb9\xf0\xa0\xb1\x93", + ), + ("Hello \u4e16\u754c", b"Hello \xe4\xb8\x96\xe7\x95\x8c"), + ("Caf\xe9 \u2615", b"Caf\xc3\xa9 \xe2\x98\x95"), + ("Price: \u20ac100", b"Price: \xe2\x82\xac100"), + ("Score: \U0001f4af/100", b"Score: \xf0\x9f\x92\xaf/100"), + ( + "ASCII text then \ud55c\uae00 then more ASCII", + b"ASCII text then \xed\x95\x9c\xea\xb8\x80 then more ASCII", + ), + ("123 numbers \u6570\u5b57 456", b"123 numbers \xe6\x95\xb0\xe5\xad\x97 456"), + ("A\x00B", b"A\x00B"), + ("\u4e2d\u6587", b"\xe4\xb8\xad\xe6\x96\x87"), + ("Before\ufffdAfter", b"Before\xef\xbf\xbdAfter"), + ("Valid\ufffdMiddle", b"Valid\xef\xbf\xbdMiddle"), + ("\ufffd\ufffd\ufffd", b"\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd"), + ("\ufffd", b"\xef\xbf\xbd"), + ("\ufffdStart", b"\xef\xbf\xbdStart"), + ("End\ufffd", b"End\xef\xbf\xbd"), + ("A\ufffdB\ufffdC", b"A\xef\xbf\xbdB\xef\xbf\xbdC"), + ("ASCII", b"ASCII"), + ("Caf\xe9", b"Caf\xc3\xa9"), + ("\u4e2d\u6587\u6d4b\u8bd5", b"\xe4\xb8\xad\xe6\x96\x87\xe6\xb5\x8b\xe8\xaf\x95"), + ("\U0001f600\U0001f30d", b"\xf0\x9f\x98\x80\xf0\x9f\x8c\x8d"), + ("0", b"0"), + ("~", b"~"), + ("\x00\x7f", b"\x00\x7f"), + ("HelloWorld123", b"HelloWorld123"), + ("Hello\U0001f600", b"Hello\xf0\x9f\x98\x80"), + ("Test\ufffdValue", b"Test\xef\xbf\xbdValue"), + ("A" * 1000, b"A" * 1000), + ("\u4e2d" * 500, b"\xe4\xb8\xad" * 500), + ("\U0001f600" * 200, b"\xf0\x9f\x98\x80" * 200), + ("Valid\ufffdText", b"Valid\xef\xbf\xbdText"), + ("A\xa9\u4e2d\U0001f600", b"A\xc2\xa9\xe4\xb8\xad\xf0\x9f\x98\x80"), + ] + for value, expected in cases: + result = Binary(value) + assert isinstance(result, bytes) + assert result == expected, repr(value) + + +def test_binary_bytes_are_unchanged(): + """Raw bytes, including malformed UTF-8, are not decoded by Binary.""" + for value in ( + b"hello bytes", + b"", + b"test", + b"Test\xed\xa0\x80", + b"\xed\xb0\x80Test", + b"A\xed\xa0\x80B", + b"\xed\xb0\x80C", + ): + assert Binary(value) is value + + +@pytest.mark.parametrize("value", [bytearray(), bytearray(b"hello bytearray")]) +def test_binary_bytearray(value): + result = Binary(value) + assert isinstance(result, bytes) + assert result == bytes(value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("\ud800", id="high-surrogate-start"), + pytest.param("\udbff", id="high-surrogate-end"), + pytest.param("\udc00", id="low-surrogate-start"), + pytest.param("\udfff", id="low-surrogate-end"), + pytest.param("A\ud800B", id="embedded-surrogate"), + pytest.param("\ud83d\ude00", id="surrogate-pair"), + ], +) +def test_binary_rejects_surrogate_codepoints(value): + with pytest.raises(UnicodeEncodeError): + Binary(value) def test_binary_memoryview(): @@ -187,1108 +341,3 @@ class CustomObject: assert "Binary() only accepts str, bytes, bytearray, or memoryview objects" in str( exc_info.value ) - - -def test_binary_comprehensive_coverage(): - """Test Binary() function comprehensive coverage including all paths.""" - # Test bytes input (should return as-is) - bytes_input = b"hello bytes" - result = Binary(bytes_input) - assert result is bytes_input, "Bytes input should be returned as-is" - assert result == b"hello bytes", "Bytes content should be unchanged" - - # Test bytearray input (should convert to bytes) - bytearray_input = bytearray(b"hello bytearray") - result = Binary(bytearray_input) - assert isinstance(result, bytes), "Bytearray should be converted to bytes" - assert result == b"hello bytearray", "Bytearray content should be preserved in bytes" - - # Test string input with various encodings (Lines 134-135) - # ASCII string - result = Binary("hello world") - assert result == b"hello world", "ASCII string should encode properly" - - # Unicode string - result = Binary("héllo wørld") - assert result == "héllo wørld".encode("utf-8"), "Unicode string should encode to UTF-8" - - # String with emojis - result = Binary("Hello 🌍") - assert result == "Hello 🌍".encode("utf-8"), "Emoji string should encode to UTF-8" - - # Empty inputs - assert Binary("") == b"", "Empty string should encode to empty bytes" - assert Binary(b"") == b"", "Empty bytes should remain empty bytes" - assert Binary(bytearray()) == b"", "Empty bytearray should convert to empty bytes" - - -def test_utf8_encoding_comprehensive(): - """Test UTF-8 encoding with various character types covering the optimized Utf8ToWString function.""" - # Test ASCII-only strings (fast path optimization) - ascii_strings = [ - "hello world", - "ABCDEFGHIJKLMNOPQRSTUVWXYZ", - "0123456789", - "!@#$%^&*()_+-=[]{}|;:',.<>?/", - "", # Empty string - "a", # Single character - "a" * 1000, # Long ASCII string - ] - - for s in ascii_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"ASCII string '{s[:20]}...' failed encoding" - - # Test 2-byte UTF-8 sequences (Latin extended, Greek, Cyrillic, etc.) - two_byte_strings = [ - "café", # Latin-1 supplement - "résumé", - "naïve", - "Ångström", - "γεια σου", # Greek - "Привет", # Cyrillic - "§©®™", # Symbols - ] - - for s in two_byte_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"2-byte UTF-8 string '{s}' failed encoding" - - # Test 3-byte UTF-8 sequences (CJK, Arabic, Hebrew, etc.) - three_byte_strings = [ - "你好世界", # Chinese - "こんにちは", # Japanese Hiragana - "안녕하세요", # Korean - "مرحبا", # Arabic - "שלום", # Hebrew - "हैलो", # Hindi - "€£¥", # Currency symbols - "→⇒↔", # Arrows - ] - - for s in three_byte_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"3-byte UTF-8 string '{s}' failed encoding" - - # Test 4-byte UTF-8 sequences (emojis, supplementary characters) - four_byte_strings = [ - "😀😃😄😁", # Emojis - "🌍🌎🌏", # Earth emojis - "👨‍👩‍👧‍👦", # Family emoji - "🔥💯✨", # Common emojis - "𝕳𝖊𝖑𝖑𝖔", # Mathematical alphanumeric - "𠜎𠜱𠝹𠱓", # Rare CJK - ] - - for s in four_byte_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"4-byte UTF-8 string '{s}' failed encoding" - - # Test mixed content (ASCII + multi-byte) - mixed_strings = [ - "Hello 世界", - "Café ☕", - "Price: €100", - "Score: 💯/100", - "ASCII text then 한글 then more ASCII", - "123 numbers 数字 456", - ] - - for s in mixed_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"Mixed string '{s}' failed encoding" - - # Test edge cases - edge_cases = [ - "\x00", # Null character - "\u0080", # Minimum 2-byte - "\u07ff", # Maximum 2-byte - "\u0800", # Minimum 3-byte - "\uffff", # Maximum 3-byte - "\U00010000", # Minimum 4-byte - "\U0010ffff", # Maximum valid Unicode - "A\u0000B", # Embedded null - ] - - for s in edge_cases: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"Edge case string failed encoding" - - -def test_utf8_byte_sequence_patterns(): - """Test specific UTF-8 byte sequence patterns to verify correct encoding/decoding.""" - - # Test 1-byte sequence (ASCII): 0xxxxxxx - # Range: U+0000 to U+007F (0-127) - one_byte_tests = [ - ("\x00", b"\x00", "Null character"), - ("\x20", b"\x20", "Space"), - ("\x41", b"\x41", "Letter A"), - ("\x5a", b"\x5a", "Letter Z"), - ("\x61", b"\x61", "Letter a"), - ("\x7a", b"\x7a", "Letter z"), - ("\x7f", b"\x7f", "DEL character (max 1-byte)"), - ("Hello", b"Hello", "ASCII word"), - ("0123456789", b"0123456789", "ASCII digits"), - ("!@#$%^&*()", b"!@#$%^&*()", "ASCII symbols"), - ] - - for char, expected_bytes, description in one_byte_tests: - result = Binary(char) - assert result == expected_bytes, f"1-byte sequence failed for {description}: {char!r}" - # Verify it's truly 1-byte per character - if len(char) == 1: - assert len(result) == 1, f"Expected 1 byte, got {len(result)} for {char!r}" - - # Test 2-byte sequence: 110xxxxx 10xxxxxx - # Range: U+0080 to U+07FF (128-2047) - two_byte_tests = [ - ("\u0080", b"\xc2\x80", "Minimum 2-byte sequence"), - ("\u00a9", b"\xc2\xa9", "Copyright symbol ©"), - ("\u00e9", b"\xc3\xa9", "Latin e with acute é"), - ("\u03b1", b"\xce\xb1", "Greek alpha α"), - ("\u0401", b"\xd0\x81", "Cyrillic Ё"), - ("\u05d0", b"\xd7\x90", "Hebrew Alef א"), - ("\u07ff", b"\xdf\xbf", "Maximum 2-byte sequence"), - ("café", b"caf\xc3\xa9", "Word with 2-byte char"), - ("Привет", b"\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", "Cyrillic word"), - ] - - for char, expected_bytes, description in two_byte_tests: - result = Binary(char) - assert result == expected_bytes, f"2-byte sequence failed for {description}: {char!r}" - - # Test 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx - # Range: U+0800 to U+FFFF (2048-65535) - three_byte_tests = [ - ("\u0800", b"\xe0\xa0\x80", "Minimum 3-byte sequence"), - ("\u20ac", b"\xe2\x82\xac", "Euro sign €"), - ("\u4e2d", b"\xe4\xb8\xad", "Chinese character 中"), - ("\u65e5", b"\xe6\x97\xa5", "Japanese Kanji 日"), - ("\uac00", b"\xea\xb0\x80", "Korean Hangul 가"), - ("\u2764", b"\xe2\x9d\xa4", "Heart symbol ❤"), - ("\uffff", b"\xef\xbf\xbf", "Maximum 3-byte sequence"), - ("你好", b"\xe4\xbd\xa0\xe5\xa5\xbd", "Chinese greeting"), - ( - "こんにちは", - b"\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf", - "Japanese greeting", - ), - ] - - for char, expected_bytes, description in three_byte_tests: - result = Binary(char) - assert result == expected_bytes, f"3-byte sequence failed for {description}: {char!r}" - - # Test 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx - # Range: U+10000 to U+10FFFF (65536-1114111) - four_byte_tests = [ - ("\U00010000", b"\xf0\x90\x80\x80", "Minimum 4-byte sequence"), - ("\U0001f600", b"\xf0\x9f\x98\x80", "Grinning face emoji 😀"), - ("\U0001f44d", b"\xf0\x9f\x91\x8d", "Thumbs up emoji 👍"), - ("\U0001f525", b"\xf0\x9f\x94\xa5", "Fire emoji 🔥"), - ("\U0001f30d", b"\xf0\x9f\x8c\x8d", "Earth globe emoji 🌍"), - ("\U0001d54a", b"\xf0\x9d\x95\x8a", "Mathematical double-struck 𝕊"), - ("\U00020000", b"\xf0\xa0\x80\x80", "CJK Extension B character"), - ("\U0010ffff", b"\xf4\x8f\xbf\xbf", "Maximum valid Unicode"), - ("Hello 😀", b"Hello \xf0\x9f\x98\x80", "ASCII + 4-byte emoji"), - ( - "🔥💯", - b"\xf0\x9f\x94\xa5\xf0\x9f\x92\xaf", - "Multiple 4-byte emojis", - ), - ] - - for char, expected_bytes, description in four_byte_tests: - result = Binary(char) - assert result == expected_bytes, f"4-byte sequence failed for {description}: {char!r}" - - # Test mixed sequences in single string - mixed_sequence_tests = [ - ( - "A\u00e9\u4e2d😀", - b"A\xc3\xa9\xe4\xb8\xad\xf0\x9f\x98\x80", - "1+2+3+4 byte mix", - ), - ("Test: €100 💰", b"Test: \xe2\x82\xac100 \xf0\x9f\x92\xb0", "Mixed content"), - ( - "\x41\u00a9\u20ac\U0001f600", - b"\x41\xc2\xa9\xe2\x82\xac\xf0\x9f\x98\x80", - "All sequence lengths", - ), - ] - - for char, expected_bytes, description in mixed_sequence_tests: - result = Binary(char) - assert result == expected_bytes, f"Mixed sequence failed for {description}: {char!r}" - - -def test_utf8_invalid_sequences_and_edge_cases(): - """ - Test invalid UTF-8 sequences and edge cases to achieve full code coverage - of the decodeUtf8 lambda function in ddbc_bindings.h Utf8ToWString. - """ - - # Test truncated 2-byte sequence (i + 1 >= len branch) - # When we have 110xxxxx but no continuation byte - truncated_2byte = b"Hello \xc3" # Incomplete é - try: - # Python's decode will handle this, but our C++ code should too - result = truncated_2byte.decode("utf-8", errors="replace") - # Should produce replacement character - assert "\ufffd" in result or result.endswith("Hello ") - except: - pass - - # Test truncated 3-byte sequence (i + 2 >= len branch) - # When we have 1110xxxx but missing continuation bytes - truncated_3byte_1 = b"Test \xe4" # Just first byte of 中 - truncated_3byte_2 = b"Test \xe4\xb8" # First two bytes of 中, missing third - - for test_bytes in [truncated_3byte_1, truncated_3byte_2]: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character for incomplete sequence - assert "\ufffd" in result or "Test" in result - except: - pass - - # Test truncated 4-byte sequence (i + 3 >= len branch) - # When we have 11110xxx but missing continuation bytes - truncated_4byte_1 = b"Emoji \xf0" # Just first byte - truncated_4byte_2 = b"Emoji \xf0\x9f" # First two bytes - truncated_4byte_3 = b"Emoji \xf0\x9f\x98" # First three bytes of 😀 - - for test_bytes in [truncated_4byte_1, truncated_4byte_2, truncated_4byte_3]: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character - assert "\ufffd" in result or "Emoji" in result - except: - pass - - # Test invalid continuation bytes (should trigger "Invalid sequence - skip byte" branch) - # When high bits indicate multi-byte but structure is wrong - invalid_sequences = [ - b"Test \xc0\x80", # Overlong encoding of NULL (invalid) - b"Test \xc1\xbf", # Overlong encoding (invalid) - b"Test \xe0\x80\x80", # Overlong 3-byte encoding (invalid) - b"Test \xf0\x80\x80\x80", # Overlong 4-byte encoding (invalid) - b"Test \xf8\x88\x80\x80\x80", # Invalid 5-byte sequence - b"Test \xfc\x84\x80\x80\x80\x80", # Invalid 6-byte sequence - b"Test \xfe\xff", # Invalid bytes (FE and FF are never valid in UTF-8) - b"Test \x80", # Unexpected continuation byte - b"Test \xbf", # Another unexpected continuation byte - ] - - for test_bytes in invalid_sequences: - try: - # Python will replace invalid sequences - result = test_bytes.decode("utf-8", errors="replace") - # Should contain replacement character or original text - assert "Test" in result - except: - pass - - # Test byte values that should trigger the else branch (invalid UTF-8 start bytes) - # These are bytes like 10xxxxxx (continuation bytes) or 11111xxx (invalid) - continuation_and_invalid = [ - b"\x80", # 10000000 - continuation byte without start - b"\xbf", # 10111111 - continuation byte without start - b"\xf8", # 11111000 - invalid 5-byte start - b"\xf9", # 11111001 - invalid - b"\xfa", # 11111010 - invalid - b"\xfb", # 11111011 - invalid - b"\xfc", # 11111100 - invalid 6-byte start - b"\xfd", # 11111101 - invalid - b"\xfe", # 11111110 - invalid - b"\xff", # 11111111 - invalid - ] - - for test_byte in continuation_and_invalid: - try: - # These should all be handled as invalid and return U+FFFD - result = test_byte.decode("utf-8", errors="replace") - assert result == "\ufffd" or len(result) >= 0 # Handled somehow - except: - pass - - # Test mixed valid and invalid sequences - mixed_valid_invalid = [ - b"Valid \xc3\xa9 invalid \x80 more text", # Valid é then invalid continuation - b"Start \xe4\xb8\xad good \xf0 bad end", # Valid 中 then truncated 4-byte - b"Test \xf0\x9f\x98\x80 \xfe end", # Valid 😀 then invalid FE - ] - - for test_bytes in mixed_valid_invalid: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should contain both valid text and replacement characters - assert "Test" in result or "Start" in result or "Valid" in result - except: - pass - - # Test empty string edge case (already tested but ensures coverage) - empty_result = Binary("") - assert empty_result == b"" - - # Test string with only invalid bytes - only_invalid = b"\x80\x81\x82\x83\xfe\xff" - try: - result = only_invalid.decode("utf-8", errors="replace") - # Should be all replacement characters - assert "\ufffd" in result or len(result) > 0 - except: - pass - - # Success - all edge cases and invalid sequences handled - assert True, "All invalid UTF-8 sequences and edge cases covered" - - -def test_invalid_surrogate_handling(): - """ - Test that invalid surrogate values are replaced with Unicode replacement character (U+FFFD). - This validates the fix for unix_utils.cpp to match ddbc_bindings.h behavior. - """ - import mssql_python - - # Test connection strings with various surrogate-related edge cases - # These should be handled gracefully without introducing invalid Unicode - - # High surrogate without low surrogate (invalid) - # In UTF-16, high surrogates (0xD800-0xDBFF) must be followed by low surrogates - try: - # Create a connection string that would exercise the conversion path - # Use environment variables or placeholder values to avoid SEC101/037 security warnings - test_server = os.getenv("TEST_SERVER", "testserver") - test_db = os.getenv("TEST_DATABASE", "TestDB") - conn_str = f"Server={test_server};Database={test_db};Trusted_Connection=yes" - conn = mssql_python.connect(conn_str, autoconnect=False) - conn.close() - except Exception: - pass # Connection will fail, but string parsing validates surrogate handling - - # Low surrogate without high surrogate (invalid) - # In UTF-16, low surrogates (0xDC00-0xDFFF) must be preceded by high surrogates - try: - test_server = os.getenv("TEST_SERVER", "testserver") - conn_str = ( - f"Server={test_server};Database=DB;ApplicationName=TestApp;Trusted_Connection=yes" - ) - conn = mssql_python.connect(conn_str, autoconnect=False) - conn.close() - except Exception: - pass - - # Valid surrogate pairs (should work correctly) - # Emoji characters like 😀 (U+1F600) are encoded as surrogate pairs in UTF-16 - emoji_tests = [ - "Database=😀_DB", # Emoji in database name - "ApplicationName=App_🔥", # Fire emoji - "Server=test_💯", # 100 points emoji - ] - - for test_str in emoji_tests: - try: - conn_str = f"Server=test;{test_str};Trusted_Connection=yes" - conn = mssql_python.connect(conn_str, autoconnect=False) - conn.close() - except Exception: - pass # Connection may fail, but surrogate pair encoding should be correct - - # The key validation is that no exceptions are raised during string conversion - # and that invalid surrogates are replaced with U+FFFD rather than being pushed as-is - assert True, "Invalid surrogate handling validated" - - -def test_utf8_overlong_encoding_security(): - """ - Test that overlong UTF-8 encodings are rejected for security. - Overlong encodings can be used to bypass security checks. - """ - - # Overlong 2-byte encoding of ASCII characters (should be rejected) - # ASCII 'A' (0x41) should use 1 byte, not 2 - overlong_2byte = b"\xc1\x81" # Overlong encoding of 0x41 ('A') - try: - result = overlong_2byte.decode("utf-8", errors="replace") - # Should produce replacement characters, not 'A' - assert "A" not in result or "\ufffd" in result - except: - pass - - # Overlong 2-byte encoding of NULL (security concern) - overlong_null_2byte = b"\xc0\x80" # Overlong encoding of 0x00 - try: - result = overlong_null_2byte.decode("utf-8", errors="replace") - # Should NOT decode to null character - assert "\x00" not in result or "\ufffd" in result - except: - pass - - # Overlong 3-byte encoding of characters that should use 2 bytes - # Character 0x7FF should use 2 bytes, not 3 - overlong_3byte = b"\xe0\x9f\xbf" # Overlong encoding of 0x7FF - try: - result = overlong_3byte.decode("utf-8", errors="replace") - # Should be rejected as overlong - assert "\ufffd" in result or len(result) > 0 - except: - pass - - # Overlong 4-byte encoding of characters that should use 3 bytes - # Character 0xFFFF should use 3 bytes, not 4 - overlong_4byte = b"\xf0\x8f\xbf\xbf" # Overlong encoding of 0xFFFF - try: - result = overlong_4byte.decode("utf-8", errors="replace") - # Should be rejected as overlong - assert "\ufffd" in result or len(result) > 0 - except: - pass - - # UTF-8 encoded surrogates (should be rejected) - # Surrogates (0xD800-0xDFFF) should never appear in valid UTF-8 - encoded_surrogate_high = b"\xed\xa0\x80" # UTF-8 encoding of 0xD800 (high surrogate) - encoded_surrogate_low = b"\xed\xbf\xbf" # UTF-8 encoding of 0xDFFF (low surrogate) - - for test_bytes in [encoded_surrogate_high, encoded_surrogate_low]: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character, not actual surrogate - assert "\ufffd" in result or len(result) > 0 - except: - pass - - # Code points above 0x10FFFF (should be rejected) - # Maximum valid Unicode is 0x10FFFF - above_max_unicode = b"\xf4\x90\x80\x80" # Encodes 0x110000 (above max) - try: - result = above_max_unicode.decode("utf-8", errors="replace") - # Should be rejected - assert "\ufffd" in result or len(result) > 0 - except: - pass - - # Test with Binary() function which uses the UTF-8 decoder - # Valid UTF-8 strings should work - valid_strings = [ - "Hello", # ASCII - "café", # 2-byte - "中文", # 3-byte - "😀", # 4-byte - ] - - for s in valid_strings: - result = Binary(s) - expected = s.encode("utf-8") - assert result == expected, f"Valid string '{s}' failed" - - # The security improvement ensures overlong encodings and invalid - # code points are rejected, preventing potential security vulnerabilities - assert True, "Overlong encoding security validation passed" - - -def test_utf8_continuation_byte_validation(): - """ - Test that continuation bytes are properly validated to have the 10xxxxxx bit pattern. - Invalid continuation bytes should be rejected to prevent malformed UTF-8 decoding. - """ - - # 2-byte sequence with invalid continuation byte (not 10xxxxxx) - # First byte indicates 2-byte sequence, but second byte doesn't start with 10 - invalid_2byte_sequences = [ - b"\xc2\x00", # Second byte is 00xxxxxx (should be 10xxxxxx) - b"\xc2\x40", # Second byte is 01xxxxxx (should be 10xxxxxx) - b"\xc2\xc0", # Second byte is 11xxxxxx (should be 10xxxxxx) - b"\xc2\xff", # Second byte is 11xxxxxx (should be 10xxxxxx) - ] - - for test_bytes in invalid_2byte_sequences: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character(s), not decode incorrectly - assert ( - "\ufffd" in result - ), f"Failed to reject invalid 2-byte sequence: {test_bytes.hex()}" - except: - pass # Also acceptable to raise exception - - # 3-byte sequence with invalid continuation bytes - invalid_3byte_sequences = [ - b"\xe0\xa0\x00", # Third byte invalid - b"\xe0\x00\x80", # Second byte invalid - b"\xe0\xc0\x80", # Second byte invalid (11xxxxxx instead of 10xxxxxx) - b"\xe4\xb8\xc0", # Third byte invalid (11xxxxxx instead of 10xxxxxx) - ] - - for test_bytes in invalid_3byte_sequences: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character(s) - assert ( - "\ufffd" in result - ), f"Failed to reject invalid 3-byte sequence: {test_bytes.hex()}" - except: - pass - - # 4-byte sequence with invalid continuation bytes - invalid_4byte_sequences = [ - b"\xf0\x90\x80\x00", # Fourth byte invalid - b"\xf0\x90\x00\x80", # Third byte invalid - b"\xf0\x00\x80\x80", # Second byte invalid - b"\xf0\xc0\x80\x80", # Second byte invalid (11xxxxxx) - b"\xf0\x9f\xc0\x80", # Third byte invalid (11xxxxxx) - b"\xf0\x9f\x98\xc0", # Fourth byte invalid (11xxxxxx) - ] - - for test_bytes in invalid_4byte_sequences: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Should produce replacement character(s) - assert ( - "\ufffd" in result - ), f"Failed to reject invalid 4-byte sequence: {test_bytes.hex()}" - except: - pass - - # Valid sequences should still work (continuation bytes with correct 10xxxxxx pattern) - valid_sequences = [ - (b"\xc2\xa9", "©"), # Valid 2-byte (copyright symbol) - (b"\xe4\xb8\xad", "中"), # Valid 3-byte (Chinese character) - (b"\xf0\x9f\x98\x80", "😀"), # Valid 4-byte (emoji) - ] - - for test_bytes, expected_char in valid_sequences: - try: - result = test_bytes.decode("utf-8") - assert result == expected_char, f"Valid sequence {test_bytes.hex()} failed to decode" - except Exception as e: - assert False, f"Valid sequence {test_bytes.hex()} raised exception: {e}" - - # Test with Binary() function - # Valid UTF-8 should work - valid_test = "Hello ©中😀" - result = Binary(valid_test) - expected = valid_test.encode("utf-8") - assert result == expected, "Valid UTF-8 with continuation bytes failed" - - assert True, "Continuation byte validation passed" - - -def test_utf8_replacement_character_handling(): - """Test that legitimate U+FFFD (replacement character) is preserved - while invalid sequences also produce U+FFFD.""" - import mssql_python - - # Test 1: Legitimate U+FFFD in the input should be preserved - # U+FFFD is encoded as EF BF BD in UTF-8 - legitimate_fffd = "Before\ufffdAfter" # Python string with actual U+FFFD - result = Binary(legitimate_fffd) - expected = legitimate_fffd.encode("utf-8") # Should encode to b'Before\xef\xbf\xbdAfter' - assert result == expected, "Legitimate U+FFFD was not preserved" - - # Test 2: Invalid single byte at position 0 should produce U+FFFD - # This specifically tests the buffer overflow fix - invalid_start = b"\xff" # Invalid UTF-8 byte - try: - decoded = invalid_start.decode("utf-8", errors="replace") - assert decoded == "\ufffd", "Invalid byte at position 0 should produce U+FFFD" - except Exception as e: - assert False, f"Decoding invalid start byte raised exception: {e}" - - # Test 3: Mix of legitimate U+FFFD and invalid sequences - test_string = "Valid\ufffdMiddle" # Legitimate U+FFFD in the middle - result = Binary(test_string) - expected = test_string.encode("utf-8") - assert result == expected, "Mixed legitimate U+FFFD failed" - - # Test 4: Multiple legitimate U+FFFD characters - multi_fffd = "\ufffd\ufffd\ufffd" - result = Binary(multi_fffd) - expected = multi_fffd.encode("utf-8") # Should be b'\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd' - assert result == expected, "Multiple legitimate U+FFFD characters failed" - - # Test 5: U+FFFD at boundaries - boundary_tests = [ - "\ufffd", # Only U+FFFD - "\ufffdStart", # U+FFFD at start - "End\ufffd", # U+FFFD at end - "A\ufffdB\ufffdC", # U+FFFD interspersed - ] - - for test_str in boundary_tests: - result = Binary(test_str) - expected = test_str.encode("utf-8") - assert result == expected, f"Boundary test '{test_str}' failed" - - assert True, "Replacement character handling passed" - - -def test_utf8_2byte_sequence_complete_coverage(): - """ - Comprehensive test for 2-byte UTF-8 sequence handling in ddbc_bindings.h lines 473-488. - - Tests all code paths: - 1. Lines 475-478: Invalid continuation byte detection - 2. Lines 479-484: Valid decoding path - 3. Lines 486-487: Overlong encoding rejection - """ - import mssql_python - - # TEST 1: Lines 475-478 - Invalid continuation byte detection - # Condition: (data[i + 1] & 0xC0) != 0x80 - invalid_continuation = [ - (b"\xc2\x00", "00000000", "00xxxxxx - should fail"), - (b"\xc2\x3f", "00111111", "00xxxxxx - should fail"), - (b"\xc2\x40", "01000000", "01xxxxxx - should fail"), - (b"\xc2\x7f", "01111111", "01xxxxxx - should fail"), - (b"\xc2\xc0", "11000000", "11xxxxxx - should fail"), - (b"\xc2\xff", "11111111", "11xxxxxx - should fail"), - ] - - for test_bytes, binary, desc in invalid_continuation: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Invalid continuation should return the replacement character (covers ddbc_bindings.h lines 476-478) - assert "\ufffd" in result, f"Should contain replacement char for {desc}" - except Exception as e: - # Any error handling is acceptable for invalid sequences - pass - - # TEST 2: Lines 481-484 - Valid decoding path - # Condition: cp >= 0x80 (after continuation byte validated) - valid_2byte = [ - (b"\xc2\x80", "\u0080", 0x80, "U+0080 - minimum valid 2-byte"), - (b"\xc2\xa9", "©", 0xA9, "U+00A9 - copyright symbol"), - (b"\xc3\xbf", "ÿ", 0xFF, "U+00FF - y with diaeresis"), - (b"\xdf\xbf", "\u07ff", 0x7FF, "U+07FF - maximum valid 2-byte"), - ] - - for test_bytes, expected_char, codepoint, desc in valid_2byte: - # Test decoding - result = test_bytes.decode("utf-8") - assert result == expected_char, f"Should decode to {expected_char!r}" - assert "\ufffd" not in result, f"Should NOT contain U+FFFD for valid sequence" - - # Test encoding via Binary() - binary_result = Binary(expected_char) - assert ( - binary_result == test_bytes - ), f"Binary({expected_char!r}) should encode to {test_bytes.hex()}" - - # TEST 3: Lines 486-487 - Overlong encoding rejection - # Condition: cp < 0x80 (overlong encoding) - overlong_2byte = [ - (b"\xc0\x80", 0x00, "NULL character - security risk"), - (b"\xc0\xaf", 0x2F, "Forward slash / - path traversal risk"), - (b"\xc1\x81", 0x41, "ASCII 'A' - should use 1 byte"), - (b"\xc1\xbf", 0x7F, "DEL character - should use 1 byte"), - ] - - for test_bytes, codepoint, desc in overlong_2byte: - try: - result = test_bytes.decode("utf-8", errors="replace") - # Overlong encodings must yield replacement, not the original codepoint (covers lines 486-487) - assert "\ufffd" in result, f"Overlong U+{codepoint:04X} should produce replacement char" - assert ( - chr(codepoint) not in result - ), f"Overlong U+{codepoint:04X} must not decode to original char" - except Exception as e: - pass - - # TEST 4: Edge cases and boundaries - # Boundary between 1-byte and 2-byte (0x7F vs 0x80) - one_byte_max = b"\x7f" # U+007F - last 1-byte character - two_byte_min = b"\xc2\x80" # U+0080 - first 2-byte character - - result_1 = one_byte_max.decode("utf-8") - result_2 = two_byte_min.decode("utf-8") - assert ord(result_1) == 0x7F - assert ord(result_2) == 0x80 - - # Boundary between 2-byte and 3-byte (0x7FF vs 0x800) - two_byte_max = b"\xdf\xbf" # U+07FF - last 2-byte character - result_3 = two_byte_max.decode("utf-8") - assert ord(result_3) == 0x7FF - - # TEST 5: Bit pattern validation details - bit_patterns = [ - (0x00, 0x00, "00xxxxxx", False), - (0x3F, 0x00, "00xxxxxx", False), - (0x40, 0x40, "01xxxxxx", False), - (0x7F, 0x40, "01xxxxxx", False), - (0x80, 0x80, "10xxxxxx", True), - (0xBF, 0x80, "10xxxxxx", True), - (0xC0, 0xC0, "11xxxxxx", False), - (0xFF, 0xC0, "11xxxxxx", False), - ] - - for byte_val, masked, pattern, valid in bit_patterns: - assert (byte_val & 0xC0) == masked, f"Bit masking incorrect for 0x{byte_val:02X}" - assert ((byte_val & 0xC0) == 0x80) == valid, f"Validation incorrect for 0x{byte_val:02X}" - assert True, "Complete 2-byte sequence coverage validated" - - -def test_utf8_3byte_sequence_complete_coverage(): - """ - Comprehensive test for 3-byte UTF-8 sequence handling in ddbc_bindings.h lines 490-506. - - Tests all code paths: - 1. Lines 492-495: Invalid continuation byte detection (both bytes) - 2. Lines 496-502: Valid decoding path - 3. Lines 499-502: Surrogate range rejection (0xD800-0xDFFF) - 4. Lines 504-505: Overlong encoding rejection - """ - import mssql_python - - # TEST 1: Lines 492-495 - Invalid continuation bytes - # Condition: (data[i + 1] & 0xC0) != 0x80 || (data[i + 2] & 0xC0) != 0x80 - - # Second byte invalid (third byte must be valid to isolate second byte error) - invalid_second_byte = [ - (b"\xe0\x00\x80", "Second byte 00xxxxxx"), - (b"\xe0\x40\x80", "Second byte 01xxxxxx"), - (b"\xe0\xc0\x80", "Second byte 11xxxxxx"), - (b"\xe4\xff\x80", "Second byte 11111111"), - ] - - for test_bytes, desc in invalid_second_byte: - try: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - except Exception: - pass - - # Third byte invalid (second byte must be valid to isolate third byte error) - invalid_third_byte = [ - (b"\xe0\xa0\x00", "Third byte 00xxxxxx"), - (b"\xe0\xa0\x40", "Third byte 01xxxxxx"), - (b"\xe4\xb8\xc0", "Third byte 11xxxxxx"), - (b"\xe4\xb8\xff", "Third byte 11111111"), - ] - - for test_bytes, desc in invalid_third_byte: - try: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - except Exception: - pass - - # Both bytes invalid - both_invalid = [ - (b"\xe0\x00\x00", "Both continuation bytes 00xxxxxx"), - (b"\xe0\x40\x40", "Both continuation bytes 01xxxxxx"), - (b"\xe0\xc0\xc0", "Both continuation bytes 11xxxxxx"), - ] - - for test_bytes, desc in both_invalid: - try: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - except Exception: - pass - - # TEST 2: Lines 496-502 - Valid decoding path - # Condition: cp >= 0x800 && (cp < 0xD800 || cp > 0xDFFF) - - valid_3byte = [ - (b"\xe0\xa0\x80", "\u0800", 0x0800, "U+0800 - minimum valid 3-byte"), - (b"\xe4\xb8\xad", "中", 0x4E2D, "U+4E2D - Chinese character"), - (b"\xe2\x82\xac", "€", 0x20AC, "U+20AC - Euro symbol"), - (b"\xed\x9f\xbf", "\ud7ff", 0xD7FF, "U+D7FF - just before surrogate range"), - (b"\xee\x80\x80", "\ue000", 0xE000, "U+E000 - just after surrogate range"), - (b"\xef\xbf\xbf", "\uffff", 0xFFFF, "U+FFFF - maximum valid 3-byte"), - ] - - for test_bytes, expected_char, codepoint, desc in valid_3byte: - result = test_bytes.decode("utf-8") - assert result == expected_char, f"Should decode to {expected_char!r}" - assert "\ufffd" not in result, f"Should NOT contain U+FFFD for valid sequence" - - binary_result = Binary(expected_char) - assert ( - binary_result == test_bytes - ), f"Binary({expected_char!r}) should encode to {test_bytes.hex()}" - - # TEST 3: Lines 499-502 - Surrogate range rejection - # Condition: cp < 0xD800 || cp > 0xDFFF (must be FALSE to reject) - - surrogate_encodings = [ - (b"\xed\xa0\x80", 0xD800, "U+D800 - high surrogate start"), - (b"\xed\xa0\xbf", 0xD83F, "U+D83F - within high surrogate range"), - (b"\xed\xaf\xbf", 0xDBFF, "U+DBFF - high surrogate end"), - (b"\xed\xb0\x80", 0xDC00, "U+DC00 - low surrogate start"), - (b"\xed\xb0\xbf", 0xDC3F, "U+DC3F - within low surrogate range"), - (b"\xed\xbf\xbf", 0xDFFF, "U+DFFF - low surrogate end"), - ] - - for test_bytes, codepoint, desc in surrogate_encodings: - try: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for surrogate U+{codepoint:04X}" - except ValueError: - pass - except Exception: - pass - - # TEST 4: Lines 504-505 - Overlong encoding rejection - # Condition: cp < 0x800 (overlong encoding) - - overlong_3byte = [ - (b"\xe0\x80\x80", 0x0000, "NULL character - security risk"), - (b"\xe0\x80\xaf", 0x002F, "Forward slash / - path traversal risk"), - (b"\xe0\x81\x81", 0x0041, "ASCII 'A' - should use 1 byte"), - (b"\xe0\x9f\xbf", 0x07FF, "U+07FF - should use 2 bytes"), - ] - - for test_bytes, codepoint, desc in overlong_3byte: - try: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for overlong U+{codepoint:04X}" - except Exception: - pass - - # TEST 5: Boundary testing - - # Boundary between 2-byte and 3-byte - two_byte_max = b"\xdf\xbf" # U+07FF - last 2-byte - three_byte_min = b"\xe0\xa0\x80" # U+0800 - first 3-byte - - result_2 = two_byte_max.decode("utf-8") - result_3 = three_byte_min.decode("utf-8") - assert ord(result_2) == 0x7FF - assert ord(result_3) == 0x800 - - # Surrogate boundaries - before_surrogate = b"\xed\x9f\xbf" # U+D7FF - last valid before surrogates - after_surrogate = b"\xee\x80\x80" # U+E000 - first valid after surrogates - - result_before = before_surrogate.decode("utf-8") - result_after = after_surrogate.decode("utf-8") - assert ord(result_before) == 0xD7FF - assert ord(result_after) == 0xE000 - - # Maximum 3-byte - three_byte_max = b"\xef\xbf\xbf" # U+FFFF - last 3-byte - result_max = three_byte_max.decode("utf-8") - assert ord(result_max) == 0xFFFF - - # TEST 6: Bit pattern validation for continuation bytes - - # Test various combinations - test_combinations = [ - (b"\xe0\x80\x80", "Valid: 10xxxxxx, 10xxxxxx", False), # Overlong, but valid pattern - (b"\xe0\xa0\x80", "Valid: 10xxxxxx, 10xxxxxx", True), # Valid all around - (b"\xe0\x00\x80", "Invalid: 00xxxxxx, 10xxxxxx", False), # First invalid - (b"\xe0\x80\x00", "Invalid: 10xxxxxx, 00xxxxxx", False), # Second invalid - (b"\xe0\xc0\x80", "Invalid: 11xxxxxx, 10xxxxxx", False), # First invalid - (b"\xe0\x80\xc0", "Invalid: 10xxxxxx, 11xxxxxx", False), # Second invalid - ] - - for test_bytes, desc, should_decode in test_combinations: - result = test_bytes.decode("utf-8", errors="replace") - byte2 = test_bytes[1] - byte3 = test_bytes[2] - byte2_valid = (byte2 & 0xC0) == 0x80 - byte3_valid = (byte3 & 0xC0) == 0x80 - - if byte2_valid and byte3_valid: - # Both valid - might be overlong or surrogate - pass - else: - # Invalid pattern - check it's handled - assert len(result) > 0, f"Invalid pattern should produce some output" - - assert True, "Complete 3-byte sequence coverage validated" - - -def test_utf8_4byte_sequence_complete_coverage(): - """ - Comprehensive test for 4-byte UTF-8 sequence handling in ddbc_bindings.h lines 508-530. - - Tests all code paths: - 1. Lines 512-514: Invalid continuation byte detection (any of 3 bytes) - 2. Lines 515-522: Valid decoding path - 3. Lines 519-522: Range validation (0x10000 <= cp <= 0x10FFFF) - 4. Lines 524-525: Overlong encoding rejection and out-of-range rejection - 5. Lines 528-529: Invalid sequence fallback - """ - import mssql_python - - # TEST 1: Lines 512-514 - Invalid continuation bytes - # Condition: (data[i+1] & 0xC0) != 0x80 || (data[i+2] & 0xC0) != 0x80 || (data[i+3] & 0xC0) != 0x80 - - # Second byte invalid (byte 1) - invalid_byte1 = [ - (b"\xf0\x00\x80\x80", "Byte 1: 00xxxxxx"), - (b"\xf0\x40\x80\x80", "Byte 1: 01xxxxxx"), - (b"\xf0\xc0\x80\x80", "Byte 1: 11xxxxxx"), - (b"\xf0\xff\x80\x80", "Byte 1: 11111111"), - ] - - for test_bytes, desc in invalid_byte1: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - - # Third byte invalid (byte 2) - invalid_byte2 = [ - (b"\xf0\x90\x00\x80", "Byte 2: 00xxxxxx"), - (b"\xf0\x90\x40\x80", "Byte 2: 01xxxxxx"), - (b"\xf0\x9f\xc0\x80", "Byte 2: 11xxxxxx"), - (b"\xf0\x90\xff\x80", "Byte 2: 11111111"), - ] - - for test_bytes, desc in invalid_byte2: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - - # Fourth byte invalid (byte 3) - invalid_byte3 = [ - (b"\xf0\x90\x80\x00", "Byte 3: 00xxxxxx"), - (b"\xf0\x90\x80\x40", "Byte 3: 01xxxxxx"), - (b"\xf0\x9f\x98\xc0", "Byte 3: 11xxxxxx"), - (b"\xf0\x90\x80\xff", "Byte 3: 11111111"), - ] - - for test_bytes, desc in invalid_byte3: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - - # Multiple bytes invalid - multiple_invalid = [ - (b"\xf0\x00\x00\x80", "Bytes 1+2 invalid"), - (b"\xf0\x00\x80\x00", "Bytes 1+3 invalid"), - (b"\xf0\x80\x00\x00", "Bytes 2+3 invalid"), - (b"\xf0\x00\x00\x00", "All continuation bytes invalid"), - ] - - for test_bytes, desc in multiple_invalid: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for {desc}" - - # TEST 2: Lines 515-522 - Valid decoding path - # Condition: cp >= 0x10000 && cp <= 0x10FFFF - - valid_4byte = [ - (b"\xf0\x90\x80\x80", "\U00010000", 0x10000, "U+10000 - minimum valid 4-byte"), - (b"\xf0\x9f\x98\x80", "😀", 0x1F600, "U+1F600 - grinning face emoji"), - (b"\xf0\x9f\x98\x81", "😁", 0x1F601, "U+1F601 - beaming face emoji"), - (b"\xf0\x9f\x8c\x8d", "🌍", 0x1F30D, "U+1F30D - earth globe emoji"), - (b"\xf3\xb0\x80\x80", "\U000f0000", 0xF0000, "U+F0000 - private use area"), - (b"\xf4\x8f\xbf\xbf", "\U0010ffff", 0x10FFFF, "U+10FFFF - maximum valid Unicode"), - ] - - for test_bytes, expected_char, codepoint, desc in valid_4byte: - # Test decoding - result = test_bytes.decode("utf-8") - assert result == expected_char, f"Should decode to {expected_char!r}" - assert "\ufffd" not in result, f"Should NOT contain U+FFFD for valid sequence" - - # Test encoding via Binary() - binary_result = Binary(expected_char) - assert ( - binary_result == test_bytes - ), f"Binary({expected_char!r}) should encode to {test_bytes.hex()}" - - # TEST 3: Lines 524-525 - Overlong encoding rejection - # Condition: cp < 0x10000 (overlong encoding) - - overlong_4byte = [ - (b"\xf0\x80\x80\x80", 0x0000, "NULL character - security risk"), - (b"\xf0\x80\x80\xaf", 0x002F, "Forward slash / - path traversal risk"), - (b"\xf0\x80\x81\x81", 0x0041, "ASCII 'A' - should use 1 byte"), - (b"\xf0\x8f\xbf\xbf", 0xFFFF, "U+FFFF - should use 3 bytes"), - ] - - for test_bytes, codepoint, desc in overlong_4byte: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0, f"Should produce some output for overlong U+{codepoint:04X}" - - # TEST 4: Lines 524-525 - Out of range rejection - # Condition: cp > 0x10FFFF (beyond maximum Unicode) - - out_of_range = [ - (b"\xf4\x90\x80\x80", 0x110000, "U+110000 - just beyond max Unicode"), - (b"\xf7\xbf\xbf\xbf", 0x1FFFFF, "U+1FFFFF - far beyond max Unicode"), - (b"\xf4\x90\x80\x81", 0x110001, "U+110001 - beyond max Unicode"), - ] - - for test_bytes, codepoint, desc in out_of_range: - result = test_bytes.decode("utf-8", errors="replace") - # Should be rejected (behavior may vary by platform) - assert len(result) > 0, f"Should produce some output for out-of-range U+{codepoint:06X}" - - # TEST 5: Lines 528-529 - Invalid sequence fallback - - # These are invalid start bytes or sequences that don't match any pattern - invalid_sequences = [ - (b"\xf8\x80\x80\x80", "Invalid start byte 11111xxx"), - (b"\xfc\x80\x80\x80", "Invalid start byte 111111xx"), - (b"\xfe\x80\x80\x80", "Invalid start byte 1111111x"), - (b"\xff\x80\x80\x80", "Invalid start byte 11111111"), - ] - - for test_bytes, desc in invalid_sequences: - result = test_bytes.decode("utf-8", errors="replace") - # Check that invalid sequences are handled - assert len(result) > 0, f"Should produce some output for invalid sequence" - - # TEST 6: Boundary testing - - # Boundary between 3-byte and 4-byte - three_byte_max = b"\xef\xbf\xbf" # U+FFFF - last 3-byte - four_byte_min = b"\xf0\x90\x80\x80" # U+10000 - first 4-byte - - result_3 = three_byte_max.decode("utf-8") - result_4 = four_byte_min.decode("utf-8") - assert ord(result_3) == 0xFFFF - assert ord(result_4) == 0x10000 - - # Maximum valid Unicode - max_unicode = b"\xf4\x8f\xbf\xbf" # U+10FFFF - beyond_max = b"\xf4\x90\x80\x80" # U+110000 (invalid) - - result_max = max_unicode.decode("utf-8") - result_beyond = beyond_max.decode("utf-8", errors="replace") - assert ord(result_max) == 0x10FFFF - # Beyond max may be handled differently on different platforms - assert len(result_beyond) > 0, "Should produce some output for beyond-max sequence" - - # TEST 7: Bit pattern validation for continuation bytes - - # Test various combinations - test_patterns = [ - (b"\xf0\x90\x80\x80", "Valid: all 10xxxxxx", True), - (b"\xf0\x90\x80\xbf", "Valid: all 10xxxxxx", True), - (b"\xf0\x00\x80\x80", "Invalid: byte1 00xxxxxx", False), - (b"\xf0\x90\x00\x80", "Invalid: byte2 00xxxxxx", False), - (b"\xf0\x90\x80\x00", "Invalid: byte3 00xxxxxx", False), - (b"\xf0\xc0\x80\x80", "Invalid: byte1 11xxxxxx", False), - (b"\xf0\x90\xc0\x80", "Invalid: byte2 11xxxxxx", False), - (b"\xf0\x90\x80\xc0", "Invalid: byte3 11xxxxxx", False), - ] - - for test_bytes, desc, should_have_valid_pattern in test_patterns: - result = test_bytes.decode("utf-8", errors="replace") - byte1 = test_bytes[1] - byte2 = test_bytes[2] - byte3 = test_bytes[3] - byte1_valid = (byte1 & 0xC0) == 0x80 - byte2_valid = (byte2 & 0xC0) == 0x80 - byte3_valid = (byte3 & 0xC0) == 0x80 - all_valid = byte1_valid and byte2_valid and byte3_valid - - if all_valid: - # All continuation bytes valid - additional range/overlong handling may still apply - pass - else: - # Invalid pattern - check it's handled - assert len(result) > 0, f"Invalid pattern should produce some output" - - assert True, "Complete 4-byte sequence coverage validated" diff --git a/tests/test_003_connection.py b/tests/test_003_connection.py index d4536fdbe..ad568a960 100644 --- a/tests/test_003_connection.py +++ b/tests/test_003_connection.py @@ -4839,38 +4839,6 @@ def test_batch_execute_with_existing_cursor_reuse(db_connection): existing_cursor.close() -def test_connection_close_with_problematic_cursors(conn_str): - """Test connection close behavior when cursors have issues.""" - - conn = connect(conn_str) - - # Create several cursors, some of which we'll manipulate to cause issues - cursor1 = conn.cursor() - cursor2 = conn.cursor() - cursor3 = conn.cursor() - - # Execute some operations to make them active - cursor1.execute("SELECT 1") - cursor1.fetchall() - - cursor2.execute("SELECT 2") - cursor2.fetchall() - - # Close one cursor manually but leave it in the cursors set - cursor3.execute("SELECT 3") - cursor3.fetchall() - cursor3.close() # This should trigger _remove_cursor - - # Now close the connection - this should try to close remaining cursors - # and trigger the cursor cleanup code (lines 1325-1335) - conn.close() - - # All cursors should be closed now - assert cursor1.closed, "Cursor1 should be closed" - assert cursor2.closed, "Cursor2 should be closed" - assert cursor3.closed, "Cursor3 should already be closed" - - def test_connection_searchescape_property_detailed(db_connection): """Test detailed searchescape property behavior including edge cases.""" diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 6900be7ea..dbd917879 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -1360,43 +1360,6 @@ def test_parametrized_insert(cursor, db_connection, data): pytest.fail(f"Parameterized data insertion/fetch failed: {e}") -def test_rowcount(cursor, db_connection): - """Test rowcount after insert operations""" - try: - cursor.execute( - "CREATE TABLE #pytest_test_rowcount (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100))" - ) - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe1');") - assert cursor.rowcount == 1, "Rowcount should be 1 after first insert" - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe2');") - assert cursor.rowcount == 1, "Rowcount should be 1 after second insert" - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe3');") - assert cursor.rowcount == 1, "Rowcount should be 1 after third insert" - - cursor.execute(""" - INSERT INTO #pytest_test_rowcount (name) - VALUES - ('JohnDoe4'), - ('JohnDoe5'), - ('JohnDoe6'); - """) - assert cursor.rowcount == 3, "Rowcount should be 3 after inserting multiple rows" - - cursor.execute("SELECT * FROM #pytest_test_rowcount;") - assert cursor.rowcount == -1, "Rowcount should be -1 after a SELECT statement" - - db_connection.commit() - except Exception as e: - pytest.fail(f"Rowcount test failed: {e}") - finally: - cursor.execute("DROP TABLE #pytest_test_rowcount") - db_connection.commit() - - def test_rowcount_executemany(cursor, db_connection): """Test rowcount after executemany operations""" try: @@ -9712,1198 +9675,1029 @@ def test_executemany_uuid_roundtrip_fixed_value(cursor, db_connection): db_connection.commit() -def test_decimal_separator_with_multiple_values(cursor, db_connection): - """Test decimal separator with multiple different decimal values""" - original_separator = mssql_python.getDecimalSeparator() - +@pytest.mark.skipif(not os.getenv("DB_CONNECTION_STRING"), reason="Requires DB_CONNECTION_STRING") +def test_decimal_separator_fetch_regression(cursor, db_connection): + """ + Test that fetchall() dealing with DECIMALS works correctly even when + setDecimalSeparator is set to something other than '.' + """ try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_multi_test ( - id INT PRIMARY KEY, - positive_value DECIMAL(10, 2), - negative_value DECIMAL(10, 2), - zero_value DECIMAL(10, 2), - small_value DECIMAL(10, 4) - ) - """) - db_connection.commit() - - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_multi_test VALUES (1, 123.45, -67.89, 0.00, 0.0001) - """) + # Create a temp table + cursor.execute("CREATE TABLE #TestDecimal (Val DECIMAL(10, 2))") + cursor.execute("INSERT INTO #TestDecimal VALUES (1234.56)") + cursor.execute("INSERT INTO #TestDecimal VALUES (78.90)") db_connection.commit() - # Test with default separator first - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default positive value formatting incorrect" - assert "-67.89" in default_str, "Default negative value formatting incorrect" - - # Change to comma separator + # Set custom separator mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") - row = cursor.fetchone() - comma_str = str(row) - # Verify comma is used in all decimal values - assert "123,45" in comma_str, "Positive value not formatted with comma" - assert "-67,89" in comma_str, "Negative value not formatted with comma" - assert "0,00" in comma_str, "Zero value not formatted with comma" - assert "0,0001" in comma_str, "Small value not formatted with comma" + # Test fetchall + cursor.execute("SELECT Val FROM #TestDecimal ORDER BY Val") + rows = cursor.fetchall() - finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) + # Verify fetchall results + assert len(rows) == 2, f"Expected 2 rows, got {len(rows)}" + assert isinstance(rows[0][0], decimal.Decimal), f"Expected Decimal, got {type(rows[0][0])}" + assert rows[0][0] == decimal.Decimal("78.90"), f"Expected 78.90, got {rows[0][0]}" + assert rows[1][0] == decimal.Decimal("1234.56"), f"Expected 1234.56, got {rows[1][0]}" - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_multi_test") - db_connection.commit() + # Verify fetchmany + cursor.execute("SELECT Val FROM #TestDecimal ORDER BY Val") + batch = cursor.fetchmany(2) + assert len(batch) == 2 + assert batch[1][0] == decimal.Decimal("1234.56") + # Verify fetchone behavior is consistent + cursor.execute("SELECT CAST(99.99 AS DECIMAL(10,2))") + val = cursor.fetchone()[0] + assert isinstance(val, decimal.Decimal) + assert val == decimal.Decimal("99.99") -def test_decimal_separator_calculations(cursor, db_connection): - """Test that decimal separator doesn't affect calculations""" - original_separator = mssql_python.getDecimalSeparator() + finally: + # Reset separator to default just in case + mssql_python.setDecimalSeparator(".") + try: + cursor.execute("DROP TABLE IF EXISTS #TestDecimal") + db_connection.commit() + except Exception: + pass + +def test_datetimeoffset_read_write(cursor, db_connection): + """Test reading and writing timezone-aware DATETIMEOFFSET values.""" try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_calc_test ( - id INT PRIMARY KEY, - value1 DECIMAL(10, 2), - value2 DECIMAL(10, 2) + test_cases = [ + # Valid timezone-aware datetimes + datetime(2023, 10, 26, 10, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))), + datetime(2023, 10, 27, 15, 45, 10, 123456, tzinfo=timezone(timedelta(hours=-8))), + datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone.utc), + ] + + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" ) - """) db_connection.commit() - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_calc_test VALUES (1, 10.25, 5.75) - """) + insert_stmt = ( + "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" + ) + for i, dt in enumerate(test_cases): + cursor.execute(insert_stmt, i, dt) db_connection.commit() - # Test with default separator - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation incorrect with default separator" - - # Change to comma separator - mssql_python.setDecimalSeparator(",") - - # Calculations should still work correctly - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation affected by separator change" - - # But string representation should use comma - assert "16,00" in str(row), "Sum result not formatted with comma in string representation" - + cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") + for i, dt in enumerate(test_cases): + row = cursor.fetchone() + assert row is not None + fetched_id, fetched_dt = row + assert fetched_dt.tzinfo is not None + assert fetched_dt == dt finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) - - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") db_connection.commit() -def test_decimal_separator_function(cursor, db_connection): - """Test decimal separator functionality with database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() - +def test_datetimeoffset_max_min_offsets(cursor, db_connection): + """ + Test inserting and retrieving DATETIMEOFFSET with maximum and minimum allowed offsets (+14:00 and -14:00). + Uses fetchone() for retrieval. + """ try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_separator_test ( - id INT PRIMARY KEY, - decimal_value DECIMAL(10, 2) + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" ) - """) db_connection.commit() - # Insert test values with default separator (.) - test_value = decimal.Decimal("123.45") - cursor.execute( - """ - INSERT INTO #pytest_decimal_separator_test (id, decimal_value) - VALUES (1, ?) - """, - [test_value], + test_cases = [ + ( + 1, + datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=14))), + ), # max offset + ( + 2, + datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=-14))), + ), # min offset + ] + + insert_stmt = ( + "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" ) + for row_id, dt in test_cases: + cursor.execute(insert_stmt, row_id, dt) db_connection.commit() - # First test with default decimal separator (.) - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default separator not found in string representation" + cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") - # Now change to comma separator and test string representation - mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() + for expected_id, expected_dt in test_cases: + row = cursor.fetchone() + assert row is not None, f"No row fetched for id {expected_id}." + fetched_id, fetched_dt = row - # This should format the decimal with a comma in the string representation - comma_str = str(row) - assert ( - "123,45" in comma_str - ), f"Expected comma in string representation but got: {comma_str}" + assert ( + fetched_id == expected_id + ), f"ID mismatch: expected {expected_id}, got {fetched_id}" + assert ( + fetched_dt.tzinfo is not None + ), f"Fetched datetime object is naive for id {fetched_id}" - finally: - # Restore original decimal separator - mssql_python.setDecimalSeparator(original_separator) + assert ( + fetched_dt == expected_dt + ), f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}" - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_separator_test") + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") db_connection.commit() -def test_decimal_separator_basic_functionality(): - """Test basic decimal separator functionality without database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() - +def test_datetimeoffset_invalid_offsets(cursor, db_connection): + """Verify driver rejects offsets beyond ±14 hours.""" try: - # Test default value - assert mssql_python.getDecimalSeparator() == ".", "Default decimal separator should be '.'" - - # Test setting to comma - mssql_python.setDecimalSeparator(",") - assert ( - mssql_python.getDecimalSeparator() == "," - ), "Decimal separator should be ',' after setting" - - # Test setting to other valid separators - mssql_python.setDecimalSeparator(":") - assert ( - mssql_python.getDecimalSeparator() == ":" - ), "Decimal separator should be ':' after setting" - - # Test invalid inputs - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("") # Empty string - - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("too_long") # More than one character + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_invalid_offsets (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" + ) + db_connection.commit() - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator(123) # Not a string + with pytest.raises(Exception): + cursor.execute( + "INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", + 1, + datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=15))), + ) + with pytest.raises(Exception): + cursor.execute( + "INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", + 2, + datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=-15))), + ) finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) - - -def test_lowercase_attribute(cursor, db_connection): - """Test that the lowercase attribute properly converts column names to lowercase""" + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_invalid_offsets;") + db_connection.commit() - # Store original value to restore after test - original_lowercase = mssql_python.lowercase - drop_cursor = None +def test_datetimeoffset_dst_transitions(cursor, db_connection): + """ + Test inserting and retrieving DATETIMEOFFSET values around DST transitions. + Ensures that driver handles DST correctly and does not crash. + """ try: - # Create a test table with mixed-case column names - cursor.execute(""" - CREATE TABLE #pytest_lowercase_test ( - ID INT PRIMARY KEY, - UserName VARCHAR(50), - EMAIL_ADDRESS VARCHAR(100), - PhoneNumber VARCHAR(20) + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_dst_transitions (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" ) - """) - db_connection.commit() - - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_lowercase_test (ID, UserName, EMAIL_ADDRESS, PhoneNumber) - VALUES (1, 'JohnDoe', 'john@example.com', '555-1234') - """) db_connection.commit() - # First test with lowercase=False (default) - mssql_python.lowercase = False - cursor1 = db_connection.cursor() - cursor1.execute("SELECT * FROM #pytest_lowercase_test") - - # Description column names should preserve original case - column_names1 = [desc[0] for desc in cursor1.description] - assert "ID" in column_names1, "Column 'ID' should be present with original case" - assert "UserName" in column_names1, "Column 'UserName' should be present with original case" + # Example DST transition dates (replace with actual region offset if needed) + dst_test_cases = [ + ( + 1, + datetime(2025, 3, 9, 1, 59, 59, tzinfo=timezone(timedelta(hours=-5))), + ), # Just before spring forward + ( + 2, + datetime(2025, 3, 9, 3, 0, 0, tzinfo=timezone(timedelta(hours=-4))), + ), # Just after spring forward + ( + 3, + datetime(2025, 11, 2, 1, 59, 59, tzinfo=timezone(timedelta(hours=-4))), + ), # Just before fall back + ( + 4, + datetime(2025, 11, 2, 1, 0, 0, tzinfo=timezone(timedelta(hours=-5))), + ), # Just after fall back + ] - # Make sure to consume all results and close the cursor - cursor1.fetchall() - cursor1.close() + insert_stmt = ( + "INSERT INTO #pytest_datetimeoffset_dst_transitions (id, dto_column) VALUES (?, ?);" + ) + for row_id, dt in dst_test_cases: + cursor.execute(insert_stmt, row_id, dt) + db_connection.commit() - # Now test with lowercase=True - mssql_python.lowercase = True - cursor2 = db_connection.cursor() - cursor2.execute("SELECT * FROM #pytest_lowercase_test") + cursor.execute( + "SELECT id, dto_column FROM #pytest_datetimeoffset_dst_transitions ORDER BY id;" + ) - # Description column names should be lowercase - column_names2 = [desc[0] for desc in cursor2.description] - assert "id" in column_names2, "Column names should be lowercase when lowercase=True" - assert "username" in column_names2, "Column names should be lowercase when lowercase=True" + for expected_id, expected_dt in dst_test_cases: + row = cursor.fetchone() + assert row is not None, f"No row fetched for id {expected_id}." + fetched_id, fetched_dt = row - # Make sure to consume all results and close the cursor - cursor2.fetchall() - cursor2.close() + assert ( + fetched_id == expected_id + ), f"ID mismatch: expected {expected_id}, got {fetched_id}" + assert ( + fetched_dt.tzinfo is not None + ), f"Fetched datetime object is naive for id {fetched_id}" - # Create a fresh cursor for cleanup - drop_cursor = db_connection.cursor() + assert ( + fetched_dt == expected_dt + ), f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}" finally: - # Restore original value - mssql_python.lowercase = original_lowercase - - try: - # Use a separate cursor for cleanup - if drop_cursor: - drop_cursor.execute("DROP TABLE IF EXISTS #pytest_lowercase_test") - db_connection.commit() - drop_cursor.close() - except Exception as e: - print(f"Warning: Failed to drop test table: {e}") - + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;") + db_connection.commit() -def test_decimal_separator_function(cursor, db_connection): - """Test decimal separator functionality with database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() +def test_datetimeoffset_leap_second(cursor, db_connection): + """Ensure driver handles leap-second-like microsecond edge cases without crashing.""" try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_separator_test ( - id INT PRIMARY KEY, - decimal_value DECIMAL(10, 2) + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_leap_second (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" ) - """) db_connection.commit() - # Insert test values with default separator (.) - test_value = decimal.Decimal("123.45") + leap_second_sim = datetime(2023, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc) cursor.execute( - """ - INSERT INTO #pytest_decimal_separator_test (id, decimal_value) - VALUES (1, ?) - """, - [test_value], + "INSERT INTO #pytest_datetimeoffset_leap_second (id, dto_column) VALUES (?, ?);", + 1, + leap_second_sim, ) db_connection.commit() - # First test with default decimal separator (.) - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default separator not found in string representation" - - # Now change to comma separator and test string representation - mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() - - # This should format the decimal with a comma in the string representation - comma_str = str(row) - assert ( - "123,45" in comma_str - ), f"Expected comma in string representation but got: {comma_str}" - + row = cursor.execute( + "SELECT dto_column FROM #pytest_datetimeoffset_leap_second;" + ).fetchone() + assert row[0].tzinfo is not None finally: - # Restore original decimal separator - mssql_python.setDecimalSeparator(original_separator) - - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_separator_test") + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_leap_second;") db_connection.commit() -def test_decimal_separator_basic_functionality(): - """Test basic decimal separator functionality without database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() - +def test_datetimeoffset_malformed_input(cursor, db_connection): + """Verify driver raises error for invalid datetimeoffset strings.""" try: - # Test default value - assert mssql_python.getDecimalSeparator() == ".", "Default decimal separator should be '.'" - - # Test setting to comma - mssql_python.setDecimalSeparator(",") - assert ( - mssql_python.getDecimalSeparator() == "," - ), "Decimal separator should be ',' after setting" - - # Test setting to other valid separators - mssql_python.setDecimalSeparator(":") - assert ( - mssql_python.getDecimalSeparator() == ":" - ), "Decimal separator should be ':' after setting" - - # Test invalid inputs - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("") # Empty string - - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("too_long") # More than one character - - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator(123) # Not a string + cursor.execute( + "CREATE TABLE #pytest_datetimeoffset_malformed_input (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" + ) + db_connection.commit() + with pytest.raises(Exception): + cursor.execute( + "INSERT INTO #pytest_datetimeoffset_malformed_input (id, dto_column) VALUES (?, ?);", + 1, + "2023-13-45 25:61:00 +99:99", + ) # invalid string finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;") + db_connection.commit() -def test_decimal_separator_with_multiple_values(cursor, db_connection): - """Test decimal separator with multiple different decimal values""" - original_separator = mssql_python.getDecimalSeparator() - +def test_datetimeoffset_executemany(cursor, db_connection): + """ + Test the driver's ability to correctly read and write DATETIMEOFFSET data + using executemany, including timezone information. + """ try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_multi_test ( - id INT PRIMARY KEY, - positive_value DECIMAL(10, 2), - negative_value DECIMAL(10, 2), - zero_value DECIMAL(10, 2), - small_value DECIMAL(10, 4) + datetimeoffset_test_cases = [ + ( + "2023-10-26 10:30:00.0000000 +05:30", + datetime( + 2023, + 10, + 26, + 10, + 30, + 0, + 0, + tzinfo=timezone(timedelta(hours=5, minutes=30)), + ), + ), + ( + "2023-10-27 15:45:10.1234567 -08:00", + datetime( + 2023, + 10, + 27, + 15, + 45, + 10, + 123456, + tzinfo=timezone(timedelta(hours=-8)), + ), + ), + ( + "2023-10-28 20:00:05.9876543 +00:00", + datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone(timedelta(hours=0))), + ), + ] + + # Create temp table + cursor.execute( + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" ) - """) + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") db_connection.commit() - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_multi_test VALUES (1, 123.45, -67.89, 0.00, 0.0001) - """) + # Prepare data for executemany + param_list = [(i, python_dt) for i, (_, python_dt) in enumerate(datetimeoffset_test_cases)] + cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) db_connection.commit() - # Test with default separator first - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default positive value formatting incorrect" - assert "-67.89" in default_str, "Default negative value formatting incorrect" - - # Change to comma separator - mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") - row = cursor.fetchone() - comma_str = str(row) + # Read back and validate + cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() - # Verify comma is used in all decimal values - assert "123,45" in comma_str, "Positive value not formatted with comma" - assert "-67,89" in comma_str, "Negative value not formatted with comma" - assert "0,00" in comma_str, "Zero value not formatted with comma" - assert "0,0001" in comma_str, "Small value not formatted with comma" + for i, (sql_str, python_dt) in enumerate(datetimeoffset_test_cases): + fetched_id, fetched_dto = rows[i] + assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive." + assert ( + fetched_dto == python_dt + ), f"Value mismatch for id {fetched_id}: expected {python_dt}, got {fetched_dto}" finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) - - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_multi_test") + cursor.execute( + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + ) db_connection.commit() -def test_decimal_separator_calculations(cursor, db_connection): - """Test that decimal separator doesn't affect calculations""" - original_separator = mssql_python.getDecimalSeparator() - +def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection): + """ + Check that execute() and executemany() produce the same stored DATETIMEOFFSET + for identical timezone-aware datetime objects. + """ try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_calc_test ( - id INT PRIMARY KEY, - value1 DECIMAL(10, 2), - value2 DECIMAL(10, 2) + test_dt = datetime( + 2023, + 10, + 30, + 12, + 0, + 0, + microsecond=123456, + tzinfo=timezone(timedelta(hours=5, minutes=30)), ) - """) + cursor.execute( + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + ) + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") db_connection.commit() - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_calc_test VALUES (1, 10.25, 5.75) - """) + # Insert using execute() + cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt) db_connection.commit() - # Test with default separator - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation incorrect with default separator" - - # Change to comma separator - mssql_python.setDecimalSeparator(",") + # Insert using executemany() + cursor.executemany( + "INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", [(2, test_dt)] + ) + db_connection.commit() - # Calculations should still work correctly - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation affected by separator change" + cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() + assert len(rows) == 2 - # But string representation should use comma - assert "16,00" in str(row), "Sum result not formatted with comma in string representation" + # Compare textual representation to ensure binding semantics match + cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;") + textual_rows = [r[0] for r in cursor.fetchall()] + assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ" finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) - - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") + cursor.execute( + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + ) db_connection.commit() -@pytest.mark.skipif(not os.getenv("DB_CONNECTION_STRING"), reason="Requires DB_CONNECTION_STRING") -def test_decimal_separator_fetch_regression(cursor, db_connection): +def test_datetimeoffset_extreme_offsets(cursor, db_connection): """ - Test that fetchall() dealing with DECIMALS works correctly even when - setDecimalSeparator is set to something other than '.' + Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling. """ try: - # Create a temp table - cursor.execute("CREATE TABLE #TestDecimal (Val DECIMAL(10, 2))") - cursor.execute("INSERT INTO #TestDecimal VALUES (1234.56)") - cursor.execute("INSERT INTO #TestDecimal VALUES (78.90)") - db_connection.commit() - - # Set custom separator - mssql_python.setDecimalSeparator(",") - - # Test fetchall - cursor.execute("SELECT Val FROM #TestDecimal ORDER BY Val") - rows = cursor.fetchall() - - # Verify fetchall results - assert len(rows) == 2, f"Expected 2 rows, got {len(rows)}" - assert isinstance(rows[0][0], decimal.Decimal), f"Expected Decimal, got {type(rows[0][0])}" - assert rows[0][0] == decimal.Decimal("78.90"), f"Expected 78.90, got {rows[0][0]}" - assert rows[1][0] == decimal.Decimal("1234.56"), f"Expected 1234.56, got {rows[1][0]}" - - # Verify fetchmany - cursor.execute("SELECT Val FROM #TestDecimal ORDER BY Val") - batch = cursor.fetchmany(2) - assert len(batch) == 2 - assert batch[1][0] == decimal.Decimal("1234.56") - - # Verify fetchone behavior is consistent - cursor.execute("SELECT CAST(99.99 AS DECIMAL(10,2))") - val = cursor.fetchone()[0] - assert isinstance(val, decimal.Decimal) - assert val == decimal.Decimal("99.99") - - finally: - # Reset separator to default just in case - mssql_python.setDecimalSeparator(".") - try: - cursor.execute("DROP TABLE IF EXISTS #TestDecimal") - db_connection.commit() - except Exception: - pass - - -def test_datetimeoffset_read_write(cursor, db_connection): - """Test reading and writing timezone-aware DATETIMEOFFSET values.""" - try: - test_cases = [ - # Valid timezone-aware datetimes - datetime(2023, 10, 26, 10, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))), - datetime(2023, 10, 27, 15, 45, 10, 123456, tzinfo=timezone(timedelta(hours=-8))), - datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone.utc), + extreme_offsets = [ + datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))), + datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))), ] cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" ) + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") db_connection.commit() - insert_stmt = ( - "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" - ) - for i, dt in enumerate(test_cases): - cursor.execute(insert_stmt, i, dt) + param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)] + cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) db_connection.commit() - cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") - for i, dt in enumerate(test_cases): - row = cursor.fetchone() - assert row is not None - fetched_id, fetched_dt = row - assert fetched_dt.tzinfo is not None - assert fetched_dt == dt + cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() + + for i, dt in enumerate(extreme_offsets): + _, fetched = rows[i] + assert fetched.tzinfo is not None + assert fetched == dt, f"Value mismatch for id {i}: expected {dt}, got {fetched}" finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") + cursor.execute( + "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + ) db_connection.commit() -def test_datetimeoffset_max_min_offsets(cursor, db_connection): +def test_datetimeoffset_native_vs_string_simple(cursor, db_connection): """ - Test inserting and retrieving DATETIMEOFFSET with maximum and minimum allowed offsets (+14:00 and -14:00). - Uses fetchone() for retrieval. + Replicates the user's testing scenario: fetch DATETIMEOFFSET as native datetime + and as string using CONVERT(nvarchar(35), ..., 121). """ try: cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" + "CREATE TABLE #pytest_dto_user_test (id INT PRIMARY KEY, Systime DATETIMEOFFSET);" ) db_connection.commit() - test_cases = [ + # Insert rows similar to user's example + test_rows = [ ( 1, - datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=14))), - ), # max offset + datetime(2025, 5, 14, 12, 35, 52, 501000, tzinfo=timezone(timedelta(hours=1))), + ), ( 2, - datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=-14))), - ), # min offset + datetime( + 2025, + 5, + 14, + 15, + 20, + 30, + 123000, + tzinfo=timezone(timedelta(hours=-5)), + ), + ), ] - insert_stmt = ( - "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" + for i, dt in test_rows: + cursor.execute("INSERT INTO #pytest_dto_user_test (id, Systime) VALUES (?, ?);", i, dt) + db_connection.commit() + + # Native fetch (like the user's first execute) + cursor.execute("SELECT Systime FROM #pytest_dto_user_test WHERE id=1;") + dt_native = cursor.fetchone()[0] + assert dt_native.tzinfo is not None + assert dt_native == test_rows[0][1] + + # String fetch (like the user's convert to nvarchar) + cursor.execute( + "SELECT CONVERT(nvarchar(35), Systime, 121) FROM #pytest_dto_user_test WHERE id=1;" ) - for row_id, dt in test_cases: - cursor.execute(insert_stmt, row_id, dt) + dt_str = cursor.fetchone()[0] + assert dt_str.endswith("+01:00") # original offset preserved + + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_dto_user_test;") db_connection.commit() - cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") - for expected_id, expected_dt in test_cases: - row = cursor.fetchone() - assert row is not None, f"No row fetched for id {expected_id}." - fetched_id, fetched_dt = row +def test_cursor_setinputsizes_basic(db_connection): + """Test the basic functionality of setinputsizes""" - assert ( - fetched_id == expected_id - ), f"ID mismatch: expected {expected_id}, got {fetched_id}" - assert ( - fetched_dt.tzinfo is not None - ), f"Fetched datetime object is naive for id {fetched_id}" + cursor = db_connection.cursor() - assert ( - fetched_dt == expected_dt - ), f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}" + # Create a test table + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes") + cursor.execute(""" + CREATE TABLE #test_inputsizes ( + string_col NVARCHAR(100), + int_col INT + ) + """) - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") - db_connection.commit() + # Set input sizes for parameters + cursor.setinputsizes([(mssql_python.SQL_WVARCHAR, 100, 0), (mssql_python.SQL_INTEGER, 0, 0)]) + # Execute with parameters + cursor.execute("INSERT INTO #test_inputsizes VALUES (?, ?)", "Test String", 42) -def test_datetimeoffset_invalid_offsets(cursor, db_connection): - """Verify driver rejects offsets beyond ±14 hours.""" - try: - cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_invalid_offsets (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" - ) - db_connection.commit() + # Verify data was inserted correctly + cursor.execute("SELECT * FROM #test_inputsizes") + row = cursor.fetchone() - with pytest.raises(Exception): - cursor.execute( - "INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", - 1, - datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=15))), - ) + assert row[0] == "Test String" + assert row[1] == 42 - with pytest.raises(Exception): - cursor.execute( - "INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", - 2, - datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=-15))), - ) - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_invalid_offsets;") - db_connection.commit() + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes") -def test_datetimeoffset_dst_transitions(cursor, db_connection): - """ - Test inserting and retrieving DATETIMEOFFSET values around DST transitions. - Ensures that driver handles DST correctly and does not crash. - """ - try: - cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_dst_transitions (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" - ) - db_connection.commit() +def test_cursor_setinputsizes_with_executemany_float(db_connection): + """Test setinputsizes with executemany using float instead of Decimal""" - # Example DST transition dates (replace with actual region offset if needed) - dst_test_cases = [ - ( - 1, - datetime(2025, 3, 9, 1, 59, 59, tzinfo=timezone(timedelta(hours=-5))), - ), # Just before spring forward - ( - 2, - datetime(2025, 3, 9, 3, 0, 0, tzinfo=timezone(timedelta(hours=-4))), - ), # Just after spring forward - ( - 3, - datetime(2025, 11, 2, 1, 59, 59, tzinfo=timezone(timedelta(hours=-4))), - ), # Just before fall back - ( - 4, - datetime(2025, 11, 2, 1, 0, 0, tzinfo=timezone(timedelta(hours=-5))), - ), # Just after fall back + cursor = db_connection.cursor() + + # Create a test table + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float") + cursor.execute(""" + CREATE TABLE #test_inputsizes_float ( + id INT, + name NVARCHAR(50), + price REAL /* Use REAL instead of DECIMAL */ + ) + """) + + # Prepare data with float values + data = [(1, "Item 1", 10.99), (2, "Item 2", 20.50), (3, "Item 3", 30.75)] + + # Set input sizes for parameters + cursor.setinputsizes( + [ + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_WVARCHAR, 50, 0), + (mssql_python.SQL_REAL, 0, 0), ] + ) - insert_stmt = ( - "INSERT INTO #pytest_datetimeoffset_dst_transitions (id, dto_column) VALUES (?, ?);" - ) - for row_id, dt in dst_test_cases: - cursor.execute(insert_stmt, row_id, dt) - db_connection.commit() + # Execute with parameters + cursor.executemany("INSERT INTO #test_inputsizes_float VALUES (?, ?, ?)", data) - cursor.execute( - "SELECT id, dto_column FROM #pytest_datetimeoffset_dst_transitions ORDER BY id;" - ) + # Verify all data was inserted correctly + cursor.execute("SELECT * FROM #test_inputsizes_float ORDER BY id") + rows = cursor.fetchall() - for expected_id, expected_dt in dst_test_cases: - row = cursor.fetchone() - assert row is not None, f"No row fetched for id {expected_id}." - fetched_id, fetched_dt = row + assert len(rows) == 3 + assert rows[0][0] == 1 + assert rows[0][1] == "Item 1" + assert abs(rows[0][2] - 10.99) < 0.001 - assert ( - fetched_id == expected_id - ), f"ID mismatch: expected {expected_id}, got {fetched_id}" - assert ( - fetched_dt.tzinfo is not None - ), f"Fetched datetime object is naive for id {fetched_id}" + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float") - assert ( - fetched_dt == expected_dt - ), f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}" - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;") - db_connection.commit() +def test_setinputsizes_sql_decimal_with_executemany(db_connection): + """Test setinputsizes with SQL_DECIMAL accepts Python Decimal values (GH-503). + Without this fix, passing SQL_DECIMAL or SQL_NUMERIC via setinputsizes() + caused a RuntimeError because Decimal objects were not converted to + NumericData before the C binding validated the C type. + """ + cursor = db_connection.cursor() -def test_datetimeoffset_leap_second(cursor, db_connection): - """Ensure driver handles leap-second-like microsecond edge cases without crashing.""" + cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") try: - cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_leap_second (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" + cursor.execute(""" + CREATE TABLE #test_sis_decimal ( + Name NVARCHAR(100), + CategoryID INT, + Price DECIMAL(18,2) ) - db_connection.commit() + """) - leap_second_sim = datetime(2023, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc) - cursor.execute( - "INSERT INTO #pytest_datetimeoffset_leap_second (id, dto_column) VALUES (?, ?);", - 1, - leap_second_sim, + cursor.setinputsizes( + [ + (mssql_python.SQL_WVARCHAR, 100, 0), + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_DECIMAL, 18, 2), + ] ) - db_connection.commit() - row = cursor.execute( - "SELECT dto_column FROM #pytest_datetimeoffset_leap_second;" - ).fetchone() - assert row[0].tzinfo is not None + cursor.executemany( + "INSERT INTO #test_sis_decimal (Name, CategoryID, Price) VALUES (?, ?, ?)", + [ + ("Widget", 1, decimal.Decimal("19.99")), + ("Gadget", 2, decimal.Decimal("29.99")), + ("Gizmo", 3, decimal.Decimal("0.01")), + ], + ) + + cursor.execute("SELECT Name, CategoryID, Price FROM #test_sis_decimal ORDER BY CategoryID") + rows = cursor.fetchall() + + assert len(rows) == 3 + assert rows[0][0] == "Widget" + assert rows[0][1] == 1 + assert rows[0][2] == decimal.Decimal("19.99") + assert rows[1][0] == "Gadget" + assert rows[1][1] == 2 + assert rows[1][2] == decimal.Decimal("29.99") + assert rows[2][0] == "Gizmo" + assert rows[2][1] == 3 + assert rows[2][2] == decimal.Decimal("0.01") finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_leap_second;") - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") -def test_datetimeoffset_malformed_input(cursor, db_connection): - """Verify driver raises error for invalid datetimeoffset strings.""" - try: - cursor.execute( - "CREATE TABLE #pytest_datetimeoffset_malformed_input (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);" - ) - db_connection.commit() - - with pytest.raises(Exception): - cursor.execute( - "INSERT INTO #pytest_datetimeoffset_malformed_input (id, dto_column) VALUES (?, ?);", - 1, - "2023-13-45 25:61:00 +99:99", - ) # invalid string - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;") - db_connection.commit() - +def test_setinputsizes_sql_numeric_with_executemany(db_connection): + """Test setinputsizes with SQL_NUMERIC accepts Python Decimal values (GH-503).""" + cursor = db_connection.cursor() -def test_datetimeoffset_executemany(cursor, db_connection): - """ - Test the driver's ability to correctly read and write DATETIMEOFFSET data - using executemany, including timezone information. - """ + cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") try: - datetimeoffset_test_cases = [ - ( - "2023-10-26 10:30:00.0000000 +05:30", - datetime( - 2023, - 10, - 26, - 10, - 30, - 0, - 0, - tzinfo=timezone(timedelta(hours=5, minutes=30)), - ), - ), - ( - "2023-10-27 15:45:10.1234567 -08:00", - datetime( - 2023, - 10, - 27, - 15, - 45, - 10, - 123456, - tzinfo=timezone(timedelta(hours=-8)), - ), - ), - ( - "2023-10-28 20:00:05.9876543 +00:00", - datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone(timedelta(hours=0))), - ), - ] + cursor.execute(""" + CREATE TABLE #test_sis_numeric ( + Value NUMERIC(10,4) + ) + """) - # Create temp table - cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + cursor.setinputsizes( + [ + (mssql_python.SQL_NUMERIC, 10, 4), + ] ) - cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") - db_connection.commit() - # Prepare data for executemany - param_list = [(i, python_dt) for i, (_, python_dt) in enumerate(datetimeoffset_test_cases)] - cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) - db_connection.commit() + cursor.executemany( + "INSERT INTO #test_sis_numeric (Value) VALUES (?)", + [ + (decimal.Decimal("123.4567"),), + (decimal.Decimal("-99.0001"),), + (decimal.Decimal("0.0000"),), + ], + ) - # Read back and validate - cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") + cursor.execute("SELECT Value FROM #test_sis_numeric ORDER BY Value") rows = cursor.fetchall() - for i, (sql_str, python_dt) in enumerate(datetimeoffset_test_cases): - fetched_id, fetched_dto = rows[i] - assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive." - - assert ( - fetched_dto == python_dt - ), f"Value mismatch for id {fetched_id}: expected {python_dt}, got {fetched_dto}" + assert len(rows) == 3 + assert rows[0][0] == decimal.Decimal("-99.0001") + assert rows[1][0] == decimal.Decimal("0.0000") + assert rows[2][0] == decimal.Decimal("123.4567") finally: - cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" - ) - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") -def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection): - """ - Check that execute() and executemany() produce the same stored DATETIMEOFFSET - for identical timezone-aware datetime objects. - """ +def test_setinputsizes_sql_decimal_with_non_decimal_values(db_connection): + """Test setinputsizes with SQL_DECIMAL converts non-Decimal values (int/float) to string (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondec") try: - test_dt = datetime( - 2023, - 10, - 30, - 12, - 0, - 0, - microsecond=123456, - tzinfo=timezone(timedelta(hours=5, minutes=30)), - ) - cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" - ) - cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") - db_connection.commit() + cursor.execute("CREATE TABLE #test_sis_dec_nondc (Price DECIMAL(18,2))") - # Insert using execute() - cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt) - db_connection.commit() + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - # Insert using executemany() + # Pass int and float instead of Decimal — exercises the non-Decimal conversion branch cursor.executemany( - "INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", [(2, test_dt)] + "INSERT INTO #test_sis_dec_nondc (Price) VALUES (?)", + [(42,), (19.99,), (0,)], ) - db_connection.commit() - cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;") + cursor.execute("SELECT Price FROM #test_sis_dec_nondc ORDER BY Price") rows = cursor.fetchall() - assert len(rows) == 2 - - # Compare textual representation to ensure binding semantics match - cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;") - textual_rows = [r[0] for r in cursor.fetchall()] - assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ" + assert len(rows) == 3 + assert rows[0][0] == decimal.Decimal("0.00") + assert rows[1][0] == decimal.Decimal("19.99") + assert rows[2][0] == decimal.Decimal("42.00") finally: - cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" - ) - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondc") -def test_datetimeoffset_extreme_offsets(cursor, db_connection): - """ - Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling. - """ +def test_setinputsizes_sql_decimal_with_execute(db_connection): + """Test setinputsizes with SQL_DECIMAL works with single execute() too (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") try: - extreme_offsets = [ - datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))), - datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))), - ] + cursor.execute("CREATE TABLE #test_sis_dec_exec (Price DECIMAL(18,2))") + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" + "INSERT INTO #test_sis_dec_exec (Price) VALUES (?)", + decimal.Decimal("99.95"), ) - cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") - db_connection.commit() - - param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)] - cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) - db_connection.commit() - - cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") - rows = cursor.fetchall() - for i, dt in enumerate(extreme_offsets): - _, fetched = rows[i] - assert fetched.tzinfo is not None - assert fetched == dt, f"Value mismatch for id {i}: expected {dt}, got {fetched}" + cursor.execute("SELECT Price FROM #test_sis_dec_exec") + row = cursor.fetchone() + assert row[0] == decimal.Decimal("99.95") finally: - cursor.execute( - "IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;" - ) - db_connection.commit() - - -def test_datetimeoffset_native_vs_string_simple(cursor, db_connection): - """ - Replicates the user's testing scenario: fetch DATETIMEOFFSET as native datetime - and as string using CONVERT(nvarchar(35), ..., 121). - """ - try: - cursor.execute( - "CREATE TABLE #pytest_dto_user_test (id INT PRIMARY KEY, Systime DATETIMEOFFSET);" - ) - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") - # Insert rows similar to user's example - test_rows = [ - ( - 1, - datetime(2025, 5, 14, 12, 35, 52, 501000, tzinfo=timezone(timedelta(hours=1))), - ), - ( - 2, - datetime( - 2025, - 5, - 14, - 15, - 20, - 30, - 123000, - tzinfo=timezone(timedelta(hours=-5)), - ), - ), - ] - for i, dt in test_rows: - cursor.execute("INSERT INTO #pytest_dto_user_test (id, Systime) VALUES (?, ?);", i, dt) - db_connection.commit() +def test_setinputsizes_sql_decimal_null(db_connection): + """Test setinputsizes with SQL_DECIMAL handles NULL values correctly (GH-503).""" + cursor = db_connection.cursor() - # Native fetch (like the user's first execute) - cursor.execute("SELECT Systime FROM #pytest_dto_user_test WHERE id=1;") - dt_native = cursor.fetchone()[0] - assert dt_native.tzinfo is not None - assert dt_native == test_rows[0][1] + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") + try: + cursor.execute("CREATE TABLE #test_sis_dec_null (Price DECIMAL(18,2))") - # String fetch (like the user's convert to nvarchar) + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) cursor.execute( - "SELECT CONVERT(nvarchar(35), Systime, 121) FROM #pytest_dto_user_test WHERE id=1;" + "INSERT INTO #test_sis_dec_null (Price) VALUES (?)", + None, ) - dt_str = cursor.fetchone()[0] - assert dt_str.endswith("+01:00") # original offset preserved + cursor.execute("SELECT Price FROM #test_sis_dec_null") + row = cursor.fetchone() + assert row[0] is None finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_dto_user_test;") - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") -def test_lowercase_attribute(cursor, db_connection): - """Test that the lowercase attribute properly converts column names to lowercase""" +def test_setinputsizes_sql_decimal_unconvertible_value(db_connection): + """Test setinputsizes with SQL_DECIMAL raises ValueError for unconvertible values (GH-503). - # Store original value to restore after test - original_lowercase = mssql_python.lowercase - drop_cursor = None + The raised message must be metadata-only: it reports the row index, column + index, and value type, but must NOT embed the offending value or the full + parameter row (which may contain PII such as SSNs/emails/balances). + """ + cursor = db_connection.cursor() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad") try: - # Create a test table with mixed-case column names - cursor.execute(""" - CREATE TABLE #pytest_lowercase_test ( - ID INT PRIMARY KEY, - UserName VARCHAR(50), - EMAIL_ADDRESS VARCHAR(100), - PhoneNumber VARCHAR(20) + cursor.execute("CREATE TABLE #test_sis_dec_bad (Price DECIMAL(18,2))") + + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + + sensitive_value = "123-45-6789" # stand-in for PII in the failing row + with pytest.raises(ValueError) as exc_info: + cursor.executemany( + "INSERT INTO #test_sis_dec_bad (Price) VALUES (?)", + [(sensitive_value,)], + ) + + message = str(exc_info.value) + # Contract: metadata is present... + assert "Failed to convert parameter" in message + assert "row 0" in message + assert "column 0" in message + assert "str" in message # value type name + # ...and the sensitive value / raw row is NOT leaked into the message. + assert sensitive_value not in message + assert repr((sensitive_value,)) not in message # no repr of the parameter tuple + # ...nor into the chained cause or the fully formatted traceback, which + # is what tracebacks and APM/log shippers actually capture. + formatted = "".join( + traceback.format_exception( + type(exc_info.value), exc_info.value, exc_info.value.__traceback__ + ) ) - """) - db_connection.commit() + assert sensitive_value not in formatted + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad") - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_lowercase_test (ID, UserName, EMAIL_ADDRESS, PhoneNumber) - VALUES (1, 'JohnDoe', 'john@example.com', '555-1234') - """) - db_connection.commit() - # First test with lowercase=False (default) - mssql_python.lowercase = False - cursor1 = db_connection.cursor() - cursor1.execute("SELECT * FROM #pytest_lowercase_test") +def test_setinputsizes_sql_decimal_str_raises_no_leak(db_connection): + """A parameter whose str() raises must not leak the exception text (GH-503). - # Description column names should preserve original case - column_names1 = [desc[0] for desc in cursor1.description] - assert "ID" in column_names1, "Column 'ID' should be present with original case" - assert "UserName" in column_names1, "Column 'UserName' should be present with original case" + Exception chaining (raise ... from e) can surface a value-bearing cause + through __cause__ and formatted tracebacks. For a value whose str() raises, + the chain must be suppressed so the metadata-only guarantee holds across + tracebacks and APM/log shippers, not just str(exc). + """ + cursor = db_connection.cursor() - # Make sure to consume all results and close the cursor - cursor1.fetchall() - cursor1.close() + secret = "secret-987-65-4321" - # Now test with lowercase=True - mssql_python.lowercase = True - cursor2 = db_connection.cursor() - cursor2.execute("SELECT * FROM #pytest_lowercase_test") + class ExplodingStr: + def __str__(self): + raise ValueError(secret) - # Description column names should be lowercase - column_names2 = [desc[0] for desc in cursor2.description] - assert "id" in column_names2, "Column names should be lowercase when lowercase=True" - assert "username" in column_names2, "Column names should be lowercase when lowercase=True" + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_explode") + try: + cursor.execute("CREATE TABLE #test_sis_dec_explode (Price DECIMAL(18,2))") - # Make sure to consume all results and close the cursor - cursor2.fetchall() - cursor2.close() + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - # Create a fresh cursor for cleanup - drop_cursor = db_connection.cursor() + with pytest.raises(ValueError) as exc_info: + cursor.executemany( + "INSERT INTO #test_sis_dec_explode (Price) VALUES (?)", + [(ExplodingStr(),)], + ) + # The metadata-only message must not carry the secret, and the chain + # must be suppressed so neither __cause__ nor the formatted traceback + # exposes it. + assert secret not in str(exc_info.value) + assert exc_info.value.__cause__ is None + formatted = "".join( + traceback.format_exception( + type(exc_info.value), exc_info.value, exc_info.value.__traceback__ + ) + ) + assert secret not in formatted finally: - # Restore original value - mssql_python.lowercase = original_lowercase + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_explode") - try: - # Use a separate cursor for cleanup - if drop_cursor: - drop_cursor.execute("DROP TABLE IF EXISTS #pytest_lowercase_test") - db_connection.commit() - drop_cursor.close() - except Exception as e: - print(f"Warning: Failed to drop test table: {e}") +def test_setinputsizes_sql_decimal_non_decimal_exception_no_leak(db_connection): + """Cover the non-DecimalException conversion branch with no value leak (GH-503). -def test_decimal_separator_function(cursor, db_connection): - """Test decimal separator functionality with database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() + ``format(decimal.Decimal("1e999999999999999999"), "f")`` raises MemoryError + (not a decimal.DecimalException) quickly and deterministically, exercising + the branch that re-raises with the chain suppressed. The resulting + ValueError must be metadata-only: no chained cause, and the offending input + must be absent from both the message and the fully formatted traceback. + """ + cursor = db_connection.cursor() + # A syntactically valid Decimal whose fixed-point expansion is astronomically + # large; format(..., "f") raises MemoryError rather than a DecimalException. + sensitive_value = "1e999999999999999999" + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mem") try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_separator_test ( - id INT PRIMARY KEY, - decimal_value DECIMAL(10, 2) - ) - """) - db_connection.commit() + cursor.execute("CREATE TABLE #test_sis_dec_mem (Price DECIMAL(18,2))") - # Insert test values with default separator (.) - test_value = decimal.Decimal("123.45") - cursor.execute( - """ - INSERT INTO #pytest_decimal_separator_test (id, decimal_value) - VALUES (1, ?) - """, - [test_value], + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + + with pytest.raises(ValueError) as exc_info: + cursor.executemany( + "INSERT INTO #test_sis_dec_mem (Price) VALUES (?)", + [(sensitive_value,)], + ) + + message = str(exc_info.value) + # Metadata-only message... + assert "Failed to convert parameter" in message + assert "row 0" in message + assert "column 0" in message + # ...no chained cause (the non-DecimalException branch suppresses it)... + assert exc_info.value.__cause__ is None + # ...and the input is absent from the message and formatted traceback. + assert sensitive_value not in message + formatted = "".join( + traceback.format_exception( + type(exc_info.value), exc_info.value, exc_info.value.__traceback__ + ) ) - db_connection.commit() + assert sensitive_value not in formatted + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mem") - # First test with default decimal separator (.) - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default separator not found in string representation" - # Now change to comma separator and test string representation - mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") - row = cursor.fetchone() +def test_executemany_debug_log_no_parameter_values(db_connection): + """executemany() DEBUG logging must not emit parameter values or rows (GH-503). - # This should format the decimal with a comma in the string representation - comma_str = str(row) - assert ( - "123,45" in comma_str - ), f"Expected comma in string representation but got: {comma_str}" + The batch-execution debug log previously dumped the first 5 full parameter + rows, leaking the same PII the exception path now redacts. This test enables + DEBUG capture, runs a successful batch of sensitive-looking values, and + asserts the values are absent from the logs while batch metadata is present + (the metadata assertion is a positive control proving capture is working, so + the absence assertions are meaningful rather than vacuous). + """ + import logging as _logging + import io + from mssql_python.logging import logger, driver_logger - finally: - # Restore original decimal separator - mssql_python.setDecimalSeparator(original_separator) + cursor = db_connection.cursor() - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_separator_test") - db_connection.commit() + # Values that stand in for PII; both insert successfully into an NVARCHAR + # column so execution reaches the batch debug-log statement. + ssn = "123-45-6789" + email = "jane.doe@example.com" + log_stream = io.StringIO() + test_handler = _logging.StreamHandler(log_stream) + test_handler.setLevel(_logging.DEBUG) -def test_decimal_separator_basic_functionality(): - """Test basic decimal separator functionality without database operations""" - # Store original value to restore after test - original_separator = mssql_python.getDecimalSeparator() + # Save state we mutate so the global logger is restored afterwards. + original_cached_level = logger._cached_level + original_driver_level = driver_logger.level + cursor.execute("DROP TABLE IF EXISTS #test_dbg_no_pii") try: - # Test default value - assert mssql_python.getDecimalSeparator() == ".", "Default decimal separator should be '.'" + cursor.execute("CREATE TABLE #test_dbg_no_pii (Data NVARCHAR(50))") - # Test setting to comma - mssql_python.setDecimalSeparator(",") - assert ( - mssql_python.getDecimalSeparator() == "," - ), "Decimal separator should be ',' after setting" + # Enable DEBUG: bypass the wrapper's cached-level gate and lower the + # underlying stdlib logger, then attach our capturing handler. + logger._cached_level = _logging.DEBUG + driver_logger.setLevel(_logging.DEBUG) + driver_logger.addHandler(test_handler) - # Test setting to other valid separators - mssql_python.setDecimalSeparator(":") - assert ( - mssql_python.getDecimalSeparator() == ":" - ), "Decimal separator should be ':' after setting" + cursor.executemany( + "INSERT INTO #test_dbg_no_pii (Data) VALUES (?)", + [(ssn,), (email,)], + ) - # Test invalid inputs - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("") # Empty string - - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator("too_long") # More than one character - - with pytest.raises(ValueError): - mssql_python.setDecimalSeparator(123) # Not a string + test_handler.flush() + log_contents = log_stream.getvalue() + # Positive control: batch metadata is logged (proves capture works). + assert "Executing batch query with 2 parameter sets" in log_contents + # Redaction: no parameter value or row representation is emitted. + assert ssn not in log_contents + assert email not in log_contents + assert repr((ssn,)) not in log_contents + assert repr((email,)) not in log_contents finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) + driver_logger.removeHandler(test_handler) + driver_logger.setLevel(original_driver_level) + logger._cached_level = original_cached_level + cursor.execute("DROP TABLE IF EXISTS #test_dbg_no_pii") -def test_decimal_separator_with_multiple_values(cursor, db_connection): - """Test decimal separator with multiple different decimal values""" - original_separator = mssql_python.getDecimalSeparator() +def test_setinputsizes_sql_decimal_high_precision(db_connection): + """Test setinputsizes with SQL_DECIMAL preserves full DECIMAL(38,18) precision (GH-503).""" + cursor = db_connection.cursor() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp") try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_multi_test ( - id INT PRIMARY KEY, - positive_value DECIMAL(10, 2), - negative_value DECIMAL(10, 2), - zero_value DECIMAL(10, 2), - small_value DECIMAL(10, 4) - ) - """) - db_connection.commit() + cursor.execute("CREATE TABLE #test_sis_dec_hp (Value DECIMAL(38,18))") - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_multi_test VALUES (1, 123.45, -67.89, 0.00, 0.0001) - """) - db_connection.commit() + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 38, 18)]) - # Test with default separator first - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") - row = cursor.fetchone() - default_str = str(row) - assert "123.45" in default_str, "Default positive value formatting incorrect" - assert "-67.89" in default_str, "Default negative value formatting incorrect" + high_prec = decimal.Decimal("12345678901234567890.123456789012345678") + cursor.execute( + "INSERT INTO #test_sis_dec_hp (Value) VALUES (?)", + high_prec, + ) - # Change to comma separator - mssql_python.setDecimalSeparator(",") - cursor.execute("SELECT * FROM #pytest_decimal_multi_test") + cursor.execute("SELECT Value FROM #test_sis_dec_hp") row = cursor.fetchone() - comma_str = str(row) + assert row[0] == high_prec + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp") - # Verify comma is used in all decimal values - assert "123,45" in comma_str, "Positive value not formatted with comma" - assert "-67,89" in comma_str, "Negative value not formatted with comma" - assert "0,00" in comma_str, "Zero value not formatted with comma" - assert "0,0001" in comma_str, "Small value not formatted with comma" - finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) +def test_setinputsizes_sql_decimal_negative_zero(db_connection): + """Test setinputsizes with SQL_DECIMAL handles Decimal('-0.00') correctly (GH-503).""" + cursor = db_connection.cursor() - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_multi_test") - db_connection.commit() + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz") + try: + cursor.execute("CREATE TABLE #test_sis_dec_negz (Value DECIMAL(18,2))") + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + cursor.execute( + "INSERT INTO #test_sis_dec_negz (Value) VALUES (?)", + decimal.Decimal("-0.00"), + ) -def test_decimal_separator_calculations(cursor, db_connection): - """Test that decimal separator doesn't affect calculations""" - original_separator = mssql_python.getDecimalSeparator() + cursor.execute("SELECT Value FROM #test_sis_dec_negz") + row = cursor.fetchone() + # SQL Server normalizes -0.00 to 0.00 + assert row[0] == decimal.Decimal("0.00") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz") + +def test_setinputsizes_sql_decimal_mixed_null_executemany(db_connection): + """Test setinputsizes with SQL_DECIMAL handles mixed NULL/non-NULL in executemany (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix") try: - # Create test table - cursor.execute(""" - CREATE TABLE #pytest_decimal_calc_test ( - id INT PRIMARY KEY, - value1 DECIMAL(10, 2), - value2 DECIMAL(10, 2) + cursor.execute("CREATE TABLE #test_sis_dec_mix (Id INT, Price DECIMAL(18,2))") + + cursor.setinputsizes( + [ + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_DECIMAL, 18, 2), + ] ) - """) - db_connection.commit() - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_decimal_calc_test VALUES (1, 10.25, 5.75) - """) - db_connection.commit() + cursor.executemany( + "INSERT INTO #test_sis_dec_mix (Id, Price) VALUES (?, ?)", + [ + (1, decimal.Decimal("10.50")), + (2, None), + (3, decimal.Decimal("30.75")), + (4, None), + ], + ) - # Test with default separator - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation incorrect with default separator" + cursor.execute("SELECT Id, Price FROM #test_sis_dec_mix ORDER BY Id") + rows = cursor.fetchall() - # Change to comma separator - mssql_python.setDecimalSeparator(",") + assert len(rows) == 4 + assert rows[0][1] == decimal.Decimal("10.50") + assert rows[1][1] is None + assert rows[2][1] == decimal.Decimal("30.75") + assert rows[3][1] is None + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix") - # Calculations should still work correctly - cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") - row = cursor.fetchone() - assert row.sum_result == decimal.Decimal( - "16.00" - ), "Sum calculation affected by separator change" - # But string representation should use comma - assert "16,00" in str(row), "Sum result not formatted with comma in string representation" +def test_decimal_without_setinputsizes_no_regression(db_connection): + """Verify plain Decimal binding without setinputsizes still works (GH-503 regression check).""" + cursor = db_connection.cursor() - finally: - # Restore original separator - mssql_python.setDecimalSeparator(original_separator) + cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg") + try: + cursor.execute("CREATE TABLE #test_dec_noreg (Price DECIMAL(18,2))") - # Cleanup - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") - db_connection.commit() + # Single execute without setinputsizes + cursor.execute( + "INSERT INTO #test_dec_noreg (Price) VALUES (?)", + decimal.Decimal("49.99"), + ) + + # executemany without setinputsizes + cursor.executemany( + "INSERT INTO #test_dec_noreg (Price) VALUES (?)", + [(decimal.Decimal("99.99"),), (decimal.Decimal("0.01"),)], + ) + cursor.execute("SELECT Price FROM #test_dec_noreg ORDER BY Price") + rows = cursor.fetchall() -def test_cursor_setinputsizes_basic(db_connection): - """Test the basic functionality of setinputsizes""" + assert len(rows) == 3 + assert rows[0][0] == decimal.Decimal("0.01") + assert rows[1][0] == decimal.Decimal("49.99") + assert rows[2][0] == decimal.Decimal("99.99") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg") + + +def test_cursor_setinputsizes_reset(db_connection): + """Test that setinputsizes is reset after execution""" cursor = db_connection.cursor() # Create a test table - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes") + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_reset") cursor.execute(""" - CREATE TABLE #test_inputsizes ( - string_col NVARCHAR(100), - int_col INT + CREATE TABLE #test_inputsizes_reset ( + col1 NVARCHAR(100), + col2 INT ) """) @@ -10911,1742 +10705,1590 @@ def test_cursor_setinputsizes_basic(db_connection): cursor.setinputsizes([(mssql_python.SQL_WVARCHAR, 100, 0), (mssql_python.SQL_INTEGER, 0, 0)]) # Execute with parameters - cursor.execute("INSERT INTO #test_inputsizes VALUES (?, ?)", "Test String", 42) + cursor.execute("INSERT INTO #test_inputsizes_reset VALUES (?, ?)", "Test String", 42) - # Verify data was inserted correctly - cursor.execute("SELECT * FROM #test_inputsizes") - row = cursor.fetchone() + # Verify inputsizes was reset + assert cursor._inputsizes is None - assert row[0] == "Test String" - assert row[1] == 42 + # Now execute again without setting input sizes + cursor.execute("INSERT INTO #test_inputsizes_reset VALUES (?, ?)", "Another String", 84) + + # Verify both rows were inserted correctly + cursor.execute("SELECT * FROM #test_inputsizes_reset ORDER BY col2") + rows = cursor.fetchall() + + assert len(rows) == 2 + assert rows[0][0] == "Test String" + assert rows[0][1] == 42 + assert rows[1][0] == "Another String" + assert rows[1][1] == 84 # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes") + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_reset") -def test_cursor_setinputsizes_with_executemany_float(db_connection): - """Test setinputsizes with executemany using float instead of Decimal""" +def test_cursor_setinputsizes_override_inference(db_connection): + """Test that setinputsizes overrides type inference""" cursor = db_connection.cursor() - # Create a test table - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float") + # Create a test table with specific types + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_override") cursor.execute(""" - CREATE TABLE #test_inputsizes_float ( - id INT, - name NVARCHAR(50), - price REAL /* Use REAL instead of DECIMAL */ + CREATE TABLE #test_inputsizes_override ( + small_int SMALLINT, + big_text NVARCHAR(MAX) ) """) - # Prepare data with float values - data = [(1, "Item 1", 10.99), (2, "Item 2", 20.50), (3, "Item 3", 30.75)] - - # Set input sizes for parameters + # Set input sizes that override the default inference + # For SMALLINT, use a valid precision value (5 is typical for SMALLINT) cursor.setinputsizes( [ - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_WVARCHAR, 50, 0), - (mssql_python.SQL_REAL, 0, 0), + (mssql_python.SQL_SMALLINT, 5, 0), # Use valid precision for SMALLINT + (mssql_python.SQL_WVARCHAR, 8000, 0), # Force short string to NVARCHAR(MAX) ] ) - # Execute with parameters - cursor.executemany("INSERT INTO #test_inputsizes_float VALUES (?, ?, ?)", data) + # Test with values that would normally be inferred differently + big_number = 30000 # Would normally be INTEGER or BIGINT + short_text = "abc" # Would normally be a regular NVARCHAR - # Verify all data was inserted correctly - cursor.execute("SELECT * FROM #test_inputsizes_float ORDER BY id") - rows = cursor.fetchall() - - assert len(rows) == 3 - assert rows[0][0] == 1 - assert rows[0][1] == "Item 1" - assert abs(rows[0][2] - 10.99) < 0.001 - - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float") - - -def test_setinputsizes_sql_decimal_with_executemany(db_connection): - """Test setinputsizes with SQL_DECIMAL accepts Python Decimal values (GH-503). - - Without this fix, passing SQL_DECIMAL or SQL_NUMERIC via setinputsizes() - caused a RuntimeError because Decimal objects were not converted to - NumericData before the C binding validated the C type. - """ - cursor = db_connection.cursor() - - cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") try: - cursor.execute(""" - CREATE TABLE #test_sis_decimal ( - Name NVARCHAR(100), - CategoryID INT, - Price DECIMAL(18,2) - ) - """) - - cursor.setinputsizes( - [ - (mssql_python.SQL_WVARCHAR, 100, 0), - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_DECIMAL, 18, 2), - ] - ) - - cursor.executemany( - "INSERT INTO #test_sis_decimal (Name, CategoryID, Price) VALUES (?, ?, ?)", - [ - ("Widget", 1, decimal.Decimal("19.99")), - ("Gadget", 2, decimal.Decimal("29.99")), - ("Gizmo", 3, decimal.Decimal("0.01")), - ], + cursor.execute( + "INSERT INTO #test_inputsizes_override VALUES (?, ?)", + big_number, + short_text, ) - cursor.execute("SELECT Name, CategoryID, Price FROM #test_sis_decimal ORDER BY CategoryID") - rows = cursor.fetchall() - - assert len(rows) == 3 - assert rows[0][0] == "Widget" - assert rows[0][1] == 1 - assert rows[0][2] == decimal.Decimal("19.99") - assert rows[1][0] == "Gadget" - assert rows[1][1] == 2 - assert rows[1][2] == decimal.Decimal("29.99") - assert rows[2][0] == "Gizmo" - assert rows[2][1] == 3 - assert rows[2][2] == decimal.Decimal("0.01") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") - - -def test_setinputsizes_sql_numeric_with_executemany(db_connection): - """Test setinputsizes with SQL_NUMERIC accepts Python Decimal values (GH-503).""" - cursor = db_connection.cursor() + # Verify the row was inserted (may have been truncated by SQL Server) + cursor.execute("SELECT * FROM #test_inputsizes_override") + row = cursor.fetchone() - cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") - try: - cursor.execute(""" - CREATE TABLE #test_sis_numeric ( - Value NUMERIC(10,4) - ) - """) + # SQL Server would either truncate or round the value + assert row[1] == short_text - cursor.setinputsizes( - [ - (mssql_python.SQL_NUMERIC, 10, 4), + except Exception as e: + # If an exception occurs, it should be related to the data type conversion + # Add "invalid precision" to the expected error messages + error_text = str(e).lower() + assert any( + text in error_text + for text in [ + "overflow", + "out of range", + "convert", + "invalid precision", + "precision value", ] - ) - - cursor.executemany( - "INSERT INTO #test_sis_numeric (Value) VALUES (?)", - [ - (decimal.Decimal("123.4567"),), - (decimal.Decimal("-99.0001"),), - (decimal.Decimal("0.0000"),), - ], - ) + ), f"Unexpected error: {e}" - cursor.execute("SELECT Value FROM #test_sis_numeric ORDER BY Value") - rows = cursor.fetchall() + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_override") - assert len(rows) == 3 - assert rows[0][0] == decimal.Decimal("-99.0001") - assert rows[1][0] == decimal.Decimal("0.0000") - assert rows[2][0] == decimal.Decimal("123.4567") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") +def test_setinputsizes_parameter_count_mismatch_fewer(db_connection): + """Test setinputsizes with fewer sizes than parameters""" + import warnings -def test_setinputsizes_sql_decimal_with_non_decimal_values(db_connection): - """Test setinputsizes with SQL_DECIMAL converts non-Decimal values (int/float) to string (GH-503).""" cursor = db_connection.cursor() - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondec") - try: - cursor.execute("CREATE TABLE #test_sis_dec_nondc (Price DECIMAL(18,2))") + # Create a test table + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") + cursor.execute(""" + CREATE TABLE #test_inputsizes_mismatch ( + col1 INT, + col2 NVARCHAR(100), + col3 FLOAT + ) + """) - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + # Set fewer input sizes than parameters + cursor.setinputsizes( + [ + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_WVARCHAR, 100, 0), + # Missing third parameter type + ] + ) - # Pass int and float instead of Decimal — exercises the non-Decimal conversion branch - cursor.executemany( - "INSERT INTO #test_sis_dec_nondc (Price) VALUES (?)", - [(42,), (19.99,), (0,)], + # Execute with more parameters than specified input sizes + # This should use automatic type inference for the third parameter + with warnings.catch_warnings(record=True) as w: + cursor.execute( + "INSERT INTO #test_inputsizes_mismatch VALUES (?, ?, ?)", + 1, + "Test String", + 3.14, ) + assert len(w) > 0, "Warning should be issued for parameter count mismatch" + assert "number of input sizes" in str(w[0].message).lower() - cursor.execute("SELECT Price FROM #test_sis_dec_nondc ORDER BY Price") - rows = cursor.fetchall() + # Verify data was inserted correctly + cursor.execute("SELECT * FROM #test_inputsizes_mismatch") + row = cursor.fetchone() - assert len(rows) == 3 - assert rows[0][0] == decimal.Decimal("0.00") - assert rows[1][0] == decimal.Decimal("19.99") - assert rows[2][0] == decimal.Decimal("42.00") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondc") + assert row[0] == 1 + assert row[1] == "Test String" + assert abs(row[2] - 3.14) < 0.0001 + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") -def test_setinputsizes_sql_decimal_with_execute(db_connection): - """Test setinputsizes with SQL_DECIMAL works with single execute() too (GH-503).""" - cursor = db_connection.cursor() - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") - try: - cursor.execute("CREATE TABLE #test_sis_dec_exec (Price DECIMAL(18,2))") +def test_setinputsizes_parameter_count_mismatch_more(db_connection): + """Test setinputsizes with more sizes than parameters""" + import warnings - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - cursor.execute( - "INSERT INTO #test_sis_dec_exec (Price) VALUES (?)", - decimal.Decimal("99.95"), - ) + cursor = db_connection.cursor() - cursor.execute("SELECT Price FROM #test_sis_dec_exec") - row = cursor.fetchone() - assert row[0] == decimal.Decimal("99.95") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") + # Create a test table + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") + cursor.execute(""" + CREATE TABLE #test_inputsizes_mismatch ( + col1 INT, + col2 NVARCHAR(100) + ) + """) + # Set more input sizes than parameters + cursor.setinputsizes( + [ + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_WVARCHAR, 100, 0), + (mssql_python.SQL_FLOAT, 0, 0), # Extra parameter type + ] + ) -def test_setinputsizes_sql_decimal_null(db_connection): - """Test setinputsizes with SQL_DECIMAL handles NULL values correctly (GH-503).""" - cursor = db_connection.cursor() + # Execute with fewer parameters than specified input sizes + with warnings.catch_warnings(record=True) as w: + cursor.execute("INSERT INTO #test_inputsizes_mismatch VALUES (?, ?)", 1, "Test String") + assert len(w) > 0, "Warning should be issued for parameter count mismatch" + assert "number of input sizes" in str(w[0].message).lower() - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") - try: - cursor.execute("CREATE TABLE #test_sis_dec_null (Price DECIMAL(18,2))") + # Verify data was inserted correctly + cursor.execute("SELECT * FROM #test_inputsizes_mismatch") + row = cursor.fetchone() - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - cursor.execute( - "INSERT INTO #test_sis_dec_null (Price) VALUES (?)", - None, - ) + assert row[0] == 1 + assert row[1] == "Test String" - cursor.execute("SELECT Price FROM #test_sis_dec_null") - row = cursor.fetchone() - assert row[0] is None - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") -def test_setinputsizes_sql_decimal_unconvertible_value(db_connection): - """Test setinputsizes with SQL_DECIMAL raises ValueError for unconvertible values (GH-503). +def test_setinputsizes_with_null_values(db_connection): + """Test setinputsizes with NULL values for various data types""" - The raised message must be metadata-only: it reports the row index, column - index, and value type, but must NOT embed the offending value or the full - parameter row (which may contain PII such as SSNs/emails/balances). - """ cursor = db_connection.cursor() - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad") - try: - cursor.execute("CREATE TABLE #test_sis_dec_bad (Price DECIMAL(18,2))") - - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - - sensitive_value = "123-45-6789" # stand-in for PII in the failing row - with pytest.raises(ValueError) as exc_info: - cursor.executemany( - "INSERT INTO #test_sis_dec_bad (Price) VALUES (?)", - [(sensitive_value,)], - ) + # Create a test table with multiple data types + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_null") + cursor.execute(""" + CREATE TABLE #test_inputsizes_null ( + int_col INT, + string_col NVARCHAR(100), + float_col FLOAT, + date_col DATE, + binary_col VARBINARY(100) + ) + """) - message = str(exc_info.value) - # Contract: metadata is present... - assert "Failed to convert parameter" in message - assert "row 0" in message - assert "column 0" in message - assert "str" in message # value type name - # ...and the sensitive value / raw row is NOT leaked into the message. - assert sensitive_value not in message - assert repr((sensitive_value,)) not in message # no repr of the parameter tuple - # ...nor into the chained cause or the fully formatted traceback, which - # is what tracebacks and APM/log shippers actually capture. - formatted = "".join( - traceback.format_exception( - type(exc_info.value), exc_info.value, exc_info.value.__traceback__ - ) - ) - assert sensitive_value not in formatted - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad") + # Set input sizes for all columns + cursor.setinputsizes( + [ + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_WVARCHAR, 100, 0), + (mssql_python.SQL_FLOAT, 0, 0), + (mssql_python.SQL_DATE, 0, 0), + (mssql_python.SQL_VARBINARY, 100, 0), + ] + ) + # Insert row with all NULL values + cursor.execute( + "INSERT INTO #test_inputsizes_null VALUES (?, ?, ?, ?, ?)", + None, + None, + None, + None, + None, + ) -def test_setinputsizes_sql_decimal_str_raises_no_leak(db_connection): - """A parameter whose str() raises must not leak the exception text (GH-503). + # Insert row with mix of NULL and non-NULL values + cursor.execute( + "INSERT INTO #test_inputsizes_null VALUES (?, ?, ?, ?, ?)", + 42, + None, + 3.14, + None, + b"binary data", + ) - Exception chaining (raise ... from e) can surface a value-bearing cause - through __cause__ and formatted tracebacks. For a value whose str() raises, - the chain must be suppressed so the metadata-only guarantee holds across - tracebacks and APM/log shippers, not just str(exc). - """ + # Verify data was inserted correctly + cursor.execute( + "SELECT * FROM #test_inputsizes_null ORDER BY CASE WHEN int_col IS NULL THEN 0 ELSE 1 END" + ) + rows = cursor.fetchall() + + # First row should be all NULLs + assert len(rows) == 2 + assert rows[0][0] is None + assert rows[0][1] is None + assert rows[0][2] is None + assert rows[0][3] is None + assert rows[0][4] is None + + # Second row should have mix of NULL and non-NULL + assert rows[1][0] == 42 + assert rows[1][1] is None + assert abs(rows[1][2] - 3.14) < 0.0001 + assert rows[1][3] is None + assert rows[1][4] == b"binary data" + + # Clean up + cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_null") + + +def test_setinputsizes_sql_injection_protection(db_connection): + """Test that setinputsizes doesn't allow SQL injection""" cursor = db_connection.cursor() - secret = "secret-987-65-4321" + # Create a test table + cursor.execute("CREATE TABLE #test_sql_injection (id INT, name VARCHAR(100))") - class ExplodingStr: - def __str__(self): - raise ValueError(secret) + # Insert legitimate data + cursor.execute("INSERT INTO #test_sql_injection VALUES (1, 'safe')") - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_explode") + # Set input sizes with potentially malicious SQL types and sizes try: - cursor.execute("CREATE TABLE #test_sis_dec_explode (Price DECIMAL(18,2))") + # This should fail with a validation error + cursor.setinputsizes([(999999, 1000000, 1000000)]) # Invalid SQL type + except ValueError: + pass # Expected - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + # Test with valid types but attempt SQL injection in parameter + cursor.setinputsizes([(mssql_python.SQL_VARCHAR, 100, 0)]) + injection_attempt = "x'; DROP TABLE #test_sql_injection; --" - with pytest.raises(ValueError) as exc_info: - cursor.executemany( - "INSERT INTO #test_sis_dec_explode (Price) VALUES (?)", - [(ExplodingStr(),)], - ) + # This should safely parameterize without executing the injection + cursor.execute("SELECT * FROM #test_sql_injection WHERE name = ?", injection_attempt) - # The metadata-only message must not carry the secret, and the chain - # must be suppressed so neither __cause__ nor the formatted traceback - # exposes it. - assert secret not in str(exc_info.value) - assert exc_info.value.__cause__ is None - formatted = "".join( - traceback.format_exception( - type(exc_info.value), exc_info.value, exc_info.value.__traceback__ - ) - ) - assert secret not in formatted - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_explode") + # Verify table still exists and injection didn't work + cursor.execute("SELECT COUNT(*) FROM #test_sql_injection") + count = cursor.fetchone()[0] + assert count == 1, "SQL injection protection failed" + # Clean up + cursor.execute("DROP TABLE #test_sql_injection") -def test_setinputsizes_sql_decimal_non_decimal_exception_no_leak(db_connection): - """Cover the non-DecimalException conversion branch with no value leak (GH-503). - ``format(decimal.Decimal("1e999999999999999999"), "f")`` raises MemoryError - (not a decimal.DecimalException) quickly and deterministically, exercising - the branch that re-raises with the chain suppressed. The resulting - ValueError must be metadata-only: no chained cause, and the offending input - must be absent from both the message and the fully formatted traceback. +def test_fetch_methods_not_shadowed_on_instance(cursor): + """Regression test for GH #620. + + The fetch methods (fetchone/fetchmany/fetchall) must remain regular class + methods and never be reassigned as instance attributes. The previous + implementation swapped them for closures on the instance while preparing + catalog/metadata result sets, which produced a union type that broke static + type checkers (e.g. ``ty`` reported a spurious missing ``self`` argument). """ - cursor = db_connection.cursor() + fetch_methods = ("fetchone", "fetchmany", "fetchall") - # A syntactically valid Decimal whose fixed-point expansion is astronomically - # large; format(..., "f") raises MemoryError rather than a DecimalException. - sensitive_value = "1e999999999999999999" + # Pristine cursor: methods come from the class, not the instance. + for name in fetch_methods: + assert name not in cursor.__dict__, f"{name} should not be an instance attribute" - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mem") - try: - cursor.execute("CREATE TABLE #test_sis_dec_mem (Price DECIMAL(18,2))") + # A catalog helper historically reassigned the fetch methods. Make sure it + # no longer shadows them on the instance. + cursor.getTypeInfo().fetchall() + for name in fetch_methods: + assert name not in cursor.__dict__, f"{name} was shadowed after getTypeInfo()" - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + # A normal execute must also leave the class methods intact, and the + # column-name cache populated by the earlier getTypeInfo() call must be + # rebuilt so catalog column names do not leak into an ordinary SELECT. + cursor.execute("SELECT 1 AS one") + rows = cursor.fetchall() + assert rows == [[1]] + row = rows[0] + assert row.one == 1 + # "TYPE_NAME" belonged to the getTypeInfo() result set. If the cache leaked, + # these would resolve to column 0 (returning 1) instead of raising. + with pytest.raises(AttributeError): + _ = row.TYPE_NAME + with pytest.raises(KeyError): + _ = row["TYPE_NAME"] + for name in fetch_methods: + assert name not in cursor.__dict__, f"{name} was shadowed after execute()" - with pytest.raises(ValueError) as exc_info: - cursor.executemany( - "INSERT INTO #test_sis_dec_mem (Price) VALUES (?)", - [(sensitive_value,)], - ) - message = str(exc_info.value) - # Metadata-only message... - assert "Failed to convert parameter" in message - assert "row 0" in message - assert "column 0" in message - # ...no chained cause (the non-DecimalException branch suppresses it)... - assert exc_info.value.__cause__ is None - # ...and the input is absent from the message and formatted traceback. - assert sensitive_value not in message - formatted = "".join( - traceback.format_exception( - type(exc_info.value), exc_info.value, exc_info.value.__traceback__ - ) - ) - assert sensitive_value not in formatted +def test_metadata_case_insensitive_access_when_lowercase(db_connection): + """Regression test for GH #620 follow-up. + + Catalog result sets must keep case-insensitive column access even when the + global ``lowercase`` setting is enabled. With lowercase=True the description + names are lowercased, so the cursor must build a lowercase lookup map for + metadata rows; otherwise original-cased ODBC names like ``TABLE_NAME`` stop + resolving. + """ + original_lowercase = mssql_python.lowercase + try: + mssql_python.lowercase = True + cursor = db_connection.cursor() + try: + row = cursor.getTypeInfo().fetchone() + assert row is not None, "getTypeInfo() should return at least one row" + # Lowercase access (the stored casing) must work... + lower_value = row.type_name + # ...and so must the original ODBC casing, via the lowercase map. + assert row.TYPE_NAME == lower_value + assert row["TYPE_NAME"] == lower_value + assert row["type_name"] == lower_value + finally: + cursor.close() finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mem") + mssql_python.lowercase = original_lowercase -def test_executemany_debug_log_no_parameter_values(db_connection): - """executemany() DEBUG logging must not emit parameter values or rows (GH-503). +def test_gettypeinfo_all_types(cursor): + """Test getTypeInfo with no arguments returns all data types""" + # Get all type information + type_info = cursor.getTypeInfo().fetchall() - The batch-execution debug log previously dumped the first 5 full parameter - rows, leaking the same PII the exception path now redacts. This test enables - DEBUG capture, runs a successful batch of sensitive-looking values, and - asserts the values are absent from the logs while batch metadata is present - (the metadata assertion is a positive control proving capture is working, so - the absence assertions are meaningful rather than vacuous). - """ - import logging as _logging - import io - from mssql_python.logging import logger, driver_logger + # Verify we got results + assert type_info is not None, "getTypeInfo() should return results" + assert len(type_info) > 0, "getTypeInfo() should return at least one data type" - cursor = db_connection.cursor() + # Verify common data types are present + type_names = [str(row.type_name).upper() for row in type_info] + assert any("VARCHAR" in name for name in type_names), "VARCHAR type should be in results" + assert any("INT" in name for name in type_names), "INTEGER type should be in results" - # Values that stand in for PII; both insert successfully into an NVARCHAR - # column so execution reaches the batch debug-log statement. - ssn = "123-45-6789" - email = "jane.doe@example.com" + # Verify first row has expected columns + first_row = type_info[0] + assert hasattr(first_row, "type_name"), "Result should have type_name column" + assert hasattr(first_row, "data_type"), "Result should have data_type column" + assert hasattr(first_row, "column_size"), "Result should have column_size column" + assert hasattr(first_row, "nullable"), "Result should have nullable column" - log_stream = io.StringIO() - test_handler = _logging.StreamHandler(log_stream) - test_handler.setLevel(_logging.DEBUG) - # Save state we mutate so the global logger is restored afterwards. - original_cached_level = logger._cached_level - original_driver_level = driver_logger.level +def test_gettypeinfo_specific_type(cursor): + """Test getTypeInfo with specific type argument""" + from mssql_python.constants import ConstantsDDBC - cursor.execute("DROP TABLE IF EXISTS #test_dbg_no_pii") - try: - cursor.execute("CREATE TABLE #test_dbg_no_pii (Data NVARCHAR(50))") + # Test with VARCHAR type (SQL_VARCHAR) + varchar_info = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() - # Enable DEBUG: bypass the wrapper's cached-level gate and lower the - # underlying stdlib logger, then attach our capturing handler. - logger._cached_level = _logging.DEBUG - driver_logger.setLevel(_logging.DEBUG) - driver_logger.addHandler(test_handler) + # Verify we got results specific to VARCHAR + assert varchar_info is not None, "getTypeInfo(SQL_VARCHAR) should return results" + assert len(varchar_info) > 0, "getTypeInfo(SQL_VARCHAR) should return at least one row" - cursor.executemany( - "INSERT INTO #test_dbg_no_pii (Data) VALUES (?)", - [(ssn,), (email,)], - ) + # All rows should be related to VARCHAR type + for row in varchar_info: + assert ( + "varchar" in row.type_name or "char" in row.type_name + ), f"Expected VARCHAR type, got {row.type_name}" + assert ( + row.data_type == ConstantsDDBC.SQL_VARCHAR.value + ), f"Expected data_type={ConstantsDDBC.SQL_VARCHAR.value}, got {row.data_type}" - test_handler.flush() - log_contents = log_stream.getvalue() - # Positive control: batch metadata is logged (proves capture works). - assert "Executing batch query with 2 parameter sets" in log_contents - # Redaction: no parameter value or row representation is emitted. - assert ssn not in log_contents - assert email not in log_contents - assert repr((ssn,)) not in log_contents - assert repr((email,)) not in log_contents - finally: - driver_logger.removeHandler(test_handler) - driver_logger.setLevel(original_driver_level) - logger._cached_level = original_cached_level - cursor.execute("DROP TABLE IF EXISTS #test_dbg_no_pii") +def test_gettypeinfo_result_structure(cursor): + """Test the structure of getTypeInfo result rows""" + # Get info for a common type like INTEGER + from mssql_python.constants import ConstantsDDBC + int_info = cursor.getTypeInfo(ConstantsDDBC.SQL_INTEGER.value).fetchall() -def test_setinputsizes_sql_decimal_high_precision(db_connection): - """Test setinputsizes with SQL_DECIMAL preserves full DECIMAL(38,18) precision (GH-503).""" - cursor = db_connection.cursor() + # Make sure we have at least one result + assert len(int_info) > 0, "getTypeInfo for INTEGER should return results" - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp") - try: - cursor.execute("CREATE TABLE #test_sis_dec_hp (Value DECIMAL(38,18))") + # Check for all required columns in the result + first_row = int_info[0] + required_columns = [ + "type_name", + "data_type", + "column_size", + "literal_prefix", + "literal_suffix", + "create_params", + "nullable", + "case_sensitive", + "searchable", + "unsigned_attribute", + "fixed_prec_scale", + "auto_unique_value", + "local_type_name", + "minimum_scale", + "maximum_scale", + "sql_data_type", + "sql_datetime_sub", + "num_prec_radix", + "interval_precision", + ] - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 38, 18)]) + for column in required_columns: + assert hasattr(first_row, column), f"Result missing required column: {column}" - high_prec = decimal.Decimal("12345678901234567890.123456789012345678") - cursor.execute( - "INSERT INTO #test_sis_dec_hp (Value) VALUES (?)", - high_prec, - ) - cursor.execute("SELECT Value FROM #test_sis_dec_hp") - row = cursor.fetchone() - assert row[0] == high_prec - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp") +def test_gettypeinfo_numeric_type(cursor): + """Test getTypeInfo for numeric data types""" + from mssql_python.constants import ConstantsDDBC + # Get information about DECIMAL type + decimal_info = cursor.getTypeInfo(ConstantsDDBC.SQL_DECIMAL.value).fetchall() -def test_setinputsizes_sql_decimal_negative_zero(db_connection): - """Test setinputsizes with SQL_DECIMAL handles Decimal('-0.00') correctly (GH-503).""" - cursor = db_connection.cursor() + # Verify decimal-specific attributes + assert len(decimal_info) > 0, "getTypeInfo for DECIMAL should return results" - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz") - try: - cursor.execute("CREATE TABLE #test_sis_dec_negz (Value DECIMAL(18,2))") + decimal_row = decimal_info[0] + # DECIMAL should have precision and scale parameters + assert decimal_row.create_params is not None, "DECIMAL should have create_params" + assert ( + "PRECISION" in decimal_row.create_params.upper() + or "SCALE" in decimal_row.create_params.upper() + ), "DECIMAL create_params should mention precision/scale" - cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) - cursor.execute( - "INSERT INTO #test_sis_dec_negz (Value) VALUES (?)", - decimal.Decimal("-0.00"), - ) + # Numeric types typically use base 10 for the num_prec_radix + assert ( + decimal_row.num_prec_radix == 10 + ), f"Expected num_prec_radix=10 for DECIMAL, got {decimal_row.num_prec_radix}" - cursor.execute("SELECT Value FROM #test_sis_dec_negz") - row = cursor.fetchone() - # SQL Server normalizes -0.00 to 0.00 - assert row[0] == decimal.Decimal("0.00") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz") +def test_gettypeinfo_datetime_types(cursor): + """Test getTypeInfo for datetime types""" + from mssql_python.constants import ConstantsDDBC -def test_setinputsizes_sql_decimal_mixed_null_executemany(db_connection): - """Test setinputsizes with SQL_DECIMAL handles mixed NULL/non-NULL in executemany (GH-503).""" - cursor = db_connection.cursor() + # Get information about TIMESTAMP type instead of DATETIME + # SQL_TYPE_TIMESTAMP (93) is more commonly used for datetime in ODBC + datetime_info = cursor.getTypeInfo(ConstantsDDBC.SQL_TYPE_TIMESTAMP.value).fetchall() - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix") - try: - cursor.execute("CREATE TABLE #test_sis_dec_mix (Id INT, Price DECIMAL(18,2))") + # Verify we got datetime-related results + assert len(datetime_info) > 0, "getTypeInfo for TIMESTAMP should return results" - cursor.setinputsizes( - [ - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_DECIMAL, 18, 2), - ] - ) + # Check for datetime-specific attributes + first_row = datetime_info[0] + assert hasattr(first_row, "type_name"), "Result should have type_name column" - cursor.executemany( - "INSERT INTO #test_sis_dec_mix (Id, Price) VALUES (?, ?)", - [ - (1, decimal.Decimal("10.50")), - (2, None), - (3, decimal.Decimal("30.75")), - (4, None), - ], - ) + # Datetime type names often contain 'date', 'time', or 'datetime' + type_name_lower = first_row.type_name.lower() + assert any( + term in type_name_lower for term in ["date", "time", "timestamp", "datetime"] + ), f"Expected datetime-related type name, got {first_row.type_name}" - cursor.execute("SELECT Id, Price FROM #test_sis_dec_mix ORDER BY Id") - rows = cursor.fetchall() - assert len(rows) == 4 - assert rows[0][1] == decimal.Decimal("10.50") - assert rows[1][1] is None - assert rows[2][1] == decimal.Decimal("30.75") - assert rows[3][1] is None - finally: - cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix") +def test_gettypeinfo_multiple_calls(cursor): + """Test calling getTypeInfo multiple times in succession""" + from mssql_python.constants import ConstantsDDBC + # First call - get all types + all_types = cursor.getTypeInfo().fetchall() + assert len(all_types) > 0, "First call to getTypeInfo should return results" -def test_decimal_without_setinputsizes_no_regression(db_connection): - """Verify plain Decimal binding without setinputsizes still works (GH-503 regression check).""" - cursor = db_connection.cursor() + # Second call - get VARCHAR type + varchar_info = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() + assert len(varchar_info) > 0, "Second call to getTypeInfo should return results" - cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg") - try: - cursor.execute("CREATE TABLE #test_dec_noreg (Price DECIMAL(18,2))") + # Third call - get INTEGER type + int_info = cursor.getTypeInfo(ConstantsDDBC.SQL_INTEGER.value).fetchall() + assert len(int_info) > 0, "Third call to getTypeInfo should return results" - # Single execute without setinputsizes - cursor.execute( - "INSERT INTO #test_dec_noreg (Price) VALUES (?)", - decimal.Decimal("49.99"), - ) + # Verify the results are different between calls + assert len(all_types) > len( + varchar_info + ), "All types should return more rows than specific type" - # executemany without setinputsizes - cursor.executemany( - "INSERT INTO #test_dec_noreg (Price) VALUES (?)", - [(decimal.Decimal("99.99"),), (decimal.Decimal("0.01"),)], - ) - cursor.execute("SELECT Price FROM #test_dec_noreg ORDER BY Price") - rows = cursor.fetchall() +def test_gettypeinfo_binary_types(cursor): + """Test getTypeInfo for binary data types""" + from mssql_python.constants import ConstantsDDBC - assert len(rows) == 3 - assert rows[0][0] == decimal.Decimal("0.01") - assert rows[1][0] == decimal.Decimal("49.99") - assert rows[2][0] == decimal.Decimal("99.99") - finally: - cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg") + # Get information about BINARY or VARBINARY type + binary_info = cursor.getTypeInfo(ConstantsDDBC.SQL_BINARY.value).fetchall() + # Verify we got binary-related results + assert len(binary_info) > 0, "getTypeInfo for BINARY should return results" -def test_cursor_setinputsizes_reset(db_connection): - """Test that setinputsizes is reset after execution""" + # Check for binary-specific attributes + for row in binary_info: + type_name_lower = row.type_name.lower() + # Include 'timestamp' as SQL Server reports it as a binary type + assert any( + term in type_name_lower for term in ["binary", "blob", "image", "timestamp"] + ), f"Expected binary-related type name, got {row.type_name}" - cursor = db_connection.cursor() + # Binary types typically don't support case sensitivity + assert ( + row.case_sensitive == 0 + ), f"Binary types should not be case sensitive, got {row.case_sensitive}" - # Create a test table - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_reset") - cursor.execute(""" - CREATE TABLE #test_inputsizes_reset ( - col1 NVARCHAR(100), - col2 INT - ) - """) - - # Set input sizes for parameters - cursor.setinputsizes([(mssql_python.SQL_WVARCHAR, 100, 0), (mssql_python.SQL_INTEGER, 0, 0)]) - - # Execute with parameters - cursor.execute("INSERT INTO #test_inputsizes_reset VALUES (?, ?)", "Test String", 42) - - # Verify inputsizes was reset - assert cursor._inputsizes is None - - # Now execute again without setting input sizes - cursor.execute("INSERT INTO #test_inputsizes_reset VALUES (?, ?)", "Another String", 84) - - # Verify both rows were inserted correctly - cursor.execute("SELECT * FROM #test_inputsizes_reset ORDER BY col2") - rows = cursor.fetchall() - - assert len(rows) == 2 - assert rows[0][0] == "Test String" - assert rows[0][1] == 42 - assert rows[1][0] == "Another String" - assert rows[1][1] == 84 - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_reset") +def test_gettypeinfo_cached_results(cursor): + """Test that multiple identical calls to getTypeInfo are efficient""" + from mssql_python.constants import ConstantsDDBC + import time + # First call - might be slower + start_time = time.time() + first_result = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() + first_duration = time.time() - start_time -def test_cursor_setinputsizes_override_inference(db_connection): - """Test that setinputsizes overrides type inference""" + # Give the system a moment + time.sleep(0.1) - cursor = db_connection.cursor() + # Second call with same type - should be similar or faster + start_time = time.time() + second_result = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() + second_duration = time.time() - start_time - # Create a test table with specific types - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_override") - cursor.execute(""" - CREATE TABLE #test_inputsizes_override ( - small_int SMALLINT, - big_text NVARCHAR(MAX) - ) - """) + # Results should be consistent + assert len(first_result) == len( + second_result + ), "Multiple calls should return same number of results" - # Set input sizes that override the default inference - # For SMALLINT, use a valid precision value (5 is typical for SMALLINT) - cursor.setinputsizes( - [ - (mssql_python.SQL_SMALLINT, 5, 0), # Use valid precision for SMALLINT - (mssql_python.SQL_WVARCHAR, 8000, 0), # Force short string to NVARCHAR(MAX) - ] - ) + # Both calls should return the correct type info + for row in second_result: + assert ( + row.data_type == ConstantsDDBC.SQL_VARCHAR.value + ), f"Expected SQL_VARCHAR type, got {row.data_type}" - # Test with values that would normally be inferred differently - big_number = 30000 # Would normally be INTEGER or BIGINT - short_text = "abc" # Would normally be a regular NVARCHAR +def test_procedures_setup(cursor, db_connection): + """Create a test schema and procedures for testing""" try: + # Create a test schema for isolation cursor.execute( - "INSERT INTO #test_inputsizes_override VALUES (?, ?)", - big_number, - short_text, + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_proc_schema') EXEC('CREATE SCHEMA pytest_proc_schema')" ) - # Verify the row was inserted (may have been truncated by SQL Server) - cursor.execute("SELECT * FROM #test_inputsizes_override") - row = cursor.fetchone() + # Create test stored procedures + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_proc1 + AS + BEGIN + SELECT 1 AS result + END + """) - # SQL Server would either truncate or round the value - assert row[1] == short_text + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_proc2 + @param1 INT, + @param2 VARCHAR(50) OUTPUT + AS + BEGIN + SELECT @param2 = 'Output ' + CAST(@param1 AS VARCHAR(10)) + RETURN @param1 + END + """) + db_connection.commit() except Exception as e: - # If an exception occurs, it should be related to the data type conversion - # Add "invalid precision" to the expected error messages - error_text = str(e).lower() - assert any( - text in error_text - for text in [ - "overflow", - "out of range", - "convert", - "invalid precision", - "precision value", - ] - ), f"Unexpected error: {e}" - - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_override") - + pytest.fail(f"Test setup failed: {e}") -def test_setinputsizes_parameter_count_mismatch_fewer(db_connection): - """Test setinputsizes with fewer sizes than parameters""" - import warnings - cursor = db_connection.cursor() +def test_procedures_all(cursor, db_connection): + """Test getting information about all procedures""" + # First set up our test procedures + test_procedures_setup(cursor, db_connection) - # Create a test table - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") - cursor.execute(""" - CREATE TABLE #test_inputsizes_mismatch ( - col1 INT, - col2 NVARCHAR(100), - col3 FLOAT - ) - """) + try: + # Get all procedures + procs = cursor.procedures().fetchall() - # Set fewer input sizes than parameters - cursor.setinputsizes( - [ - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_WVARCHAR, 100, 0), - # Missing third parameter type - ] - ) + # Verify we got results + assert procs is not None, "procedures() should return results" + assert len(procs) > 0, "procedures() should return at least one procedure" - # Execute with more parameters than specified input sizes - # This should use automatic type inference for the third parameter - with warnings.catch_warnings(record=True) as w: - cursor.execute( - "INSERT INTO #test_inputsizes_mismatch VALUES (?, ?, ?)", - 1, - "Test String", - 3.14, - ) - assert len(w) > 0, "Warning should be issued for parameter count mismatch" - assert "number of input sizes" in str(w[0].message).lower() + # Verify structure of results + first_row = procs[0] + assert hasattr(first_row, "procedure_cat"), "Result should have procedure_cat column" + assert hasattr(first_row, "procedure_schem"), "Result should have procedure_schem column" + assert hasattr(first_row, "procedure_name"), "Result should have procedure_name column" + assert hasattr(first_row, "num_input_params"), "Result should have num_input_params column" + assert hasattr( + first_row, "num_output_params" + ), "Result should have num_output_params column" + assert hasattr(first_row, "num_result_sets"), "Result should have num_result_sets column" + assert hasattr(first_row, "remarks"), "Result should have remarks column" + assert hasattr(first_row, "procedure_type"), "Result should have procedure_type column" - # Verify data was inserted correctly - cursor.execute("SELECT * FROM #test_inputsizes_mismatch") - row = cursor.fetchone() + finally: + # Clean up happens in test_procedures_cleanup + pass - assert row[0] == 1 - assert row[1] == "Test String" - assert abs(row[2] - 3.14) < 0.0001 - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") +def test_procedures_specific(cursor, db_connection): + """Test getting information about a specific procedure""" + try: + # Get specific procedure + procs = cursor.procedures(procedure="test_proc1", schema="pytest_proc_schema").fetchall() + # Verify we got the correct procedure + assert len(procs) == 1, "Should find exactly one procedure" + proc = procs[0] + assert proc.procedure_name == "test_proc1;1", "Wrong procedure name returned" + assert proc.procedure_schem == "pytest_proc_schema", "Wrong schema returned" -def test_setinputsizes_parameter_count_mismatch_more(db_connection): - """Test setinputsizes with more sizes than parameters""" - import warnings + finally: + # Clean up happens in test_procedures_cleanup + pass - cursor = db_connection.cursor() - # Create a test table - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") - cursor.execute(""" - CREATE TABLE #test_inputsizes_mismatch ( - col1 INT, - col2 NVARCHAR(100) - ) - """) +def test_procedures_with_schema(cursor, db_connection): + """Test getting procedures with schema filter""" + try: + # Get procedures for our test schema + procs = cursor.procedures(schema="pytest_proc_schema").fetchall() - # Set more input sizes than parameters - cursor.setinputsizes( - [ - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_WVARCHAR, 100, 0), - (mssql_python.SQL_FLOAT, 0, 0), # Extra parameter type - ] - ) + # Verify schema filter worked + assert len(procs) >= 2, "Should find at least two procedures in schema" + for proc in procs: + assert ( + proc.procedure_schem == "pytest_proc_schema" + ), f"Expected schema pytest_proc_schema, got {proc.procedure_schem}" - # Execute with fewer parameters than specified input sizes - with warnings.catch_warnings(record=True) as w: - cursor.execute("INSERT INTO #test_inputsizes_mismatch VALUES (?, ?)", 1, "Test String") - assert len(w) > 0, "Warning should be issued for parameter count mismatch" - assert "number of input sizes" in str(w[0].message).lower() + # Verify our specific procedures are in the results + proc_names = [p.procedure_name for p in procs] + assert "test_proc1;1" in proc_names, "test_proc1;1 should be in results" + assert "test_proc2;1" in proc_names, "test_proc2;1 should be in results" - # Verify data was inserted correctly - cursor.execute("SELECT * FROM #test_inputsizes_mismatch") - row = cursor.fetchone() + finally: + # Clean up happens in test_procedures_cleanup + pass - assert row[0] == 1 - assert row[1] == "Test String" - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_mismatch") +def test_procedures_nonexistent(cursor): + """Test procedures() with non-existent procedure name""" + # Use a procedure name that's highly unlikely to exist + procs = cursor.procedures(procedure="nonexistent_procedure_xyz123").fetchall() + # Should return empty list, not error + assert isinstance(procs, list), "Should return a list for non-existent procedure" + assert len(procs) == 0, "Should return empty list for non-existent procedure" -def test_setinputsizes_with_null_values(db_connection): - """Test setinputsizes with NULL values for various data types""" - cursor = db_connection.cursor() +def test_procedures_catalog_filter(cursor, db_connection): + """Test procedures() with catalog filter""" + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + current_db = cursor.fetchone().current_db - # Create a test table with multiple data types - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_null") - cursor.execute(""" - CREATE TABLE #test_inputsizes_null ( - int_col INT, - string_col NVARCHAR(100), - float_col FLOAT, - date_col DATE, - binary_col VARBINARY(100) - ) - """) + try: + # Get procedures with current catalog + procs = cursor.procedures(catalog=current_db, schema="pytest_proc_schema").fetchall() - # Set input sizes for all columns - cursor.setinputsizes( - [ - (mssql_python.SQL_INTEGER, 0, 0), - (mssql_python.SQL_WVARCHAR, 100, 0), - (mssql_python.SQL_FLOAT, 0, 0), - (mssql_python.SQL_DATE, 0, 0), - (mssql_python.SQL_VARBINARY, 100, 0), - ] - ) + # Verify catalog filter worked + assert len(procs) >= 2, "Should find procedures in current catalog" + for proc in procs: + assert ( + proc.procedure_cat == current_db + ), f"Expected catalog {current_db}, got {proc.procedure_cat}" - # Insert row with all NULL values - cursor.execute( - "INSERT INTO #test_inputsizes_null VALUES (?, ?, ?, ?, ?)", - None, - None, - None, - None, - None, - ) + # Get procedures with non-existent catalog + fake_procs = cursor.procedures(catalog="nonexistent_db_xyz123").fetchall() + assert len(fake_procs) == 0, "Should return empty list for non-existent catalog" - # Insert row with mix of NULL and non-NULL values - cursor.execute( - "INSERT INTO #test_inputsizes_null VALUES (?, ?, ?, ?, ?)", - 42, - None, - 3.14, - None, - b"binary data", - ) + finally: + # Clean up happens in test_procedures_cleanup + pass - # Verify data was inserted correctly - cursor.execute( - "SELECT * FROM #test_inputsizes_null ORDER BY CASE WHEN int_col IS NULL THEN 0 ELSE 1 END" - ) - rows = cursor.fetchall() - # First row should be all NULLs - assert len(rows) == 2 - assert rows[0][0] is None - assert rows[0][1] is None - assert rows[0][2] is None - assert rows[0][3] is None - assert rows[0][4] is None +def test_procedures_with_parameters(cursor, db_connection): + """Test that procedures() correctly reports parameter information""" + try: + # Create a simpler procedure with basic parameters + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_params_proc + @in1 INT, + @in2 VARCHAR(50) + AS + BEGIN + SELECT @in1 AS value1, @in2 AS value2 + END + """) + db_connection.commit() - # Second row should have mix of NULL and non-NULL - assert rows[1][0] == 42 - assert rows[1][1] is None - assert abs(rows[1][2] - 3.14) < 0.0001 - assert rows[1][3] is None - assert rows[1][4] == b"binary data" + # Get procedure info + procs = cursor.procedures( + procedure="test_params_proc", schema="pytest_proc_schema" + ).fetchall() - # Clean up - cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_null") + # Verify we found the procedure + assert len(procs) == 1, "Should find exactly one procedure" + proc = procs[0] + # Just check if columns exist, don't check specific values + assert hasattr(proc, "num_input_params"), "Result should have num_input_params column" + assert hasattr(proc, "num_output_params"), "Result should have num_output_params column" -def test_setinputsizes_sql_injection_protection(db_connection): - """Test that setinputsizes doesn't allow SQL injection""" - cursor = db_connection.cursor() + # Test simple execution without output parameters + cursor.execute("EXEC pytest_proc_schema.test_params_proc 10, 'Test'") - # Create a test table - cursor.execute("CREATE TABLE #test_sql_injection (id INT, name VARCHAR(100))") + # Verify the procedure returned expected values + row = cursor.fetchone() + assert row is not None, "Procedure should return results" + assert row[0] == 10, "First parameter value incorrect" + assert row[1] == "Test", "Second parameter value incorrect" - # Insert legitimate data - cursor.execute("INSERT INTO #test_sql_injection VALUES (1, 'safe')") + finally: + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_params_proc") + db_connection.commit() - # Set input sizes with potentially malicious SQL types and sizes - try: - # This should fail with a validation error - cursor.setinputsizes([(999999, 1000000, 1000000)]) # Invalid SQL type - except ValueError: - pass # Expected - # Test with valid types but attempt SQL injection in parameter - cursor.setinputsizes([(mssql_python.SQL_VARCHAR, 100, 0)]) - injection_attempt = "x'; DROP TABLE #test_sql_injection; --" +def test_procedures_result_set_info(cursor, db_connection): + """Test that procedures() reports information about result sets""" + try: + # Create procedures with different result set patterns + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_no_results + AS + BEGIN + DECLARE @x INT = 1 + END + """) - # This should safely parameterize without executing the injection - cursor.execute("SELECT * FROM #test_sql_injection WHERE name = ?", injection_attempt) + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_one_result + AS + BEGIN + SELECT 1 AS col1, 'test' AS col2 + END + """) - # Verify table still exists and injection didn't work - cursor.execute("SELECT COUNT(*) FROM #test_sql_injection") - count = cursor.fetchone()[0] - assert count == 1, "SQL injection protection failed" + cursor.execute(""" + CREATE OR ALTER PROCEDURE pytest_proc_schema.test_multiple_results + AS + BEGIN + SELECT 1 AS result1 + SELECT 'test' AS result2 + SELECT GETDATE() AS result3 + END + """) + db_connection.commit() - # Clean up - cursor.execute("DROP TABLE #test_sql_injection") + # Get procedure info for all test procedures + procs = cursor.procedures(schema="pytest_proc_schema", procedure="test_%").fetchall() + # Verify we found at least some procedures + assert len(procs) > 0, "Should find at least some test procedures" -def test_fetch_methods_not_shadowed_on_instance(cursor): - """Regression test for GH #620. + # Get the procedure names we found + result_proc_names = [ + p.procedure_name + for p in procs + if p.procedure_name.startswith("test_") and "results" in p.procedure_name + ] + print(f"Found result procedures: {result_proc_names}") - The fetch methods (fetchone/fetchmany/fetchall) must remain regular class - methods and never be reassigned as instance attributes. The previous - implementation swapped them for closures on the instance while preparing - catalog/metadata result sets, which produced a union type that broke static - type checkers (e.g. ``ty`` reported a spurious missing ``self`` argument). - """ - fetch_methods = ("fetchone", "fetchmany", "fetchall") + # The num_result_sets column exists but might not have correct values + for proc in procs: + assert hasattr(proc, "num_result_sets"), "Result should have num_result_sets column" - # Pristine cursor: methods come from the class, not the instance. - for name in fetch_methods: - assert name not in cursor.__dict__, f"{name} should not be an instance attribute" + # Test execution of the procedures to verify they work + cursor.execute("EXEC pytest_proc_schema.test_no_results") + # Procedures with no results should have no description and calling fetchall() should raise an error + assert ( + cursor.description is None + ), "test_no_results should have no description (no result set)" + # Don't call fetchall() on procedures with no results - this is invalid in ODBC - # A catalog helper historically reassigned the fetch methods. Make sure it - # no longer shadows them on the instance. - cursor.getTypeInfo().fetchall() - for name in fetch_methods: - assert name not in cursor.__dict__, f"{name} was shadowed after getTypeInfo()" + cursor.execute("EXEC pytest_proc_schema.test_one_result") + rows = cursor.fetchall() + assert len(rows) == 1, "test_one_result should return one row" + assert len(rows[0]) == 2, "test_one_result row should have two columns" - # A normal execute must also leave the class methods intact, and the - # column-name cache populated by the earlier getTypeInfo() call must be - # rebuilt so catalog column names do not leak into an ordinary SELECT. - cursor.execute("SELECT 1 AS one") - rows = cursor.fetchall() - assert rows == [[1]] - row = rows[0] - assert row.one == 1 - # "TYPE_NAME" belonged to the getTypeInfo() result set. If the cache leaked, - # these would resolve to column 0 (returning 1) instead of raising. - with pytest.raises(AttributeError): - _ = row.TYPE_NAME - with pytest.raises(KeyError): - _ = row["TYPE_NAME"] - for name in fetch_methods: - assert name not in cursor.__dict__, f"{name} was shadowed after execute()" + cursor.execute("EXEC pytest_proc_schema.test_multiple_results") + rows1 = cursor.fetchall() + assert len(rows1) == 1, "First result set should have one row" + assert cursor.nextset(), "Should have a second result set" + rows2 = cursor.fetchall() + assert len(rows2) == 1, "Second result set should have one row" + assert cursor.nextset(), "Should have a third result set" + rows3 = cursor.fetchall() + assert len(rows3) == 1, "Third result set should have one row" + finally: + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_no_results") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_one_result") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_multiple_results") + db_connection.commit() -def test_metadata_case_insensitive_access_when_lowercase(db_connection): - """Regression test for GH #620 follow-up. - Catalog result sets must keep case-insensitive column access even when the - global ``lowercase`` setting is enabled. With lowercase=True the description - names are lowercased, so the cursor must build a lowercase lookup map for - metadata rows; otherwise original-cased ODBC names like ``TABLE_NAME`` stop - resolving. - """ - original_lowercase = mssql_python.lowercase +def test_procedures_cleanup(cursor, db_connection): + """Clean up all test procedures and schema after testing""" try: - mssql_python.lowercase = True - cursor = db_connection.cursor() - try: - row = cursor.getTypeInfo().fetchone() - assert row is not None, "getTypeInfo() should return at least one row" - # Lowercase access (the stored casing) must work... - lower_value = row.type_name - # ...and so must the original ODBC casing, via the lowercase map. - assert row.TYPE_NAME == lower_value - assert row["TYPE_NAME"] == lower_value - assert row["type_name"] == lower_value - finally: - cursor.close() - finally: - mssql_python.lowercase = original_lowercase - + # Drop all test procedures + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_proc1") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_proc2") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_params_proc") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_no_results") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_one_result") + cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_multiple_results") -def test_gettypeinfo_all_types(cursor): - """Test getTypeInfo with no arguments returns all data types""" - # Get all type information - type_info = cursor.getTypeInfo().fetchall() + # Drop the test schema + cursor.execute("DROP SCHEMA IF EXISTS pytest_proc_schema") + db_connection.commit() + except Exception as e: + pytest.fail(f"Test cleanup failed: {e}") - # Verify we got results - assert type_info is not None, "getTypeInfo() should return results" - assert len(type_info) > 0, "getTypeInfo() should return at least one data type" - # Verify common data types are present - type_names = [str(row.type_name).upper() for row in type_info] - assert any("VARCHAR" in name for name in type_names), "VARCHAR type should be in results" - assert any("INT" in name for name in type_names), "INTEGER type should be in results" +def test_foreignkeys_setup(cursor, db_connection): + """Create tables with foreign key relationships for testing""" + try: + # Create a test schema for isolation + cursor.execute( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_fk_schema') EXEC('CREATE SCHEMA pytest_fk_schema')" + ) - # Verify first row has expected columns - first_row = type_info[0] - assert hasattr(first_row, "type_name"), "Result should have type_name column" - assert hasattr(first_row, "data_type"), "Result should have data_type column" - assert hasattr(first_row, "column_size"), "Result should have column_size column" - assert hasattr(first_row, "nullable"), "Result should have nullable column" + # Drop tables if they exist (in reverse order to avoid constraint conflicts) + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + # Create parent table + cursor.execute(""" + CREATE TABLE pytest_fk_schema.customers ( + customer_id INT PRIMARY KEY, + customer_name VARCHAR(100) NOT NULL + ) + """) -def test_gettypeinfo_specific_type(cursor): - """Test getTypeInfo with specific type argument""" - from mssql_python.constants import ConstantsDDBC + # Create child table with foreign key + cursor.execute(""" + CREATE TABLE pytest_fk_schema.orders ( + order_id INT PRIMARY KEY, + order_date DATETIME NOT NULL, + customer_id INT NOT NULL, + total_amount DECIMAL(10, 2) NOT NULL, + CONSTRAINT FK_Orders_Customers FOREIGN KEY (customer_id) + REFERENCES pytest_fk_schema.customers (customer_id) + ) + """) - # Test with VARCHAR type (SQL_VARCHAR) - varchar_info = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() + # Insert test data + cursor.execute(""" + INSERT INTO pytest_fk_schema.customers (customer_id, customer_name) + VALUES (1, 'Test Customer 1'), (2, 'Test Customer 2') + """) - # Verify we got results specific to VARCHAR - assert varchar_info is not None, "getTypeInfo(SQL_VARCHAR) should return results" - assert len(varchar_info) > 0, "getTypeInfo(SQL_VARCHAR) should return at least one row" + cursor.execute(""" + INSERT INTO pytest_fk_schema.orders (order_id, order_date, customer_id, total_amount) + VALUES (101, GETDATE(), 1, 150.00), (102, GETDATE(), 2, 250.50) + """) - # All rows should be related to VARCHAR type - for row in varchar_info: - assert ( - "varchar" in row.type_name or "char" in row.type_name - ), f"Expected VARCHAR type, got {row.type_name}" - assert ( - row.data_type == ConstantsDDBC.SQL_VARCHAR.value - ), f"Expected data_type={ConstantsDDBC.SQL_VARCHAR.value}, got {row.data_type}" + db_connection.commit() + except Exception as e: + pytest.fail(f"Test setup failed: {e}") -def test_gettypeinfo_result_structure(cursor): - """Test the structure of getTypeInfo result rows""" - # Get info for a common type like INTEGER - from mssql_python.constants import ConstantsDDBC +def test_foreignkeys_all(cursor, db_connection): + """Test getting all foreign keys""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) - int_info = cursor.getTypeInfo(ConstantsDDBC.SQL_INTEGER.value).fetchall() + # Get all foreign keys + fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() - # Make sure we have at least one result - assert len(int_info) > 0, "getTypeInfo for INTEGER should return results" + # Verify we got results + assert fks is not None, "foreignKeys() should return results" + assert len(fks) > 0, "foreignKeys() should return at least one foreign key" - # Check for all required columns in the result - first_row = int_info[0] - required_columns = [ - "type_name", - "data_type", - "column_size", - "literal_prefix", - "literal_suffix", - "create_params", - "nullable", - "case_sensitive", - "searchable", - "unsigned_attribute", - "fixed_prec_scale", - "auto_unique_value", - "local_type_name", - "minimum_scale", - "maximum_scale", - "sql_data_type", - "sql_datetime_sub", - "num_prec_radix", - "interval_precision", - ] + # Verify our test FK is in the results + # Search case-insensitively since the database might return different case + found_test_fk = False + for fk in fks: + if fk.fktable_name.lower() == "orders" and fk.pktable_name.lower() == "customers": + found_test_fk = True + break - for column in required_columns: - assert hasattr(first_row, column), f"Result missing required column: {column}" + assert found_test_fk, "Could not find the test foreign key in results" + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() -def test_gettypeinfo_numeric_type(cursor): - """Test getTypeInfo for numeric data types""" - from mssql_python.constants import ConstantsDDBC - # Get information about DECIMAL type - decimal_info = cursor.getTypeInfo(ConstantsDDBC.SQL_DECIMAL.value).fetchall() +def test_foreignkeys_specific_table(cursor, db_connection): + """Test getting foreign keys for a specific table""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) - # Verify decimal-specific attributes - assert len(decimal_info) > 0, "getTypeInfo for DECIMAL should return results" + # Get foreign keys for the orders table + fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() - decimal_row = decimal_info[0] - # DECIMAL should have precision and scale parameters - assert decimal_row.create_params is not None, "DECIMAL should have create_params" - assert ( - "PRECISION" in decimal_row.create_params.upper() - or "SCALE" in decimal_row.create_params.upper() - ), "DECIMAL create_params should mention precision/scale" + # Verify we got results + assert len(fks) == 1, "Should find exactly one foreign key for orders table" - # Numeric types typically use base 10 for the num_prec_radix - assert ( - decimal_row.num_prec_radix == 10 - ), f"Expected num_prec_radix=10 for DECIMAL, got {decimal_row.num_prec_radix}" + # Verify the foreign key details + fk = fks[0] + assert fk.fktable_name.lower() == "orders", "Wrong foreign key table name" + assert fk.pktable_name.lower() == "customers", "Wrong primary key table name" + assert fk.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" + assert fk.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() -def test_gettypeinfo_datetime_types(cursor): - """Test getTypeInfo for datetime types""" - from mssql_python.constants import ConstantsDDBC - # Get information about TIMESTAMP type instead of DATETIME - # SQL_TYPE_TIMESTAMP (93) is more commonly used for datetime in ODBC - datetime_info = cursor.getTypeInfo(ConstantsDDBC.SQL_TYPE_TIMESTAMP.value).fetchall() +def test_foreignkeys_specific_foreign_table(cursor, db_connection): + """Test getting foreign keys that reference a specific table""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) - # Verify we got datetime-related results - assert len(datetime_info) > 0, "getTypeInfo for TIMESTAMP should return results" + # Get foreign keys that reference the customers table + fks = cursor.foreignKeys( + foreignTable="customers", foreignSchema="pytest_fk_schema" + ).fetchall() - # Check for datetime-specific attributes - first_row = datetime_info[0] - assert hasattr(first_row, "type_name"), "Result should have type_name column" + # Verify we got results + assert len(fks) > 0, "Should find at least one foreign key referencing customers table" - # Datetime type names often contain 'date', 'time', or 'datetime' - type_name_lower = first_row.type_name.lower() - assert any( - term in type_name_lower for term in ["date", "time", "timestamp", "datetime"] - ), f"Expected datetime-related type name, got {first_row.type_name}" + # Verify our test FK is in the results + found_test_fk = False + for fk in fks: + if fk.fktable_name.lower() == "orders" and fk.pktable_name.lower() == "customers": + found_test_fk = True + break + assert found_test_fk, "Could not find the test foreign key in results" -def test_gettypeinfo_multiple_calls(cursor): - """Test calling getTypeInfo multiple times in succession""" - from mssql_python.constants import ConstantsDDBC + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() - # First call - get all types - all_types = cursor.getTypeInfo().fetchall() - assert len(all_types) > 0, "First call to getTypeInfo should return results" - # Second call - get VARCHAR type - varchar_info = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() - assert len(varchar_info) > 0, "Second call to getTypeInfo should return results" +def test_foreignkeys_both_tables(cursor, db_connection): + """Test getting foreign keys with both table and foreignTable specified""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) - # Third call - get INTEGER type - int_info = cursor.getTypeInfo(ConstantsDDBC.SQL_INTEGER.value).fetchall() - assert len(int_info) > 0, "Third call to getTypeInfo should return results" + # Get foreign keys between the two tables + fks = cursor.foreignKeys( + table="orders", + schema="pytest_fk_schema", + foreignTable="customers", + foreignSchema="pytest_fk_schema", + ).fetchall() - # Verify the results are different between calls - assert len(all_types) > len( - varchar_info - ), "All types should return more rows than specific type" + # Verify we got results + assert len(fks) == 1, "Should find exactly one foreign key between specified tables" + # Verify the foreign key details + fk = fks[0] + assert fk.fktable_name.lower() == "orders", "Wrong foreign key table name" + assert fk.pktable_name.lower() == "customers", "Wrong primary key table name" + assert fk.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" + assert fk.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" -def test_gettypeinfo_binary_types(cursor): - """Test getTypeInfo for binary data types""" - from mssql_python.constants import ConstantsDDBC + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() - # Get information about BINARY or VARBINARY type - binary_info = cursor.getTypeInfo(ConstantsDDBC.SQL_BINARY.value).fetchall() - # Verify we got binary-related results - assert len(binary_info) > 0, "getTypeInfo for BINARY should return results" +def test_foreignkeys_nonexistent(cursor): + """Test foreignKeys() with non-existent table name""" + # Use a table name that's highly unlikely to exist + fks = cursor.foreignKeys(table="nonexistent_table_xyz123").fetchall() - # Check for binary-specific attributes - for row in binary_info: - type_name_lower = row.type_name.lower() - # Include 'timestamp' as SQL Server reports it as a binary type - assert any( - term in type_name_lower for term in ["binary", "blob", "image", "timestamp"] - ), f"Expected binary-related type name, got {row.type_name}" + # Should return empty list, not error + assert isinstance(fks, list), "Should return a list for non-existent table" + assert len(fks) == 0, "Should return empty list for non-existent table" - # Binary types typically don't support case sensitivity - assert ( - row.case_sensitive == 0 - ), f"Binary types should not be case sensitive, got {row.case_sensitive}" +def test_foreignkeys_catalog_schema(cursor, db_connection): + """Test foreignKeys() with catalog and schema filters""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) -def test_gettypeinfo_cached_results(cursor): - """Test that multiple identical calls to getTypeInfo are efficient""" - from mssql_python.constants import ConstantsDDBC - import time + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + row = cursor.fetchone() + current_db = row.current_db - # First call - might be slower - start_time = time.time() - first_result = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() - first_duration = time.time() - start_time + # Get foreign keys with current catalog and pytest schema + fks = cursor.foreignKeys( + table="orders", catalog=current_db, schema="pytest_fk_schema" + ).fetchall() - # Give the system a moment - time.sleep(0.1) + # Verify we got results + assert len(fks) > 0, "Should find foreign keys with correct catalog/schema" - # Second call with same type - should be similar or faster - start_time = time.time() - second_result = cursor.getTypeInfo(ConstantsDDBC.SQL_VARCHAR.value).fetchall() - second_duration = time.time() - start_time + # Verify catalog/schema in results + for fk in fks: + assert fk.fktable_cat == current_db, "Wrong foreign key table catalog" + assert fk.fktable_schem == "pytest_fk_schema", "Wrong foreign key table schema" - # Results should be consistent - assert len(first_result) == len( - second_result - ), "Multiple calls should return same number of results" + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() - # Both calls should return the correct type info - for row in second_result: - assert ( - row.data_type == ConstantsDDBC.SQL_VARCHAR.value - ), f"Expected SQL_VARCHAR type, got {row.data_type}" +def test_foreignkeys_result_structure(cursor, db_connection): + """Test the structure of foreignKeys result rows""" + try: + # First set up our test tables + test_foreignkeys_setup(cursor, db_connection) + + # Get foreign keys for the orders table + fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() -def test_procedures_setup(cursor, db_connection): - """Create a test schema and procedures for testing""" + # Verify we got results + assert len(fks) > 0, "Should find at least one foreign key" + + # Check for all required columns in the result + first_row = fks[0] + required_columns = [ + "pktable_cat", + "pktable_schem", + "pktable_name", + "pkcolumn_name", + "fktable_cat", + "fktable_schem", + "fktable_name", + "fkcolumn_name", + "key_seq", + "update_rule", + "delete_rule", + "fk_name", + "pk_name", + "deferrability", + ] + + for column in required_columns: + assert hasattr(first_row, column), f"Result missing required column: {column}" + + # Verify specific values + assert first_row.fktable_name.lower() == "orders", "Wrong foreign key table name" + assert first_row.pktable_name.lower() == "customers", "Wrong primary key table name" + assert first_row.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" + assert first_row.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" + assert first_row.key_seq == 1, "Wrong key sequence number" + assert first_row.fk_name is not None, "Foreign key name should not be None" + assert first_row.pk_name is not None, "Primary key name should not be None" + + finally: + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + db_connection.commit() + + +def test_foreignkeys_multiple_column_fk(cursor, db_connection): + """Test foreignKeys() with a multi-column foreign key""" try: - # Create a test schema for isolation + # First create the schema if needed cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_proc_schema') EXEC('CREATE SCHEMA pytest_proc_schema')" + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_fk_schema') EXEC('CREATE SCHEMA pytest_fk_schema')" ) - # Create test stored procedures + # Drop tables if they exist (in reverse order to avoid constraint conflicts) + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") + + # Create parent table with composite primary key cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_proc1 - AS - BEGIN - SELECT 1 AS result - END + CREATE TABLE pytest_fk_schema.product_variants ( + product_id INT NOT NULL, + variant_id INT NOT NULL, + variant_name VARCHAR(100) NOT NULL, + PRIMARY KEY (product_id, variant_id) + ) """) + # Create child table with composite foreign key cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_proc2 - @param1 INT, - @param2 VARCHAR(50) OUTPUT - AS - BEGIN - SELECT @param2 = 'Output ' + CAST(@param1 AS VARCHAR(10)) - RETURN @param1 - END + CREATE TABLE pytest_fk_schema.order_details ( + order_id INT NOT NULL, + product_id INT NOT NULL, + variant_id INT NOT NULL, + quantity INT NOT NULL, + PRIMARY KEY (order_id, product_id, variant_id), + CONSTRAINT FK_OrderDetails_ProductVariants FOREIGN KEY (product_id, variant_id) + REFERENCES pytest_fk_schema.product_variants (product_id, variant_id) + ) """) db_connection.commit() - except Exception as e: - pytest.fail(f"Test setup failed: {e}") + # Get foreign keys for the order_details table + fks = cursor.foreignKeys(table="order_details", schema="pytest_fk_schema").fetchall() -def test_procedures_all(cursor, db_connection): - """Test getting information about all procedures""" - # First set up our test procedures - test_procedures_setup(cursor, db_connection) + # Verify we got results + assert len(fks) == 2, "Should find two rows for the composite foreign key (one per column)" - try: - # Get all procedures - procs = cursor.procedures().fetchall() + # Group by key_seq to verify both columns + fk_columns = {} + for fk in fks: + fk_columns[fk.key_seq] = { + "pkcolumn": fk.pkcolumn_name.lower(), + "fkcolumn": fk.fkcolumn_name.lower(), + } - # Verify we got results - assert procs is not None, "procedures() should return results" - assert len(procs) > 0, "procedures() should return at least one procedure" + # Verify both columns are present + assert 1 in fk_columns, "First column of composite key missing" + assert 2 in fk_columns, "Second column of composite key missing" - # Verify structure of results - first_row = procs[0] - assert hasattr(first_row, "procedure_cat"), "Result should have procedure_cat column" - assert hasattr(first_row, "procedure_schem"), "Result should have procedure_schem column" - assert hasattr(first_row, "procedure_name"), "Result should have procedure_name column" - assert hasattr(first_row, "num_input_params"), "Result should have num_input_params column" - assert hasattr( - first_row, "num_output_params" - ), "Result should have num_output_params column" - assert hasattr(first_row, "num_result_sets"), "Result should have num_result_sets column" - assert hasattr(first_row, "remarks"), "Result should have remarks column" - assert hasattr(first_row, "procedure_type"), "Result should have procedure_type column" + # Verify column mappings + assert fk_columns[1]["pkcolumn"] == "product_id", "Wrong primary key column 1" + assert fk_columns[1]["fkcolumn"] == "product_id", "Wrong foreign key column 1" + assert fk_columns[2]["pkcolumn"] == "variant_id", "Wrong primary key column 2" + assert fk_columns[2]["fkcolumn"] == "variant_id", "Wrong foreign key column 2" finally: - # Clean up happens in test_procedures_cleanup - pass + # Clean up + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") + db_connection.commit() -def test_procedures_specific(cursor, db_connection): - """Test getting information about a specific procedure""" +def test_cleanup_schema(cursor, db_connection): + """Clean up the test schema after all tests""" try: - # Get specific procedure - procs = cursor.procedures(procedure="test_proc1", schema="pytest_proc_schema").fetchall() - - # Verify we got the correct procedure - assert len(procs) == 1, "Should find exactly one procedure" - proc = procs[0] - assert proc.procedure_name == "test_proc1;1", "Wrong procedure name returned" - assert proc.procedure_schem == "pytest_proc_schema", "Wrong schema returned" + # Make sure no tables remain + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") + cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") + db_connection.commit() - finally: - # Clean up happens in test_procedures_cleanup - pass + # Drop the schema + cursor.execute("DROP SCHEMA IF EXISTS pytest_fk_schema") + db_connection.commit() + except Exception as e: + pytest.fail(f"Schema cleanup failed: {e}") -def test_procedures_with_schema(cursor, db_connection): - """Test getting procedures with schema filter""" +def test_primarykeys_setup(cursor, db_connection): + """Create tables with primary keys for testing""" try: - # Get procedures for our test schema - procs = cursor.procedures(schema="pytest_proc_schema").fetchall() - - # Verify schema filter worked - assert len(procs) >= 2, "Should find at least two procedures in schema" - for proc in procs: - assert ( - proc.procedure_schem == "pytest_proc_schema" - ), f"Expected schema pytest_proc_schema, got {proc.procedure_schem}" - - # Verify our specific procedures are in the results - proc_names = [p.procedure_name for p in procs] - assert "test_proc1;1" in proc_names, "test_proc1;1 should be in results" - assert "test_proc2;1" in proc_names, "test_proc2;1 should be in results" - - finally: - # Clean up happens in test_procedures_cleanup - pass + # Create a test schema for isolation + cursor.execute( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_pk_schema') EXEC('CREATE SCHEMA pytest_pk_schema')" + ) + # Drop tables if they exist + cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.single_pk_test") + cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.composite_pk_test") -def test_procedures_nonexistent(cursor): - """Test procedures() with non-existent procedure name""" - # Use a procedure name that's highly unlikely to exist - procs = cursor.procedures(procedure="nonexistent_procedure_xyz123").fetchall() + # Create table with simple primary key + cursor.execute(""" + CREATE TABLE pytest_pk_schema.single_pk_test ( + id INT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(200) NULL + ) + """) - # Should return empty list, not error - assert isinstance(procs, list), "Should return a list for non-existent procedure" - assert len(procs) == 0, "Should return empty list for non-existent procedure" + # Create table with composite primary key + cursor.execute(""" + CREATE TABLE pytest_pk_schema.composite_pk_test ( + dept_id INT NOT NULL, + emp_id INT NOT NULL, + hire_date DATE NOT NULL, + CONSTRAINT PK_composite_test PRIMARY KEY (dept_id, emp_id) + ) + """) + db_connection.commit() + except Exception as e: + pytest.fail(f"Test setup failed: {e}") -def test_procedures_catalog_filter(cursor, db_connection): - """Test procedures() with catalog filter""" - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - current_db = cursor.fetchone().current_db +def test_primarykeys_simple(cursor, db_connection): + """Test primaryKeys returns information about a simple primary key""" try: - # Get procedures with current catalog - procs = cursor.procedures(catalog=current_db, schema="pytest_proc_schema").fetchall() + # First set up our test tables + test_primarykeys_setup(cursor, db_connection) - # Verify catalog filter worked - assert len(procs) >= 2, "Should find procedures in current catalog" - for proc in procs: - assert ( - proc.procedure_cat == current_db - ), f"Expected catalog {current_db}, got {proc.procedure_cat}" + # Get primary key information + pks = cursor.primaryKeys("single_pk_test", schema="pytest_pk_schema").fetchall() - # Get procedures with non-existent catalog - fake_procs = cursor.procedures(catalog="nonexistent_db_xyz123").fetchall() - assert len(fake_procs) == 0, "Should return empty list for non-existent catalog" + # Verify we got results + assert len(pks) == 1, "Should find exactly one primary key column" + pk = pks[0] + + # Verify primary key details + assert pk.table_name.lower() == "single_pk_test", "Wrong table name" + assert pk.column_name.lower() == "id", "Wrong primary key column name" + assert pk.key_seq == 1, "Wrong key sequence number" + assert pk.pk_name is not None, "Primary key name should not be None" finally: - # Clean up happens in test_procedures_cleanup + # Clean up happens in test_primarykeys_cleanup pass -def test_procedures_with_parameters(cursor, db_connection): - """Test that procedures() correctly reports parameter information""" +def test_primarykeys_composite(cursor, db_connection): + """Test primaryKeys with a composite primary key""" try: - # Create a simpler procedure with basic parameters - cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_params_proc - @in1 INT, - @in2 VARCHAR(50) - AS - BEGIN - SELECT @in1 AS value1, @in2 AS value2 - END - """) - db_connection.commit() + # Get primary key information + pks = cursor.primaryKeys("composite_pk_test", schema="pytest_pk_schema").fetchall() - # Get procedure info - procs = cursor.procedures( - procedure="test_params_proc", schema="pytest_proc_schema" - ).fetchall() + # Verify we got results for both columns + assert len(pks) == 2, "Should find two primary key columns" - # Verify we found the procedure - assert len(procs) == 1, "Should find exactly one procedure" - proc = procs[0] + # Sort by key_seq to ensure consistent order + pks = sorted(pks, key=lambda row: row.key_seq) - # Just check if columns exist, don't check specific values - assert hasattr(proc, "num_input_params"), "Result should have num_input_params column" - assert hasattr(proc, "num_output_params"), "Result should have num_output_params column" + # Verify first column + assert pks[0].table_name.lower() == "composite_pk_test", "Wrong table name" + assert pks[0].column_name.lower() == "dept_id", "Wrong first primary key column name" + assert pks[0].key_seq == 1, "Wrong key sequence number for first column" - # Test simple execution without output parameters - cursor.execute("EXEC pytest_proc_schema.test_params_proc 10, 'Test'") + # Verify second column + assert pks[1].table_name.lower() == "composite_pk_test", "Wrong table name" + assert pks[1].column_name.lower() == "emp_id", "Wrong second primary key column name" + assert pks[1].key_seq == 2, "Wrong key sequence number for second column" - # Verify the procedure returned expected values - row = cursor.fetchone() - assert row is not None, "Procedure should return results" - assert row[0] == 10, "First parameter value incorrect" - assert row[1] == "Test", "Second parameter value incorrect" + # Both should have the same PK name + assert ( + pks[0].pk_name == pks[1].pk_name + ), "Both columns should have the same primary key name" finally: - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_params_proc") - db_connection.commit() + # Clean up happens in test_primarykeys_cleanup + pass -def test_procedures_result_set_info(cursor, db_connection): - """Test that procedures() reports information about result sets""" +def test_primarykeys_column_info(cursor, db_connection): + """Test that primaryKeys returns correct column information""" try: - # Create procedures with different result set patterns - cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_no_results - AS - BEGIN - DECLARE @x INT = 1 - END - """) + # Get primary key information + pks = cursor.primaryKeys("single_pk_test", schema="pytest_pk_schema").fetchall() - cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_one_result - AS - BEGIN - SELECT 1 AS col1, 'test' AS col2 - END - """) + # Verify column information + assert len(pks) == 1, "Should find exactly one primary key column" + pk = pks[0] - cursor.execute(""" - CREATE OR ALTER PROCEDURE pytest_proc_schema.test_multiple_results - AS - BEGIN - SELECT 1 AS result1 - SELECT 'test' AS result2 - SELECT GETDATE() AS result3 - END - """) - db_connection.commit() + # Verify expected columns are present + assert hasattr(pk, "table_cat"), "Result should have table_cat column" + assert hasattr(pk, "table_schem"), "Result should have table_schem column" + assert hasattr(pk, "table_name"), "Result should have table_name column" + assert hasattr(pk, "column_name"), "Result should have column_name column" + assert hasattr(pk, "key_seq"), "Result should have key_seq column" + assert hasattr(pk, "pk_name"), "Result should have pk_name column" - # Get procedure info for all test procedures - procs = cursor.procedures(schema="pytest_proc_schema", procedure="test_%").fetchall() + # Verify values are correct + assert pk.table_schem.lower() == "pytest_pk_schema", "Wrong schema name" + assert pk.table_name.lower() == "single_pk_test", "Wrong table name" + assert pk.column_name.lower() == "id", "Wrong column name" + assert isinstance(pk.key_seq, int), "key_seq should be an integer" - # Verify we found at least some procedures - assert len(procs) > 0, "Should find at least some test procedures" + finally: + # Clean up happens in test_primarykeys_cleanup + pass - # Get the procedure names we found - result_proc_names = [ - p.procedure_name - for p in procs - if p.procedure_name.startswith("test_") and "results" in p.procedure_name - ] - print(f"Found result procedures: {result_proc_names}") - # The num_result_sets column exists but might not have correct values - for proc in procs: - assert hasattr(proc, "num_result_sets"), "Result should have num_result_sets column" +def test_primarykeys_nonexistent(cursor): + """Test primaryKeys() with non-existent table name""" + # Use a table name that's highly unlikely to exist + pks = cursor.primaryKeys("nonexistent_table_xyz123").fetchall() - # Test execution of the procedures to verify they work - cursor.execute("EXEC pytest_proc_schema.test_no_results") - # Procedures with no results should have no description and calling fetchall() should raise an error - assert ( - cursor.description is None - ), "test_no_results should have no description (no result set)" - # Don't call fetchall() on procedures with no results - this is invalid in ODBC + # Should return empty list, not error + assert isinstance(pks, list), "Should return a list for non-existent table" + assert len(pks) == 0, "Should return empty list for non-existent table" - cursor.execute("EXEC pytest_proc_schema.test_one_result") - rows = cursor.fetchall() - assert len(rows) == 1, "test_one_result should return one row" - assert len(rows[0]) == 2, "test_one_result row should have two columns" - cursor.execute("EXEC pytest_proc_schema.test_multiple_results") - rows1 = cursor.fetchall() - assert len(rows1) == 1, "First result set should have one row" - assert cursor.nextset(), "Should have a second result set" - rows2 = cursor.fetchall() - assert len(rows2) == 1, "Second result set should have one row" - assert cursor.nextset(), "Should have a third result set" - rows3 = cursor.fetchall() - assert len(rows3) == 1, "Third result set should have one row" +def test_primarykeys_catalog_filter(cursor, db_connection): + """Test primaryKeys() with catalog filter""" + try: + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + current_db = cursor.fetchone().current_db + + # Get primary keys with current catalog + pks = cursor.primaryKeys( + "single_pk_test", catalog=current_db, schema="pytest_pk_schema" + ).fetchall() + + # Verify catalog filter worked + assert len(pks) == 1, "Should find exactly one primary key column" + pk = pks[0] + assert pk.table_cat == current_db, f"Expected catalog {current_db}, got {pk.table_cat}" + + # Get primary keys with non-existent catalog + fake_pks = cursor.primaryKeys("single_pk_test", catalog="nonexistent_db_xyz123").fetchall() + assert len(fake_pks) == 0, "Should return empty list for non-existent catalog" finally: - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_no_results") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_one_result") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_multiple_results") - db_connection.commit() + # Clean up happens in test_primarykeys_cleanup + pass -def test_procedures_cleanup(cursor, db_connection): - """Clean up all test procedures and schema after testing""" - try: - # Drop all test procedures - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_proc1") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_proc2") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_params_proc") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_no_results") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_one_result") - cursor.execute("DROP PROCEDURE IF EXISTS pytest_proc_schema.test_multiple_results") +def test_primarykeys_cleanup(cursor, db_connection): + """Clean up test tables after testing""" + try: + # Drop all test tables + cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.single_pk_test") + cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.composite_pk_test") # Drop the test schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_proc_schema") + cursor.execute("DROP SCHEMA IF EXISTS pytest_pk_schema") db_connection.commit() except Exception as e: pytest.fail(f"Test cleanup failed: {e}") -def test_foreignkeys_setup(cursor, db_connection): - """Create tables with foreign key relationships for testing""" +def test_rowcount_after_fetch_operations(cursor, db_connection): + """Test that rowcount is updated correctly after various fetch operations.""" try: - # Create a test schema for isolation - cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_fk_schema') EXEC('CREATE SCHEMA pytest_fk_schema')" - ) + # Create a test table + cursor.execute("CREATE TABLE #rowcount_fetch_test (id INT PRIMARY KEY, name NVARCHAR(100))") - # Drop tables if they exist (in reverse order to avoid constraint conflicts) - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") + # Insert some test data + cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (1, 'Row 1')") + cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (2, 'Row 2')") + cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (3, 'Row 3')") + cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (4, 'Row 4')") + cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (5, 'Row 5')") + db_connection.commit() - # Create parent table - cursor.execute(""" - CREATE TABLE pytest_fk_schema.customers ( - customer_id INT PRIMARY KEY, - customer_name VARCHAR(100) NOT NULL - ) - """) + # Test fetchone + cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") + # Initially, rowcount should be -1 after a SELECT statement + assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" - # Create child table with foreign key - cursor.execute(""" - CREATE TABLE pytest_fk_schema.orders ( - order_id INT PRIMARY KEY, - order_date DATETIME NOT NULL, - customer_id INT NOT NULL, - total_amount DECIMAL(10, 2) NOT NULL, - CONSTRAINT FK_Orders_Customers FOREIGN KEY (customer_id) - REFERENCES pytest_fk_schema.customers (customer_id) - ) - """) + # After fetchone, rowcount should be 1 + row = cursor.fetchone() + assert row is not None, "Should fetch one row" + assert cursor.rowcount == 1, "rowcount should be 1 after fetchone" - # Insert test data - cursor.execute(""" - INSERT INTO pytest_fk_schema.customers (customer_id, customer_name) - VALUES (1, 'Test Customer 1'), (2, 'Test Customer 2') - """) + # After another fetchone, rowcount should be 2 + row = cursor.fetchone() + assert row is not None, "Should fetch second row" + assert cursor.rowcount == 2, "rowcount should be 2 after second fetchone" - cursor.execute(""" - INSERT INTO pytest_fk_schema.orders (order_id, order_date, customer_id, total_amount) - VALUES (101, GETDATE(), 1, 150.00), (102, GETDATE(), 2, 250.50) - """) + # Test fetchmany + cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") + assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" - db_connection.commit() - except Exception as e: - pytest.fail(f"Test setup failed: {e}") + # After fetchmany(2), rowcount should be 2 + rows = cursor.fetchmany(2) + assert len(rows) == 2, "Should fetch two rows" + assert cursor.rowcount == 2, "rowcount should be 2 after fetchmany(2)" + # After another fetchmany(2), rowcount should be 4 + rows = cursor.fetchmany(2) + assert len(rows) == 2, "Should fetch two more rows" + assert cursor.rowcount == 4, "rowcount should be 4 after second fetchmany(2)" -def test_foreignkeys_all(cursor, db_connection): - """Test getting all foreign keys""" - try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) + # Test fetchall + cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") + assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" - # Get all foreign keys - fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() + # After fetchall, rowcount should be the total number of rows fetched (5) + rows = cursor.fetchall() + assert len(rows) == 5, "Should fetch all rows" + assert cursor.rowcount == 5, "rowcount should be 5 after fetchall" - # Verify we got results - assert fks is not None, "foreignKeys() should return results" - assert len(fks) > 0, "foreignKeys() should return at least one foreign key" + # Test mixed fetch operations + cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") - # Verify our test FK is in the results - # Search case-insensitively since the database might return different case - found_test_fk = False - for fk in fks: - if fk.fktable_name.lower() == "orders" and fk.pktable_name.lower() == "customers": - found_test_fk = True - break + # Fetch one row + row = cursor.fetchone() + assert row is not None, "Should fetch one row" + assert cursor.rowcount == 1, "rowcount should be 1 after fetchone" - assert found_test_fk, "Could not find the test foreign key in results" + # Fetch two more rows with fetchmany + rows = cursor.fetchmany(2) + assert len(rows) == 2, "Should fetch two more rows" + assert cursor.rowcount == 3, "rowcount should be 3 after fetchone + fetchmany(2)" + + # Fetch remaining rows with fetchall + rows = cursor.fetchall() + assert len(rows) == 2, "Should fetch remaining two rows" + assert cursor.rowcount == 5, "rowcount should be 5 after fetchone + fetchmany(2) + fetchall" + + # Test fetchall on an empty result + cursor.execute("SELECT * FROM #rowcount_fetch_test WHERE id > 100") + rows = cursor.fetchall() + assert len(rows) == 0, "Should fetch zero rows" + assert cursor.rowcount == 0, "rowcount should be 0 after fetchall on empty result" finally: # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - db_connection.commit() + try: + cursor.execute("DROP TABLE #rowcount_fetch_test") + db_connection.commit() + except: + pass -def test_foreignkeys_specific_table(cursor, db_connection): - """Test getting foreign keys for a specific table""" +def test_rowcount_guid_table(cursor, db_connection): + """Test rowcount with GUID/uniqueidentifier columns to match the GitHub issue scenario.""" try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) + # Create a test table similar to the one in the GitHub issue + cursor.execute( + "CREATE TABLE #test_log (id uniqueidentifier PRIMARY KEY DEFAULT NEWID(), message VARCHAR(100))" + ) - # Get foreign keys for the orders table - fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() + # Insert test data + cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 1')") + cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 2')") + cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 3')") + db_connection.commit() - # Verify we got results - assert len(fks) == 1, "Should find exactly one foreign key for orders table" + # Execute SELECT query + cursor.execute("SELECT * FROM #test_log") + assert ( + cursor.rowcount == -1 + ), "Rowcount should be -1 after a SELECT statement (before fetch)" - # Verify the foreign key details - fk = fks[0] - assert fk.fktable_name.lower() == "orders", "Wrong foreign key table name" - assert fk.pktable_name.lower() == "customers", "Wrong primary key table name" - assert fk.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" - assert fk.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" + # Test fetchall + rows = cursor.fetchall() + assert len(rows) == 3, "Should fetch 3 rows" + assert cursor.rowcount == 3, "Rowcount should be 3 after fetchall" - finally: - # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - db_connection.commit() + # Execute SELECT again + cursor.execute("SELECT * FROM #test_log") + # Test fetchmany + rows = cursor.fetchmany(2) + assert len(rows) == 2, "Should fetch 2 rows" + assert cursor.rowcount == 2, "Rowcount should be 2 after fetchmany(2)" -def test_foreignkeys_specific_foreign_table(cursor, db_connection): - """Test getting foreign keys that reference a specific table""" - try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) + # Fetch remaining row + rows = cursor.fetchall() + assert len(rows) == 1, "Should fetch 1 remaining row" + assert cursor.rowcount == 3, "Rowcount should be 3 after fetchmany(2) + fetchall" - # Get foreign keys that reference the customers table - fks = cursor.foreignKeys( - foreignTable="customers", foreignSchema="pytest_fk_schema" - ).fetchall() + # Execute SELECT again + cursor.execute("SELECT * FROM #test_log") - # Verify we got results - assert len(fks) > 0, "Should find at least one foreign key referencing customers table" + # Test individual fetchone calls + row1 = cursor.fetchone() + assert row1 is not None, "First row should not be None" + assert cursor.rowcount == 1, "Rowcount should be 1 after first fetchone" - # Verify our test FK is in the results - found_test_fk = False - for fk in fks: - if fk.fktable_name.lower() == "orders" and fk.pktable_name.lower() == "customers": - found_test_fk = True - break + row2 = cursor.fetchone() + assert row2 is not None, "Second row should not be None" + assert cursor.rowcount == 2, "Rowcount should be 2 after second fetchone" - assert found_test_fk, "Could not find the test foreign key in results" + row3 = cursor.fetchone() + assert row3 is not None, "Third row should not be None" + assert cursor.rowcount == 3, "Rowcount should be 3 after third fetchone" + + row4 = cursor.fetchone() + assert row4 is None, "Fourth row should be None (no more rows)" + assert cursor.rowcount == 3, "Rowcount should remain 3 when fetchone returns None" finally: # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - db_connection.commit() + try: + cursor.execute("DROP TABLE #test_log") + db_connection.commit() + except: + pass -def test_foreignkeys_both_tables(cursor, db_connection): - """Test getting foreign keys with both table and foreignTable specified""" +def test_rowcount(cursor, db_connection): + """Test rowcount after various operations""" try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) + cursor.execute( + "CREATE TABLE #pytest_test_rowcount (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100))" + ) + db_connection.commit() - # Get foreign keys between the two tables - fks = cursor.foreignKeys( - table="orders", - schema="pytest_fk_schema", - foreignTable="customers", - foreignSchema="pytest_fk_schema", - ).fetchall() + cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe1');") + assert cursor.rowcount == 1, "Rowcount should be 1 after first insert" - # Verify we got results - assert len(fks) == 1, "Should find exactly one foreign key between specified tables" + cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe2');") + assert cursor.rowcount == 1, "Rowcount should be 1 after second insert" - # Verify the foreign key details - fk = fks[0] - assert fk.fktable_name.lower() == "orders", "Wrong foreign key table name" - assert fk.pktable_name.lower() == "customers", "Wrong primary key table name" - assert fk.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" - assert fk.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" - - finally: - # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - db_connection.commit() - - -def test_foreignkeys_nonexistent(cursor): - """Test foreignKeys() with non-existent table name""" - # Use a table name that's highly unlikely to exist - fks = cursor.foreignKeys(table="nonexistent_table_xyz123").fetchall() - - # Should return empty list, not error - assert isinstance(fks, list), "Should return a list for non-existent table" - assert len(fks) == 0, "Should return empty list for non-existent table" - - -def test_foreignkeys_catalog_schema(cursor, db_connection): - """Test foreignKeys() with catalog and schema filters""" - try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) - - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - row = cursor.fetchone() - current_db = row.current_db + cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe3');") + assert cursor.rowcount == 1, "Rowcount should be 1 after third insert" - # Get foreign keys with current catalog and pytest schema - fks = cursor.foreignKeys( - table="orders", catalog=current_db, schema="pytest_fk_schema" - ).fetchall() + cursor.execute(""" + INSERT INTO #pytest_test_rowcount (name) + VALUES + ('JohnDoe4'), + ('JohnDoe5'), + ('JohnDoe6'); + """) + assert cursor.rowcount == 3, "Rowcount should be 3 after inserting multiple rows" - # Verify we got results - assert len(fks) > 0, "Should find foreign keys with correct catalog/schema" + cursor.execute("SELECT * FROM #pytest_test_rowcount;") + assert ( + cursor.rowcount == -1 + ), "Rowcount should be -1 after a SELECT statement (before fetch)" - # Verify catalog/schema in results - for fk in fks: - assert fk.fktable_cat == current_db, "Wrong foreign key table catalog" - assert fk.fktable_schem == "pytest_fk_schema", "Wrong foreign key table schema" + # After fetchall, rowcount should be updated to match the number of rows fetched + rows = cursor.fetchall() + assert len(rows) == 6, "Should have fetched 6 rows" + assert cursor.rowcount == 6, "Rowcount should be updated to 6 after fetchall" - finally: - # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") db_connection.commit() - - -def test_foreignkeys_result_structure(cursor, db_connection): - """Test the structure of foreignKeys result rows""" - try: - # First set up our test tables - test_foreignkeys_setup(cursor, db_connection) - - # Get foreign keys for the orders table - fks = cursor.foreignKeys(table="orders", schema="pytest_fk_schema").fetchall() - - # Verify we got results - assert len(fks) > 0, "Should find at least one foreign key" - - # Check for all required columns in the result - first_row = fks[0] - required_columns = [ - "pktable_cat", - "pktable_schem", - "pktable_name", - "pkcolumn_name", - "fktable_cat", - "fktable_schem", - "fktable_name", - "fkcolumn_name", - "key_seq", - "update_rule", - "delete_rule", - "fk_name", - "pk_name", - "deferrability", - ] - - for column in required_columns: - assert hasattr(first_row, column), f"Result missing required column: {column}" - - # Verify specific values - assert first_row.fktable_name.lower() == "orders", "Wrong foreign key table name" - assert first_row.pktable_name.lower() == "customers", "Wrong primary key table name" - assert first_row.fkcolumn_name.lower() == "customer_id", "Wrong foreign key column name" - assert first_row.pkcolumn_name.lower() == "customer_id", "Wrong primary key column name" - assert first_row.key_seq == 1, "Wrong key sequence number" - assert first_row.fk_name is not None, "Foreign key name should not be None" - assert first_row.pk_name is not None, "Primary key name should not be None" - + except Exception as e: + pytest.fail(f"Rowcount test failed: {e}") finally: - # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - db_connection.commit() + cursor.execute("DROP TABLE #pytest_test_rowcount") -def test_foreignkeys_multiple_column_fk(cursor, db_connection): - """Test foreignKeys() with a multi-column foreign key""" +def test_specialcolumns_setup(cursor, db_connection): + """Create test tables for testing rowIdColumns and rowVerColumns""" try: - # First create the schema if needed + # Create a test schema for isolation cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_fk_schema') EXEC('CREATE SCHEMA pytest_fk_schema')" + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_special_schema') EXEC('CREATE SCHEMA pytest_special_schema')" ) - # Drop tables if they exist (in reverse order to avoid constraint conflicts) - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") + # Drop tables if they exist + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.rowid_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.timestamp_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.multiple_unique_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.identity_test") - # Create parent table with composite primary key + # Create table with primary key (for rowIdColumns) cursor.execute(""" - CREATE TABLE pytest_fk_schema.product_variants ( - product_id INT NOT NULL, - variant_id INT NOT NULL, - variant_name VARCHAR(100) NOT NULL, - PRIMARY KEY (product_id, variant_id) + CREATE TABLE pytest_special_schema.rowid_test ( + id INT PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + unique_col NVARCHAR(100) UNIQUE, + non_unique_col NVARCHAR(100) ) """) - # Create child table with composite foreign key + # Create table with rowversion column (for rowVerColumns) cursor.execute(""" - CREATE TABLE pytest_fk_schema.order_details ( - order_id INT NOT NULL, - product_id INT NOT NULL, - variant_id INT NOT NULL, - quantity INT NOT NULL, - PRIMARY KEY (order_id, product_id, variant_id), - CONSTRAINT FK_OrderDetails_ProductVariants FOREIGN KEY (product_id, variant_id) - REFERENCES pytest_fk_schema.product_variants (product_id, variant_id) + CREATE TABLE pytest_special_schema.timestamp_test ( + id INT PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + last_updated ROWVERSION ) """) - db_connection.commit() - - # Get foreign keys for the order_details table - fks = cursor.foreignKeys(table="order_details", schema="pytest_fk_schema").fetchall() - - # Verify we got results - assert len(fks) == 2, "Should find two rows for the composite foreign key (one per column)" - - # Group by key_seq to verify both columns - fk_columns = {} - for fk in fks: - fk_columns[fk.key_seq] = { - "pkcolumn": fk.pkcolumn_name.lower(), - "fkcolumn": fk.fkcolumn_name.lower(), - } - - # Verify both columns are present - assert 1 in fk_columns, "First column of composite key missing" - assert 2 in fk_columns, "Second column of composite key missing" - - # Verify column mappings - assert fk_columns[1]["pkcolumn"] == "product_id", "Wrong primary key column 1" - assert fk_columns[1]["fkcolumn"] == "product_id", "Wrong foreign key column 1" - assert fk_columns[2]["pkcolumn"] == "variant_id", "Wrong primary key column 2" - assert fk_columns[2]["fkcolumn"] == "variant_id", "Wrong foreign key column 2" - - finally: - # Clean up - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") - db_connection.commit() - - -def test_cleanup_schema(cursor, db_connection): - """Clean up the test schema after all tests""" - try: - # Make sure no tables remain - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.orders") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.customers") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.order_details") - cursor.execute("DROP TABLE IF EXISTS pytest_fk_schema.product_variants") - db_connection.commit() - - # Drop the schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_fk_schema") - db_connection.commit() - except Exception as e: - pytest.fail(f"Schema cleanup failed: {e}") - - -def test_primarykeys_setup(cursor, db_connection): - """Create tables with primary keys for testing""" - try: - # Create a test schema for isolation - cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_pk_schema') EXEC('CREATE SCHEMA pytest_pk_schema')" - ) - - # Drop tables if they exist - cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.single_pk_test") - cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.composite_pk_test") - - # Create table with simple primary key + # Create table with multiple unique identifiers cursor.execute(""" - CREATE TABLE pytest_pk_schema.single_pk_test ( - id INT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description VARCHAR(200) NULL + CREATE TABLE pytest_special_schema.multiple_unique_test ( + id INT NOT NULL, + code VARCHAR(10) NOT NULL, + email VARCHAR(100) UNIQUE, + order_number VARCHAR(20) UNIQUE, + CONSTRAINT PK_multiple_unique_test PRIMARY KEY (id, code) ) """) - # Create table with composite primary key + # Create table with identity column cursor.execute(""" - CREATE TABLE pytest_pk_schema.composite_pk_test ( - dept_id INT NOT NULL, - emp_id INT NOT NULL, - hire_date DATE NOT NULL, - CONSTRAINT PK_composite_test PRIMARY KEY (dept_id, emp_id) + CREATE TABLE pytest_special_schema.identity_test ( + id INT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + last_modified DATETIME DEFAULT GETDATE() ) """) @@ -12655,386 +12297,356 @@ def test_primarykeys_setup(cursor, db_connection): pytest.fail(f"Test setup failed: {e}") -def test_primarykeys_simple(cursor, db_connection): - """Test primaryKeys returns information about a simple primary key""" +def test_rowid_columns_basic(cursor, db_connection): + """Test basic functionality of rowIdColumns""" try: - # First set up our test tables - test_primarykeys_setup(cursor, db_connection) + # Get row identifier columns for simple table + rowid_cols = cursor.rowIdColumns( + table="rowid_test", schema="pytest_special_schema" + ).fetchall() - # Get primary key information - pks = cursor.primaryKeys("single_pk_test", schema="pytest_pk_schema").fetchall() + # LIMITATION: Only returns first column of primary key + assert len(rowid_cols) == 1, "Should find exactly one ROWID column (first column of PK)" - # Verify we got results - assert len(pks) == 1, "Should find exactly one primary key column" - pk = pks[0] + # Verify column name in the results + col = rowid_cols[0] + assert ( + col.column_name.lower() == "id" + ), "Primary key column should be included in ROWID results" - # Verify primary key details - assert pk.table_name.lower() == "single_pk_test", "Wrong table name" - assert pk.column_name.lower() == "id", "Wrong primary key column name" - assert pk.key_seq == 1, "Wrong key sequence number" - assert pk.pk_name is not None, "Primary key name should not be None" + # Verify result structure + assert hasattr(col, "scope"), "Result should have scope column" + assert hasattr(col, "column_name"), "Result should have column_name column" + assert hasattr(col, "data_type"), "Result should have data_type column" + assert hasattr(col, "type_name"), "Result should have type_name column" + assert hasattr(col, "column_size"), "Result should have column_size column" + assert hasattr(col, "buffer_length"), "Result should have buffer_length column" + assert hasattr(col, "decimal_digits"), "Result should have decimal_digits column" + assert hasattr(col, "pseudo_column"), "Result should have pseudo_column column" + + # The scope should be one of the valid values or NULL + assert col.scope in [0, 1, 2, None], f"Invalid scope value: {col.scope}" + + # The pseudo_column should be one of the valid values + assert col.pseudo_column in [ + 0, + 1, + 2, + None, + ], f"Invalid pseudo_column value: {col.pseudo_column}" + except Exception as e: + pytest.fail(f"rowIdColumns basic test failed: {e}") finally: - # Clean up happens in test_primarykeys_cleanup + # Clean up happens in test_specialcolumns_cleanup pass -def test_primarykeys_composite(cursor, db_connection): - """Test primaryKeys with a composite primary key""" +def test_rowid_columns_identity(cursor, db_connection): + """Test rowIdColumns with identity column""" try: - # Get primary key information - pks = cursor.primaryKeys("composite_pk_test", schema="pytest_pk_schema").fetchall() - - # Verify we got results for both columns - assert len(pks) == 2, "Should find two primary key columns" - - # Sort by key_seq to ensure consistent order - pks = sorted(pks, key=lambda row: row.key_seq) - - # Verify first column - assert pks[0].table_name.lower() == "composite_pk_test", "Wrong table name" - assert pks[0].column_name.lower() == "dept_id", "Wrong first primary key column name" - assert pks[0].key_seq == 1, "Wrong key sequence number for first column" + # Get row identifier columns for table with identity column + rowid_cols = cursor.rowIdColumns( + table="identity_test", schema="pytest_special_schema" + ).fetchall() - # Verify second column - assert pks[1].table_name.lower() == "composite_pk_test", "Wrong table name" - assert pks[1].column_name.lower() == "emp_id", "Wrong second primary key column name" - assert pks[1].key_seq == 2, "Wrong key sequence number for second column" + # LIMITATION: Only returns the identity column if it's the primary key + assert len(rowid_cols) == 1, "Should find exactly one ROWID column (identity column as PK)" - # Both should have the same PK name - assert ( - pks[0].pk_name == pks[1].pk_name - ), "Both columns should have the same primary key name" + # Verify it's the identity column + col = rowid_cols[0] + assert col.column_name.lower() == "id", "Identity column should be included as it's the PK" + except Exception as e: + pytest.fail(f"rowIdColumns identity test failed: {e}") finally: - # Clean up happens in test_primarykeys_cleanup + # Clean up happens in test_specialcolumns_cleanup pass -def test_primarykeys_column_info(cursor, db_connection): - """Test that primaryKeys returns correct column information""" +def test_rowid_columns_composite(cursor, db_connection): + """Test rowIdColumns with composite primary key""" try: - # Get primary key information - pks = cursor.primaryKeys("single_pk_test", schema="pytest_pk_schema").fetchall() + # Get row identifier columns for table with composite primary key + rowid_cols = cursor.rowIdColumns( + table="multiple_unique_test", schema="pytest_special_schema" + ).fetchall() - # Verify column information - assert len(pks) == 1, "Should find exactly one primary key column" - pk = pks[0] + # LIMITATION: Only returns first column of composite primary key + assert len(rowid_cols) >= 1, "Should find at least one ROWID column (first column of PK)" - # Verify expected columns are present - assert hasattr(pk, "table_cat"), "Result should have table_cat column" - assert hasattr(pk, "table_schem"), "Result should have table_schem column" - assert hasattr(pk, "table_name"), "Result should have table_name column" - assert hasattr(pk, "column_name"), "Result should have column_name column" - assert hasattr(pk, "key_seq"), "Result should have key_seq column" - assert hasattr(pk, "pk_name"), "Result should have pk_name column" + # Verify column names in the results - should be the first PK column + col_names = [col.column_name.lower() for col in rowid_cols] + assert "id" in col_names, "First part of composite PK should be included" - # Verify values are correct - assert pk.table_schem.lower() == "pytest_pk_schema", "Wrong schema name" - assert pk.table_name.lower() == "single_pk_test", "Wrong table name" - assert pk.column_name.lower() == "id", "Wrong column name" - assert isinstance(pk.key_seq, int), "key_seq should be an integer" + # LIMITATION: Other parts of the PK or unique constraints may not be included + if len(rowid_cols) > 1: + # If additional columns are returned, they should be valid + for col in rowid_cols: + assert col.column_name.lower() in [ + "id", + "code", + ], "Only PK columns should be returned" + except Exception as e: + pytest.fail(f"rowIdColumns composite test failed: {e}") finally: - # Clean up happens in test_primarykeys_cleanup + # Clean up happens in test_specialcolumns_cleanup pass -def test_primarykeys_nonexistent(cursor): - """Test primaryKeys() with non-existent table name""" +def test_rowid_columns_nonexistent(cursor): + """Test rowIdColumns with non-existent table""" # Use a table name that's highly unlikely to exist - pks = cursor.primaryKeys("nonexistent_table_xyz123").fetchall() + rowid_cols = cursor.rowIdColumns("nonexistent_table_xyz123").fetchall() # Should return empty list, not error - assert isinstance(pks, list), "Should return a list for non-existent table" - assert len(pks) == 0, "Should return empty list for non-existent table" + assert isinstance(rowid_cols, list), "Should return a list for non-existent table" + assert len(rowid_cols) == 0, "Should return empty list for non-existent table" -def test_primarykeys_catalog_filter(cursor, db_connection): - """Test primaryKeys() with catalog filter""" +def test_rowid_columns_nullable(cursor, db_connection): + """Test rowIdColumns with nullable parameter""" try: - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - current_db = cursor.fetchone().current_db + # First create a table with nullable unique column and non-nullable PK + cursor.execute(""" + CREATE TABLE pytest_special_schema.nullable_test ( + id INT PRIMARY KEY, -- PK can't be nullable in SQL Server + data NVARCHAR(100) NULL + ) + """) + db_connection.commit() - # Get primary keys with current catalog - pks = cursor.primaryKeys( - "single_pk_test", catalog=current_db, schema="pytest_pk_schema" + # Test with nullable=True (default) + rowid_cols_with_nullable = cursor.rowIdColumns( + table="nullable_test", schema="pytest_special_schema" ).fetchall() - # Verify catalog filter worked - assert len(pks) == 1, "Should find exactly one primary key column" - pk = pks[0] - assert pk.table_cat == current_db, f"Expected catalog {current_db}, got {pk.table_cat}" + # Verify PK column is included + assert len(rowid_cols_with_nullable) == 1, "Should return exactly one column (PK)" + assert ( + rowid_cols_with_nullable[0].column_name.lower() == "id" + ), "PK column should be returned" - # Get primary keys with non-existent catalog - fake_pks = cursor.primaryKeys("single_pk_test", catalog="nonexistent_db_xyz123").fetchall() - assert len(fake_pks) == 0, "Should return empty list for non-existent catalog" + # Test with nullable=False + rowid_cols_no_nullable = cursor.rowIdColumns( + table="nullable_test", schema="pytest_special_schema", nullable=False + ).fetchall() + + # The behavior of SQLSpecialColumns with SQL_NO_NULLS is to only return + # non-nullable columns that uniquely identify a row, but SQL Server returns + # an empty set in this case - this is expected behavior + assert ( + len(rowid_cols_no_nullable) == 0 + ), "Should return empty list when nullable=False (ODBC API behavior)" + except Exception as e: + pytest.fail(f"rowIdColumns nullable test failed: {e}") finally: - # Clean up happens in test_primarykeys_cleanup - pass + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_test") + db_connection.commit() -def test_primarykeys_cleanup(cursor, db_connection): - """Clean up test tables after testing""" +def test_rowver_columns_basic(cursor, db_connection): + """Test basic functionality of rowVerColumns""" try: - # Drop all test tables - cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.single_pk_test") - cursor.execute("DROP TABLE IF EXISTS pytest_pk_schema.composite_pk_test") + # Get version columns from timestamp test table + rowver_cols = cursor.rowVerColumns( + table="timestamp_test", schema="pytest_special_schema" + ).fetchall() - # Drop the test schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_pk_schema") - db_connection.commit() - except Exception as e: - pytest.fail(f"Test cleanup failed: {e}") + # Verify we got results + assert len(rowver_cols) == 1, "Should find exactly one ROWVER column" + # Verify the column is the rowversion column + rowver_col = rowver_cols[0] + assert ( + rowver_col.column_name.lower() == "last_updated" + ), "ROWVER column should be 'last_updated'" + assert rowver_col.type_name.lower() in [ + "rowversion", + "timestamp", + ], "ROWVER column should have rowversion or timestamp type" -def test_rowcount_after_fetch_operations(cursor, db_connection): - """Test that rowcount is updated correctly after various fetch operations.""" - try: - # Create a test table - cursor.execute("CREATE TABLE #rowcount_fetch_test (id INT PRIMARY KEY, name NVARCHAR(100))") + # Verify result structure - allowing for NULL values + assert hasattr(rowver_col, "scope"), "Result should have scope column" + assert hasattr(rowver_col, "column_name"), "Result should have column_name column" + assert hasattr(rowver_col, "data_type"), "Result should have data_type column" + assert hasattr(rowver_col, "type_name"), "Result should have type_name column" + assert hasattr(rowver_col, "column_size"), "Result should have column_size column" + assert hasattr(rowver_col, "buffer_length"), "Result should have buffer_length column" + assert hasattr(rowver_col, "decimal_digits"), "Result should have decimal_digits column" + assert hasattr(rowver_col, "pseudo_column"), "Result should have pseudo_column column" - # Insert some test data - cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (1, 'Row 1')") - cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (2, 'Row 2')") - cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (3, 'Row 3')") - cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (4, 'Row 4')") - cursor.execute("INSERT INTO #rowcount_fetch_test VALUES (5, 'Row 5')") - db_connection.commit() + # The scope should be one of the valid values or NULL + assert rowver_col.scope in [ + 0, + 1, + 2, + None, + ], f"Invalid scope value: {rowver_col.scope}" - # Test fetchone - cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") - # Initially, rowcount should be -1 after a SELECT statement - assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" + except Exception as e: + pytest.fail(f"rowVerColumns basic test failed: {e}") + finally: + # Clean up happens in test_specialcolumns_cleanup + pass - # After fetchone, rowcount should be 1 - row = cursor.fetchone() - assert row is not None, "Should fetch one row" - assert cursor.rowcount == 1, "rowcount should be 1 after fetchone" - # After another fetchone, rowcount should be 2 - row = cursor.fetchone() - assert row is not None, "Should fetch second row" - assert cursor.rowcount == 2, "rowcount should be 2 after second fetchone" +def test_rowver_columns_nonexistent(cursor): + """Test rowVerColumns with non-existent table""" + # Use a table name that's highly unlikely to exist + rowver_cols = cursor.rowVerColumns("nonexistent_table_xyz123").fetchall() - # Test fetchmany - cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") - assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" - - # After fetchmany(2), rowcount should be 2 - rows = cursor.fetchmany(2) - assert len(rows) == 2, "Should fetch two rows" - assert cursor.rowcount == 2, "rowcount should be 2 after fetchmany(2)" - - # After another fetchmany(2), rowcount should be 4 - rows = cursor.fetchmany(2) - assert len(rows) == 2, "Should fetch two more rows" - assert cursor.rowcount == 4, "rowcount should be 4 after second fetchmany(2)" - - # Test fetchall - cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") - assert cursor.rowcount == -1, "rowcount should be -1 right after SELECT statement" + # Should return empty list, not error + assert isinstance(rowver_cols, list), "Should return a list for non-existent table" + assert len(rowver_cols) == 0, "Should return empty list for non-existent table" - # After fetchall, rowcount should be the total number of rows fetched (5) - rows = cursor.fetchall() - assert len(rows) == 5, "Should fetch all rows" - assert cursor.rowcount == 5, "rowcount should be 5 after fetchall" - # Test mixed fetch operations - cursor.execute("SELECT * FROM #rowcount_fetch_test ORDER BY id") +def test_rowver_columns_nullable(cursor, db_connection): + """Test rowVerColumns with nullable parameter (not expected to have effect)""" + try: + # First create a table with rowversion column + cursor.execute(""" + CREATE TABLE pytest_special_schema.nullable_rowver_test ( + id INT PRIMARY KEY, + ts ROWVERSION + ) + """) + db_connection.commit() - # Fetch one row - row = cursor.fetchone() - assert row is not None, "Should fetch one row" - assert cursor.rowcount == 1, "rowcount should be 1 after fetchone" + # Test with nullable=True (default) + rowver_cols_with_nullable = cursor.rowVerColumns( + table="nullable_rowver_test", schema="pytest_special_schema" + ).fetchall() - # Fetch two more rows with fetchmany - rows = cursor.fetchmany(2) - assert len(rows) == 2, "Should fetch two more rows" - assert cursor.rowcount == 3, "rowcount should be 3 after fetchone + fetchmany(2)" + # Verify rowversion column is included (rowversion can't be nullable) + assert len(rowver_cols_with_nullable) == 1, "Should find exactly one ROWVER column" + assert ( + rowver_cols_with_nullable[0].column_name.lower() == "ts" + ), "ROWVERSION column should be included" - # Fetch remaining rows with fetchall - rows = cursor.fetchall() - assert len(rows) == 2, "Should fetch remaining two rows" - assert cursor.rowcount == 5, "rowcount should be 5 after fetchone + fetchmany(2) + fetchall" + # Test with nullable=False + rowver_cols_no_nullable = cursor.rowVerColumns( + table="nullable_rowver_test", schema="pytest_special_schema", nullable=False + ).fetchall() - # Test fetchall on an empty result - cursor.execute("SELECT * FROM #rowcount_fetch_test WHERE id > 100") - rows = cursor.fetchall() - assert len(rows) == 0, "Should fetch zero rows" - assert cursor.rowcount == 0, "rowcount should be 0 after fetchall on empty result" + # Verify rowversion column is still included + assert len(rowver_cols_no_nullable) == 1, "Should find exactly one ROWVER column" + assert ( + rowver_cols_no_nullable[0].column_name.lower() == "ts" + ), "ROWVERSION column should be included even with nullable=False" + except Exception as e: + pytest.fail(f"rowVerColumns nullable test failed: {e}") finally: - # Clean up - try: - cursor.execute("DROP TABLE #rowcount_fetch_test") - db_connection.commit() - except: - pass - - -def test_rowcount_guid_table(cursor, db_connection): - """Test rowcount with GUID/uniqueidentifier columns to match the GitHub issue scenario.""" - try: - # Create a test table similar to the one in the GitHub issue - cursor.execute( - "CREATE TABLE #test_log (id uniqueidentifier PRIMARY KEY DEFAULT NEWID(), message VARCHAR(100))" - ) - - # Insert test data - cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 1')") - cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 2')") - cursor.execute("INSERT INTO #test_log (message) VALUES ('Log 3')") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_rowver_test") db_connection.commit() - # Execute SELECT query - cursor.execute("SELECT * FROM #test_log") - assert ( - cursor.rowcount == -1 - ), "Rowcount should be -1 after a SELECT statement (before fetch)" - - # Test fetchall - rows = cursor.fetchall() - assert len(rows) == 3, "Should fetch 3 rows" - assert cursor.rowcount == 3, "Rowcount should be 3 after fetchall" - - # Execute SELECT again - cursor.execute("SELECT * FROM #test_log") - # Test fetchmany - rows = cursor.fetchmany(2) - assert len(rows) == 2, "Should fetch 2 rows" - assert cursor.rowcount == 2, "Rowcount should be 2 after fetchmany(2)" +def test_specialcolumns_catalog_filter(cursor, db_connection): + """Test special columns with catalog filter""" + try: + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + current_db = cursor.fetchone().current_db - # Fetch remaining row - rows = cursor.fetchall() - assert len(rows) == 1, "Should fetch 1 remaining row" - assert cursor.rowcount == 3, "Rowcount should be 3 after fetchmany(2) + fetchall" + # Test rowIdColumns with current catalog + rowid_cols = cursor.rowIdColumns( + table="rowid_test", catalog=current_db, schema="pytest_special_schema" + ).fetchall() - # Execute SELECT again - cursor.execute("SELECT * FROM #test_log") + # Verify catalog filter worked + assert len(rowid_cols) > 0, "Should find ROWID columns with correct catalog" - # Test individual fetchone calls - row1 = cursor.fetchone() - assert row1 is not None, "First row should not be None" - assert cursor.rowcount == 1, "Rowcount should be 1 after first fetchone" + # Test rowIdColumns with non-existent catalog + fake_rowid_cols = cursor.rowIdColumns( + table="rowid_test", + catalog="nonexistent_db_xyz123", + schema="pytest_special_schema", + ).fetchall() + assert len(fake_rowid_cols) == 0, "Should return empty list for non-existent catalog" - row2 = cursor.fetchone() - assert row2 is not None, "Second row should not be None" - assert cursor.rowcount == 2, "Rowcount should be 2 after second fetchone" + # Test rowVerColumns with current catalog + rowver_cols = cursor.rowVerColumns( + table="timestamp_test", catalog=current_db, schema="pytest_special_schema" + ).fetchall() - row3 = cursor.fetchone() - assert row3 is not None, "Third row should not be None" - assert cursor.rowcount == 3, "Rowcount should be 3 after third fetchone" + # Verify catalog filter worked + assert len(rowver_cols) > 0, "Should find ROWVER columns with correct catalog" - row4 = cursor.fetchone() - assert row4 is None, "Fourth row should be None (no more rows)" - assert cursor.rowcount == 3, "Rowcount should remain 3 when fetchone returns None" + # Test rowVerColumns with non-existent catalog + fake_rowver_cols = cursor.rowVerColumns( + table="timestamp_test", + catalog="nonexistent_db_xyz123", + schema="pytest_special_schema", + ).fetchall() + assert len(fake_rowver_cols) == 0, "Should return empty list for non-existent catalog" + except Exception as e: + pytest.fail(f"Special columns catalog filter test failed: {e}") finally: - # Clean up - try: - cursor.execute("DROP TABLE #test_log") - db_connection.commit() - except: - pass + # Clean up happens in test_specialcolumns_cleanup + pass -def test_rowcount(cursor, db_connection): - """Test rowcount after various operations""" +def test_specialcolumns_cleanup(cursor, db_connection): + """Clean up test tables after testing""" try: - cursor.execute( - "CREATE TABLE #pytest_test_rowcount (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100))" - ) - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe1');") - assert cursor.rowcount == 1, "Rowcount should be 1 after first insert" - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe2');") - assert cursor.rowcount == 1, "Rowcount should be 1 after second insert" - - cursor.execute("INSERT INTO #pytest_test_rowcount (name) VALUES ('JohnDoe3');") - assert cursor.rowcount == 1, "Rowcount should be 1 after third insert" - - cursor.execute(""" - INSERT INTO #pytest_test_rowcount (name) - VALUES - ('JohnDoe4'), - ('JohnDoe5'), - ('JohnDoe6'); - """) - assert cursor.rowcount == 3, "Rowcount should be 3 after inserting multiple rows" - - cursor.execute("SELECT * FROM #pytest_test_rowcount;") - assert ( - cursor.rowcount == -1 - ), "Rowcount should be -1 after a SELECT statement (before fetch)" - - # After fetchall, rowcount should be updated to match the number of rows fetched - rows = cursor.fetchall() - assert len(rows) == 6, "Should have fetched 6 rows" - assert cursor.rowcount == 6, "Rowcount should be updated to 6 after fetchall" + # Drop all test tables + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.rowid_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.timestamp_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.multiple_unique_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.identity_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_unique_test") + cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_timestamp_test") + # Drop the test schema + cursor.execute("DROP SCHEMA IF EXISTS pytest_special_schema") db_connection.commit() except Exception as e: - pytest.fail(f"Rowcount test failed: {e}") - finally: - cursor.execute("DROP TABLE #pytest_test_rowcount") + pytest.fail(f"Test cleanup failed: {e}") -def test_specialcolumns_setup(cursor, db_connection): - """Create test tables for testing rowIdColumns and rowVerColumns""" +def test_statistics_setup(cursor, db_connection): + """Create test tables and indexes for statistics testing""" try: # Create a test schema for isolation cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_special_schema') EXEC('CREATE SCHEMA pytest_special_schema')" + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_stats_schema') EXEC('CREATE SCHEMA pytest_stats_schema')" ) # Drop tables if they exist - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.rowid_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.timestamp_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.multiple_unique_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.identity_test") + cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.stats_test") + cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.empty_stats_test") - # Create table with primary key (for rowIdColumns) + # Create test table with various indexes cursor.execute(""" - CREATE TABLE pytest_special_schema.rowid_test ( + CREATE TABLE pytest_stats_schema.stats_test ( id INT PRIMARY KEY, - name NVARCHAR(100) NOT NULL, - unique_col NVARCHAR(100) UNIQUE, - non_unique_col NVARCHAR(100) + name VARCHAR(100) NOT NULL, + email VARCHAR(100) UNIQUE, + department VARCHAR(50) NOT NULL, + salary DECIMAL(10, 2) NULL, + hire_date DATE NOT NULL ) """) - # Create table with rowversion column (for rowVerColumns) + # Create a non-unique index cursor.execute(""" - CREATE TABLE pytest_special_schema.timestamp_test ( - id INT PRIMARY KEY, - name NVARCHAR(100) NOT NULL, - last_updated ROWVERSION - ) + CREATE INDEX IX_stats_test_dept_date ON pytest_stats_schema.stats_test (department, hire_date) """) - # Create table with multiple unique identifiers + # Create a unique index on multiple columns cursor.execute(""" - CREATE TABLE pytest_special_schema.multiple_unique_test ( - id INT NOT NULL, - code VARCHAR(10) NOT NULL, - email VARCHAR(100) UNIQUE, - order_number VARCHAR(20) UNIQUE, - CONSTRAINT PK_multiple_unique_test PRIMARY KEY (id, code) - ) + CREATE UNIQUE INDEX UX_stats_test_name_dept ON pytest_stats_schema.stats_test (name, department) """) - # Create table with identity column + # Create an empty table for testing cursor.execute(""" - CREATE TABLE pytest_special_schema.identity_test ( - id INT IDENTITY(1,1) PRIMARY KEY, - name NVARCHAR(100) NOT NULL, - last_modified DATETIME DEFAULT GETDATE() + CREATE TABLE pytest_stats_schema.empty_stats_test ( + id INT PRIMARY KEY, + data VARCHAR(100) NULL ) """) @@ -13043,356 +12655,290 @@ def test_specialcolumns_setup(cursor, db_connection): pytest.fail(f"Test setup failed: {e}") -def test_rowid_columns_basic(cursor, db_connection): - """Test basic functionality of rowIdColumns""" +def test_statistics_basic(cursor, db_connection): + """Test basic functionality of statistics method""" try: - # Get row identifier columns for simple table - rowid_cols = cursor.rowIdColumns( - table="rowid_test", schema="pytest_special_schema" - ).fetchall() + # First set up our test tables + test_statistics_setup(cursor, db_connection) - # LIMITATION: Only returns first column of primary key - assert len(rowid_cols) == 1, "Should find exactly one ROWID column (first column of PK)" + # Get statistics for the test table (all indexes) + stats = cursor.statistics(table="stats_test", schema="pytest_stats_schema").fetchall() - # Verify column name in the results - col = rowid_cols[0] + # Verify we got results - should include PK, unique index on email, and non-unique index + assert stats is not None, "statistics() should return results" + assert len(stats) > 0, "statistics() should return at least one row" + + # Count different types of indexes + table_stats = [s for s in stats if s.type == 0] # TABLE_STAT + indexes = [s for s in stats if s.type != 0] # Actual indexes + + # We should have at least one table statistics row and multiple index rows + assert len(table_stats) <= 1, "Should have at most one TABLE_STAT row" assert ( - col.column_name.lower() == "id" - ), "Primary key column should be included in ROWID results" + len(indexes) >= 3 + ), "Should have at least 3 index entries (PK, unique email, non-unique dept+date)" - # Verify result structure - assert hasattr(col, "scope"), "Result should have scope column" - assert hasattr(col, "column_name"), "Result should have column_name column" - assert hasattr(col, "data_type"), "Result should have data_type column" - assert hasattr(col, "type_name"), "Result should have type_name column" - assert hasattr(col, "column_size"), "Result should have column_size column" - assert hasattr(col, "buffer_length"), "Result should have buffer_length column" - assert hasattr(col, "decimal_digits"), "Result should have decimal_digits column" - assert hasattr(col, "pseudo_column"), "Result should have pseudo_column column" + # Verify column names in results + first_row = stats[0] + assert hasattr(first_row, "table_name"), "Result should have table_name column" + assert hasattr(first_row, "non_unique"), "Result should have non_unique column" + assert hasattr(first_row, "index_name"), "Result should have index_name column" + assert hasattr(first_row, "type"), "Result should have type column" + assert hasattr(first_row, "column_name"), "Result should have column_name column" - # The scope should be one of the valid values or NULL - assert col.scope in [0, 1, 2, None], f"Invalid scope value: {col.scope}" + # Check that we can find the primary key + pk_found = False + for stat in stats: + if hasattr(stat, "index_name") and stat.index_name and "pk" in stat.index_name.lower(): + pk_found = True + break - # The pseudo_column should be one of the valid values - assert col.pseudo_column in [ - 0, - 1, - 2, - None, - ], f"Invalid pseudo_column value: {col.pseudo_column}" + assert pk_found, "Primary key should be included in statistics results" + + # Check that we can find the unique index on email + email_index_found = False + for stat in stats: + if ( + hasattr(stat, "column_name") + and stat.column_name + and stat.column_name.lower() == "email" + and hasattr(stat, "non_unique") + and stat.non_unique == 0 + ): # 0 = unique + email_index_found = True + break + + assert email_index_found, "Unique index on email should be included in statistics results" - except Exception as e: - pytest.fail(f"rowIdColumns basic test failed: {e}") finally: - # Clean up happens in test_specialcolumns_cleanup + # Clean up happens in test_statistics_cleanup pass -def test_rowid_columns_identity(cursor, db_connection): - """Test rowIdColumns with identity column""" +def test_statistics_unique_only(cursor, db_connection): + """Test statistics with unique=True to get only unique indexes""" try: - # Get row identifier columns for table with identity column - rowid_cols = cursor.rowIdColumns( - table="identity_test", schema="pytest_special_schema" + # Get statistics for only unique indexes + stats = cursor.statistics( + table="stats_test", schema="pytest_stats_schema", unique=True ).fetchall() - # LIMITATION: Only returns the identity column if it's the primary key - assert len(rowid_cols) == 1, "Should find exactly one ROWID column (identity column as PK)" + # Verify we got results + assert stats is not None, "statistics() with unique=True should return results" + assert len(stats) > 0, "statistics() with unique=True should return at least one row" - # Verify it's the identity column - col = rowid_cols[0] - assert col.column_name.lower() == "id", "Identity column should be included as it's the PK" + # All index entries should be for unique indexes (non_unique = 0) + for stat in stats: + if hasattr(stat, "type") and stat.type != 0: # Skip TABLE_STAT entries + assert hasattr(stat, "non_unique"), "Index entry should have non_unique column" + assert stat.non_unique == 0, "With unique=True, all indexes should be unique" + + # Count different types of indexes + indexes = [s for s in stats if hasattr(s, "type") and s.type != 0] + + # We should have multiple unique indexes (PK, unique email, unique name+dept) + assert len(indexes) >= 3, "Should have at least 3 unique index entries" - except Exception as e: - pytest.fail(f"rowIdColumns identity test failed: {e}") finally: - # Clean up happens in test_specialcolumns_cleanup + # Clean up happens in test_statistics_cleanup pass -def test_rowid_columns_composite(cursor, db_connection): - """Test rowIdColumns with composite primary key""" +def test_statistics_empty_table(cursor, db_connection): + """Test statistics on a table with no data (just schema)""" try: - # Get row identifier columns for table with composite primary key - rowid_cols = cursor.rowIdColumns( - table="multiple_unique_test", schema="pytest_special_schema" - ).fetchall() + # Get statistics for the empty table + stats = cursor.statistics(table="empty_stats_test", schema="pytest_stats_schema").fetchall() - # LIMITATION: Only returns first column of composite primary key - assert len(rowid_cols) >= 1, "Should find at least one ROWID column (first column of PK)" + # Should still return metadata about the primary key + assert stats is not None, "statistics() should return results even for empty table" + assert len(stats) > 0, "statistics() should return at least one row for empty table" - # Verify column names in the results - should be the first PK column - col_names = [col.column_name.lower() for col in rowid_cols] - assert "id" in col_names, "First part of composite PK should be included" + # Check for primary key + pk_found = False + for stat in stats: + if hasattr(stat, "index_name") and stat.index_name and "pk" in stat.index_name.lower(): + pk_found = True + break - # LIMITATION: Other parts of the PK or unique constraints may not be included - if len(rowid_cols) > 1: - # If additional columns are returned, they should be valid - for col in rowid_cols: - assert col.column_name.lower() in [ - "id", - "code", - ], "Only PK columns should be returned" + assert pk_found, "Primary key should be included in statistics results for empty table" - except Exception as e: - pytest.fail(f"rowIdColumns composite test failed: {e}") finally: - # Clean up happens in test_specialcolumns_cleanup + # Clean up happens in test_statistics_cleanup pass -def test_rowid_columns_nonexistent(cursor): - """Test rowIdColumns with non-existent table""" +def test_statistics_nonexistent(cursor): + """Test statistics with non-existent table name""" # Use a table name that's highly unlikely to exist - rowid_cols = cursor.rowIdColumns("nonexistent_table_xyz123").fetchall() + stats = cursor.statistics("nonexistent_table_xyz123").fetchall() # Should return empty list, not error - assert isinstance(rowid_cols, list), "Should return a list for non-existent table" - assert len(rowid_cols) == 0, "Should return empty list for non-existent table" + assert isinstance(stats, list), "Should return a list for non-existent table" + assert len(stats) == 0, "Should return empty list for non-existent table" -def test_rowid_columns_nullable(cursor, db_connection): - """Test rowIdColumns with nullable parameter""" +def test_statistics_result_structure(cursor, db_connection): + """Test the complete structure of statistics result rows""" try: - # First create a table with nullable unique column and non-nullable PK - cursor.execute(""" - CREATE TABLE pytest_special_schema.nullable_test ( - id INT PRIMARY KEY, -- PK can't be nullable in SQL Server - data NVARCHAR(100) NULL - ) - """) - db_connection.commit() + # Get statistics for the test table + stats = cursor.statistics(table="stats_test", schema="pytest_stats_schema").fetchall() - # Test with nullable=True (default) - rowid_cols_with_nullable = cursor.rowIdColumns( - table="nullable_test", schema="pytest_special_schema" - ).fetchall() + # Verify we have results + assert len(stats) > 0, "Should have statistics results" - # Verify PK column is included - assert len(rowid_cols_with_nullable) == 1, "Should return exactly one column (PK)" - assert ( - rowid_cols_with_nullable[0].column_name.lower() == "id" - ), "PK column should be returned" + # Find a row that's an actual index (not TABLE_STAT) + index_row = None + for stat in stats: + if hasattr(stat, "type") and stat.type != 0: + index_row = stat + break - # Test with nullable=False - rowid_cols_no_nullable = cursor.rowIdColumns( - table="nullable_test", schema="pytest_special_schema", nullable=False - ).fetchall() + assert index_row is not None, "Should have at least one index row" - # The behavior of SQLSpecialColumns with SQL_NO_NULLS is to only return - # non-nullable columns that uniquely identify a row, but SQL Server returns - # an empty set in this case - this is expected behavior - assert ( - len(rowid_cols_no_nullable) == 0 - ), "Should return empty list when nullable=False (ODBC API behavior)" + # Check for all required columns + required_columns = [ + "table_cat", + "table_schem", + "table_name", + "non_unique", + "index_qualifier", + "index_name", + "type", + "ordinal_position", + "column_name", + "asc_or_desc", + "cardinality", + "pages", + "filter_condition", + ] + + for column in required_columns: + assert hasattr(index_row, column), f"Result missing required column: {column}" + + # Check types of key columns + assert isinstance(index_row.table_name, str), "table_name should be a string" + assert isinstance(index_row.type, int), "type should be an integer" + + # Don't check the actual values of cardinality and pages as they may be NULL + # or driver-dependent, especially for empty tables - except Exception as e: - pytest.fail(f"rowIdColumns nullable test failed: {e}") finally: - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_test") - db_connection.commit() + # Clean up happens in test_statistics_cleanup + pass -def test_rowver_columns_basic(cursor, db_connection): - """Test basic functionality of rowVerColumns""" +def test_statistics_catalog_filter(cursor, db_connection): + """Test statistics with catalog filter""" try: - # Get version columns from timestamp test table - rowver_cols = cursor.rowVerColumns( - table="timestamp_test", schema="pytest_special_schema" - ).fetchall() - - # Verify we got results - assert len(rowver_cols) == 1, "Should find exactly one ROWVER column" - - # Verify the column is the rowversion column - rowver_col = rowver_cols[0] - assert ( - rowver_col.column_name.lower() == "last_updated" - ), "ROWVER column should be 'last_updated'" - assert rowver_col.type_name.lower() in [ - "rowversion", - "timestamp", - ], "ROWVER column should have rowversion or timestamp type" - - # Verify result structure - allowing for NULL values - assert hasattr(rowver_col, "scope"), "Result should have scope column" - assert hasattr(rowver_col, "column_name"), "Result should have column_name column" - assert hasattr(rowver_col, "data_type"), "Result should have data_type column" - assert hasattr(rowver_col, "type_name"), "Result should have type_name column" - assert hasattr(rowver_col, "column_size"), "Result should have column_size column" - assert hasattr(rowver_col, "buffer_length"), "Result should have buffer_length column" - assert hasattr(rowver_col, "decimal_digits"), "Result should have decimal_digits column" - assert hasattr(rowver_col, "pseudo_column"), "Result should have pseudo_column column" - - # The scope should be one of the valid values or NULL - assert rowver_col.scope in [ - 0, - 1, - 2, - None, - ], f"Invalid scope value: {rowver_col.scope}" - - except Exception as e: - pytest.fail(f"rowVerColumns basic test failed: {e}") - finally: - # Clean up happens in test_specialcolumns_cleanup - pass - - -def test_rowver_columns_nonexistent(cursor): - """Test rowVerColumns with non-existent table""" - # Use a table name that's highly unlikely to exist - rowver_cols = cursor.rowVerColumns("nonexistent_table_xyz123").fetchall() - - # Should return empty list, not error - assert isinstance(rowver_cols, list), "Should return a list for non-existent table" - assert len(rowver_cols) == 0, "Should return empty list for non-existent table" - - -def test_rowver_columns_nullable(cursor, db_connection): - """Test rowVerColumns with nullable parameter (not expected to have effect)""" - try: - # First create a table with rowversion column - cursor.execute(""" - CREATE TABLE pytest_special_schema.nullable_rowver_test ( - id INT PRIMARY KEY, - ts ROWVERSION - ) - """) - db_connection.commit() + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + current_db = cursor.fetchone().current_db - # Test with nullable=True (default) - rowver_cols_with_nullable = cursor.rowVerColumns( - table="nullable_rowver_test", schema="pytest_special_schema" + # Get statistics with current catalog + stats = cursor.statistics( + table="stats_test", catalog=current_db, schema="pytest_stats_schema" ).fetchall() - # Verify rowversion column is included (rowversion can't be nullable) - assert len(rowver_cols_with_nullable) == 1, "Should find exactly one ROWVER column" - assert ( - rowver_cols_with_nullable[0].column_name.lower() == "ts" - ), "ROWVERSION column should be included" + # Verify catalog filter worked + assert len(stats) > 0, "Should find statistics with correct catalog" - # Test with nullable=False - rowver_cols_no_nullable = cursor.rowVerColumns( - table="nullable_rowver_test", schema="pytest_special_schema", nullable=False - ).fetchall() + # Verify catalog in results + for stat in stats: + if hasattr(stat, "table_cat"): + assert stat.table_cat.lower() == current_db.lower(), "Wrong table catalog" - # Verify rowversion column is still included - assert len(rowver_cols_no_nullable) == 1, "Should find exactly one ROWVER column" - assert ( - rowver_cols_no_nullable[0].column_name.lower() == "ts" - ), "ROWVERSION column should be included even with nullable=False" + # Get statistics with non-existent catalog + fake_stats = cursor.statistics( + table="stats_test", + catalog="nonexistent_db_xyz123", + schema="pytest_stats_schema", + ).fetchall() + assert len(fake_stats) == 0, "Should return empty list for non-existent catalog" - except Exception as e: - pytest.fail(f"rowVerColumns nullable test failed: {e}") finally: - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_rowver_test") - db_connection.commit() + # Clean up happens in test_statistics_cleanup + pass -def test_specialcolumns_catalog_filter(cursor, db_connection): - """Test special columns with catalog filter""" +def test_statistics_with_quick_parameter(cursor, db_connection): + """Test statistics with quick parameter variations""" try: - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - current_db = cursor.fetchone().current_db - - # Test rowIdColumns with current catalog - rowid_cols = cursor.rowIdColumns( - table="rowid_test", catalog=current_db, schema="pytest_special_schema" - ).fetchall() - - # Verify catalog filter worked - assert len(rowid_cols) > 0, "Should find ROWID columns with correct catalog" - - # Test rowIdColumns with non-existent catalog - fake_rowid_cols = cursor.rowIdColumns( - table="rowid_test", - catalog="nonexistent_db_xyz123", - schema="pytest_special_schema", + # Test with quick=True (default) + quick_stats = cursor.statistics( + table="stats_test", schema="pytest_stats_schema", quick=True ).fetchall() - assert len(fake_rowid_cols) == 0, "Should return empty list for non-existent catalog" - # Test rowVerColumns with current catalog - rowver_cols = cursor.rowVerColumns( - table="timestamp_test", catalog=current_db, schema="pytest_special_schema" + # Test with quick=False + thorough_stats = cursor.statistics( + table="stats_test", schema="pytest_stats_schema", quick=False ).fetchall() - # Verify catalog filter worked - assert len(rowver_cols) > 0, "Should find ROWVER columns with correct catalog" + # Both should return results, but we can't guarantee behavior differences + # since it depends on the ODBC driver and database system + assert len(quick_stats) > 0, "quick=True should return results" + assert len(thorough_stats) > 0, "quick=False should return results" - # Test rowVerColumns with non-existent catalog - fake_rowver_cols = cursor.rowVerColumns( - table="timestamp_test", - catalog="nonexistent_db_xyz123", - schema="pytest_special_schema", - ).fetchall() - assert len(fake_rowver_cols) == 0, "Should return empty list for non-existent catalog" + # Just verify that changing the parameter didn't cause errors - except Exception as e: - pytest.fail(f"Special columns catalog filter test failed: {e}") finally: - # Clean up happens in test_specialcolumns_cleanup + # Clean up happens in test_statistics_cleanup pass -def test_specialcolumns_cleanup(cursor, db_connection): +def test_statistics_cleanup(cursor, db_connection): """Clean up test tables after testing""" try: # Drop all test tables - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.rowid_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.timestamp_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.multiple_unique_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.identity_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_unique_test") - cursor.execute("DROP TABLE IF EXISTS pytest_special_schema.nullable_timestamp_test") + cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.stats_test") + cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.empty_stats_test") # Drop the test schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_special_schema") + cursor.execute("DROP SCHEMA IF EXISTS pytest_stats_schema") db_connection.commit() except Exception as e: pytest.fail(f"Test cleanup failed: {e}") -def test_statistics_setup(cursor, db_connection): - """Create test tables and indexes for statistics testing""" +def test_columns_setup(cursor, db_connection): + """Create test tables for columns method testing""" try: # Create a test schema for isolation cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_stats_schema') EXEC('CREATE SCHEMA pytest_stats_schema')" + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_cols_schema') EXEC('CREATE SCHEMA pytest_cols_schema')" ) # Drop tables if they exist - cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.stats_test") - cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.empty_stats_test") + cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_test") + cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_special_test") - # Create test table with various indexes - cursor.execute(""" - CREATE TABLE pytest_stats_schema.stats_test ( + # Create test table with various column types + cursor.execute(""" + CREATE TABLE pytest_cols_schema.columns_test ( id INT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - email VARCHAR(100) UNIQUE, - department VARCHAR(50) NOT NULL, - salary DECIMAL(10, 2) NULL, - hire_date DATE NOT NULL + name NVARCHAR(100) NOT NULL, + description NVARCHAR(MAX) NULL, + price DECIMAL(10, 2) NULL, + created_date DATETIME DEFAULT GETDATE(), + is_active BIT NOT NULL DEFAULT 1, + binary_data VARBINARY(MAX) NULL, + notes TEXT NULL, + [computed_col] AS (name + ' - ' + CAST(id AS VARCHAR(10))) ) """) - # Create a non-unique index - cursor.execute(""" - CREATE INDEX IX_stats_test_dept_date ON pytest_stats_schema.stats_test (department, hire_date) - """) - - # Create a unique index on multiple columns - cursor.execute(""" - CREATE UNIQUE INDEX UX_stats_test_name_dept ON pytest_stats_schema.stats_test (name, department) - """) - - # Create an empty table for testing - cursor.execute(""" - CREATE TABLE pytest_stats_schema.empty_stats_test ( - id INT PRIMARY KEY, - data VARCHAR(100) NULL + # Create table with special column names and edge cases - fix the problematic column name + cursor.execute(""" + CREATE TABLE pytest_cols_schema.columns_special_test ( + [ID] INT PRIMARY KEY, + [User Name] NVARCHAR(100) NULL, + [Spaces Multiple] VARCHAR(50) NULL, + [123_numeric_start] INT NULL, + [MAX] VARCHAR(20) NULL, -- SQL keyword as column name + [SELECT] INT NULL, -- SQL keyword as column name + [Column.With.Dots] VARCHAR(20) NULL, + [Column/With/Slashes] VARCHAR(20) NULL, + [Column_With_Underscores] VARCHAR(20) NULL -- Changed from problematic nested brackets ) """) @@ -13401,806 +12947,493 @@ def test_statistics_setup(cursor, db_connection): pytest.fail(f"Test setup failed: {e}") -def test_statistics_basic(cursor, db_connection): - """Test basic functionality of statistics method""" +def test_columns_all(cursor, db_connection): + """Test columns returns information about all columns in all tables""" try: # First set up our test tables - test_statistics_setup(cursor, db_connection) - - # Get statistics for the test table (all indexes) - stats = cursor.statistics(table="stats_test", schema="pytest_stats_schema").fetchall() - - # Verify we got results - should include PK, unique index on email, and non-unique index - assert stats is not None, "statistics() should return results" - assert len(stats) > 0, "statistics() should return at least one row" + test_columns_setup(cursor, db_connection) - # Count different types of indexes - table_stats = [s for s in stats if s.type == 0] # TABLE_STAT - indexes = [s for s in stats if s.type != 0] # Actual indexes + # Get all columns (no filters) + cols_cursor = cursor.columns() + cols = cols_cursor.fetchall() - # We should have at least one table statistics row and multiple index rows - assert len(table_stats) <= 1, "Should have at most one TABLE_STAT row" - assert ( - len(indexes) >= 3 - ), "Should have at least 3 index entries (PK, unique email, non-unique dept+date)" + # Verify we got results + assert cols is not None, "columns() should return results" + assert len(cols) > 0, "columns() should return at least one column" - # Verify column names in results - first_row = stats[0] - assert hasattr(first_row, "table_name"), "Result should have table_name column" - assert hasattr(first_row, "non_unique"), "Result should have non_unique column" - assert hasattr(first_row, "index_name"), "Result should have index_name column" - assert hasattr(first_row, "type"), "Result should have type column" - assert hasattr(first_row, "column_name"), "Result should have column_name column" - - # Check that we can find the primary key - pk_found = False - for stat in stats: - if hasattr(stat, "index_name") and stat.index_name and "pk" in stat.index_name.lower(): - pk_found = True - break - - assert pk_found, "Primary key should be included in statistics results" - - # Check that we can find the unique index on email - email_index_found = False - for stat in stats: + # Verify our test tables' columns are in the results + # Use case-insensitive comparison to avoid driver case sensitivity issues + found_test_table = False + for col in cols: if ( - hasattr(stat, "column_name") - and stat.column_name - and stat.column_name.lower() == "email" - and hasattr(stat, "non_unique") - and stat.non_unique == 0 - ): # 0 = unique - email_index_found = True + hasattr(col, "table_name") + and col.table_name + and col.table_name.lower() == "columns_test" + and hasattr(col, "table_schem") + and col.table_schem + and col.table_schem.lower() == "pytest_cols_schema" + ): + found_test_table = True break - assert email_index_found, "Unique index on email should be included in statistics results" + assert found_test_table, "Test table columns should be included in results" + + # Verify structure of results + first_row = cols[0] + assert hasattr(first_row, "table_cat"), "Result should have table_cat column" + assert hasattr(first_row, "table_schem"), "Result should have table_schem column" + assert hasattr(first_row, "table_name"), "Result should have table_name column" + assert hasattr(first_row, "column_name"), "Result should have column_name column" + assert hasattr(first_row, "data_type"), "Result should have data_type column" + assert hasattr(first_row, "type_name"), "Result should have type_name column" + assert hasattr(first_row, "column_size"), "Result should have column_size column" + assert hasattr(first_row, "buffer_length"), "Result should have buffer_length column" + assert hasattr(first_row, "decimal_digits"), "Result should have decimal_digits column" + assert hasattr(first_row, "num_prec_radix"), "Result should have num_prec_radix column" + assert hasattr(first_row, "nullable"), "Result should have nullable column" + assert hasattr(first_row, "remarks"), "Result should have remarks column" + assert hasattr(first_row, "column_def"), "Result should have column_def column" + assert hasattr(first_row, "sql_data_type"), "Result should have sql_data_type column" + assert hasattr(first_row, "sql_datetime_sub"), "Result should have sql_datetime_sub column" + assert hasattr( + first_row, "char_octet_length" + ), "Result should have char_octet_length column" + assert hasattr(first_row, "ordinal_position"), "Result should have ordinal_position column" + assert hasattr(first_row, "is_nullable"), "Result should have is_nullable column" finally: - # Clean up happens in test_statistics_cleanup + # Clean up happens in test_columns_cleanup pass -def test_statistics_unique_only(cursor, db_connection): - """Test statistics with unique=True to get only unique indexes""" +def test_columns_specific_table(cursor, db_connection): + """Test columns returns information about a specific table""" try: - # Get statistics for only unique indexes - stats = cursor.statistics( - table="stats_test", schema="pytest_stats_schema", unique=True - ).fetchall() + # Get columns for the test table + cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() # Verify we got results - assert stats is not None, "statistics() with unique=True should return results" - assert len(stats) > 0, "statistics() with unique=True should return at least one row" + assert len(cols) == 9, "Should find exactly 9 columns in columns_test" - # All index entries should be for unique indexes (non_unique = 0) - for stat in stats: - if hasattr(stat, "type") and stat.type != 0: # Skip TABLE_STAT entries - assert hasattr(stat, "non_unique"), "Index entry should have non_unique column" - assert stat.non_unique == 0, "With unique=True, all indexes should be unique" + # Verify all column names are present (case insensitive) + col_names = [col.column_name.lower() for col in cols] + expected_names = [ + "id", + "name", + "description", + "price", + "created_date", + "is_active", + "binary_data", + "notes", + "computed_col", + ] - # Count different types of indexes - indexes = [s for s in stats if hasattr(s, "type") and s.type != 0] + for name in expected_names: + assert name in col_names, f"Column {name} should be in results" - # We should have multiple unique indexes (PK, unique email, unique name+dept) - assert len(indexes) >= 3, "Should have at least 3 unique index entries" + # Verify details of a specific column (id) + id_col = next(col for col in cols if col.column_name.lower() == "id") + assert id_col.nullable == 0, "id column should be non-nullable" + assert id_col.ordinal_position == 1, "id should be the first column" + assert id_col.is_nullable == "NO", "is_nullable should be NO for id column" + + # Check data types (but don't assume specific ODBC type codes since they vary by driver) + # Instead check that the type_name is correct + id_type = id_col.type_name.lower() + assert "int" in id_type, f"id column should be INTEGER type, got {id_type}" + + # Check a nullable column + desc_col = next(col for col in cols if col.column_name.lower() == "description") + assert desc_col.nullable == 1, "description column should be nullable" + assert desc_col.is_nullable == "YES", "is_nullable should be YES for description column" finally: - # Clean up happens in test_statistics_cleanup + # Clean up happens in test_columns_cleanup pass -def test_statistics_empty_table(cursor, db_connection): - """Test statistics on a table with no data (just schema)""" +def test_columns_special_chars(cursor, db_connection): + """Test columns with special characters and edge cases""" try: - # Get statistics for the empty table - stats = cursor.statistics(table="empty_stats_test", schema="pytest_stats_schema").fetchall() + # Get columns for the special table + cols = cursor.columns(table="columns_special_test", schema="pytest_cols_schema").fetchall() - # Should still return metadata about the primary key - assert stats is not None, "statistics() should return results even for empty table" - assert len(stats) > 0, "statistics() should return at least one row for empty table" + # Verify we got results + assert len(cols) == 9, "Should find exactly 9 columns in columns_special_test" - # Check for primary key - pk_found = False - for stat in stats: - if hasattr(stat, "index_name") and stat.index_name and "pk" in stat.index_name.lower(): - pk_found = True - break + # Check that special column names are handled correctly + col_names = [col.column_name for col in cols] - assert pk_found, "Primary key should be included in statistics results for empty table" + # Create case-insensitive lookup + col_names_lower = [name.lower() if name else None for name in col_names] + + # Check for columns with special characters - note that column names might be + # returned with or without brackets/quotes depending on the driver + assert any( + "user name" in name.lower() for name in col_names + ), "Column with spaces should be in results" + assert any("id" == name.lower() for name in col_names), "ID column should be in results" + assert any( + "123_numeric_start" in name.lower() for name in col_names + ), "Column starting with numbers should be in results" + assert any("max" == name.lower() for name in col_names), "MAX column should be in results" + assert any( + "select" == name.lower() for name in col_names + ), "SELECT column should be in results" + assert any( + "column.with.dots" in name.lower() for name in col_names + ), "Column with dots should be in results" + assert any( + "column/with/slashes" in name.lower() for name in col_names + ), "Column with slashes should be in results" + assert any( + "column_with_underscores" in name.lower() for name in col_names + ), "Column with underscores should be in results" finally: - # Clean up happens in test_statistics_cleanup + # Clean up happens in test_columns_cleanup pass -def test_statistics_nonexistent(cursor): - """Test statistics with non-existent table name""" - # Use a table name that's highly unlikely to exist - stats = cursor.statistics("nonexistent_table_xyz123").fetchall() - - # Should return empty list, not error - assert isinstance(stats, list), "Should return a list for non-existent table" - assert len(stats) == 0, "Should return empty list for non-existent table" - - -def test_statistics_result_structure(cursor, db_connection): - """Test the complete structure of statistics result rows""" +def test_columns_specific_column(cursor, db_connection): + """Test columns with specific column filter""" try: - # Get statistics for the test table - stats = cursor.statistics(table="stats_test", schema="pytest_stats_schema").fetchall() + # Get specific column + cols = cursor.columns( + table="columns_test", schema="pytest_cols_schema", column="name" + ).fetchall() - # Verify we have results - assert len(stats) > 0, "Should have statistics results" + # Verify we got just one result + assert len(cols) == 1, "Should find exactly 1 column named 'name'" - # Find a row that's an actual index (not TABLE_STAT) - index_row = None - for stat in stats: - if hasattr(stat, "type") and stat.type != 0: - index_row = stat - break + # Verify column details + col = cols[0] + assert col.column_name.lower() == "name", "Column name should be 'name'" + assert col.table_name.lower() == "columns_test", "Table name should be 'columns_test'" + assert ( + col.table_schem.lower() == "pytest_cols_schema" + ), "Schema should be 'pytest_cols_schema'" + assert col.nullable == 0, "name column should be non-nullable" - assert index_row is not None, "Should have at least one index row" + # Get column using pattern (% wildcard) + pattern_cols = cursor.columns( + table="columns_test", schema="pytest_cols_schema", column="%date%" + ).fetchall() - # Check for all required columns - required_columns = [ - "table_cat", - "table_schem", - "table_name", - "non_unique", - "index_qualifier", - "index_name", - "type", - "ordinal_position", - "column_name", - "asc_or_desc", - "cardinality", - "pages", - "filter_condition", - ] + # Should find created_date column + assert len(pattern_cols) == 1, "Should find 1 column matching '%date%'" - for column in required_columns: - assert hasattr(index_row, column), f"Result missing required column: {column}" + assert ( + pattern_cols[0].column_name.lower() == "created_date" + ), "Should find created_date column" - # Check types of key columns - assert isinstance(index_row.table_name, str), "table_name should be a string" - assert isinstance(index_row.type, int), "type should be an integer" + # Get multiple columns with pattern + multi_cols = cursor.columns( + table="columns_test", + schema="pytest_cols_schema", + column="%d%", # Should match id, description, created_date + ).fetchall() - # Don't check the actual values of cardinality and pages as they may be NULL - # or driver-dependent, especially for empty tables + # At least 3 columns should match this pattern + assert len(multi_cols) >= 3, "Should find at least 3 columns matching '%d%'" + match_names = [col.column_name.lower() for col in multi_cols] + assert "id" in match_names, "id should match '%d%'" + assert "description" in match_names, "description should match '%d%'" + assert "created_date" in match_names, "created_date should match '%d%'" finally: - # Clean up happens in test_statistics_cleanup + # Clean up happens in test_columns_cleanup pass -def test_statistics_catalog_filter(cursor, db_connection): - """Test statistics with catalog filter""" +def test_columns_with_underscore_pattern(cursor): + """Test columns with underscore wildcard pattern""" try: - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - current_db = cursor.fetchone().current_db - - # Get statistics with current catalog - stats = cursor.statistics( - table="stats_test", catalog=current_db, schema="pytest_stats_schema" + # Get columns with underscore pattern (one character wildcard) + # Looking for 'id' (exactly 2 chars) + cols = cursor.columns( + table="columns_test", schema="pytest_cols_schema", column="__" ).fetchall() - # Verify catalog filter worked - assert len(stats) > 0, "Should find statistics with correct catalog" + # Should find 'id' column + id_found = False + for col in cols: + if col.column_name.lower() == "id" and col.table_name.lower() == "columns_test": + id_found = True + break - # Verify catalog in results - for stat in stats: - if hasattr(stat, "table_cat"): - assert stat.table_cat.lower() == current_db.lower(), "Wrong table catalog" + assert id_found, "Should find 'id' column with pattern '__'" - # Get statistics with non-existent catalog - fake_stats = cursor.statistics( - table="stats_test", - catalog="nonexistent_db_xyz123", - schema="pytest_stats_schema", + # Try a more complex pattern with both % and _ + # For example: '%_d%' matches any column with 'd' as the second or later character + pattern_cols = cursor.columns( + table="columns_test", schema="pytest_cols_schema", column="%_d%" ).fetchall() - assert len(fake_stats) == 0, "Should return empty list for non-existent catalog" + + # Should match 'id' (if considering case-insensitive) and 'created_date' + match_names = [ + col.column_name.lower() + for col in pattern_cols + if col.table_name.lower() == "columns_test" + ] + + # At least 'created_date' should match this pattern + assert "created_date" in match_names, "created_date should match '%_d%'" finally: - # Clean up happens in test_statistics_cleanup + # Clean up happens in test_columns_cleanup pass -def test_statistics_with_quick_parameter(cursor, db_connection): - """Test statistics with quick parameter variations""" +def test_columns_data_types(cursor): + """Test columns returns correct data type information""" try: - # Test with quick=True (default) - quick_stats = cursor.statistics( - table="stats_test", schema="pytest_stats_schema", quick=True - ).fetchall() + # Get all columns from test table + cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() - # Test with quick=False - thorough_stats = cursor.statistics( - table="stats_test", schema="pytest_stats_schema", quick=False - ).fetchall() + # Create a dictionary mapping column names to their details + col_dict = {col.column_name.lower(): col for col in cols} - # Both should return results, but we can't guarantee behavior differences - # since it depends on the ODBC driver and database system - assert len(quick_stats) > 0, "quick=True should return results" - assert len(thorough_stats) > 0, "quick=False should return results" + # Check data types by name (case insensitive checks) + # Note: We're checking type_name as a string to avoid SQL type code inconsistencies + # between drivers - # Just verify that changing the parameter didn't cause errors + # INT column + assert "int" in col_dict["id"].type_name.lower(), "id should be INT type" - finally: - # Clean up happens in test_statistics_cleanup - pass + # NVARCHAR column + assert any( + name in col_dict["name"].type_name.lower() + for name in ["nvarchar", "varchar", "char", "wchar"] + ), "name should be NVARCHAR type" + # DECIMAL column + assert any( + name in col_dict["price"].type_name.lower() for name in ["decimal", "numeric", "money"] + ), "price should be DECIMAL type" -def test_statistics_cleanup(cursor, db_connection): - """Clean up test tables after testing""" - try: - # Drop all test tables - cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.stats_test") - cursor.execute("DROP TABLE IF EXISTS pytest_stats_schema.empty_stats_test") + # BIT column + assert any( + name in col_dict["is_active"].type_name.lower() for name in ["bit", "boolean"] + ), "is_active should be BIT type" - # Drop the test schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_stats_schema") - db_connection.commit() - except Exception as e: - pytest.fail(f"Test cleanup failed: {e}") + # TEXT column + assert any( + name in col_dict["notes"].type_name.lower() for name in ["text", "char", "varchar"] + ), "notes should be TEXT type" + # Check nullable flag + assert col_dict["id"].nullable == 0, "id should be non-nullable" + assert col_dict["description"].nullable == 1, "description should be nullable" -def test_columns_setup(cursor, db_connection): - """Create test tables for columns method testing""" - try: - # Create a test schema for isolation - cursor.execute( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'pytest_cols_schema') EXEC('CREATE SCHEMA pytest_cols_schema')" - ) + # Check column size + assert col_dict["name"].column_size == 100, "name should have size 100" - # Drop tables if they exist - cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_test") - cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_special_test") + # Check decimal digits for numeric type + assert col_dict["price"].decimal_digits == 2, "price should have 2 decimal digits" - # Create test table with various column types - cursor.execute(""" - CREATE TABLE pytest_cols_schema.columns_test ( - id INT PRIMARY KEY, - name NVARCHAR(100) NOT NULL, - description NVARCHAR(MAX) NULL, - price DECIMAL(10, 2) NULL, - created_date DATETIME DEFAULT GETDATE(), - is_active BIT NOT NULL DEFAULT 1, - binary_data VARBINARY(MAX) NULL, - notes TEXT NULL, - [computed_col] AS (name + ' - ' + CAST(id AS VARCHAR(10))) - ) - """) + finally: + # Clean up happens in test_columns_cleanup + pass - # Create table with special column names and edge cases - fix the problematic column name - cursor.execute(""" - CREATE TABLE pytest_cols_schema.columns_special_test ( - [ID] INT PRIMARY KEY, - [User Name] NVARCHAR(100) NULL, - [Spaces Multiple] VARCHAR(50) NULL, - [123_numeric_start] INT NULL, - [MAX] VARCHAR(20) NULL, -- SQL keyword as column name - [SELECT] INT NULL, -- SQL keyword as column name - [Column.With.Dots] VARCHAR(20) NULL, - [Column/With/Slashes] VARCHAR(20) NULL, - [Column_With_Underscores] VARCHAR(20) NULL -- Changed from problematic nested brackets - ) - """) - db_connection.commit() - except Exception as e: - pytest.fail(f"Test setup failed: {e}") +def test_columns_nonexistent(cursor): + """Test columns with non-existent table or column""" + # Test with non-existent table + table_cols = cursor.columns(table="nonexistent_table_xyz123").fetchall() + assert len(table_cols) == 0, "Should return empty list for non-existent table" + + # Test with non-existent column in existing table + col_cols = cursor.columns( + table="columns_test", + schema="pytest_cols_schema", + column="nonexistent_column_xyz123", + ).fetchall() + assert len(col_cols) == 0, "Should return empty list for non-existent column" + + # Test with non-existent schema + schema_cols = cursor.columns( + table="columns_test", schema="nonexistent_schema_xyz123" + ).fetchall() + assert len(schema_cols) == 0, "Should return empty list for non-existent schema" -def test_columns_all(cursor, db_connection): - """Test columns returns information about all columns in all tables""" +def test_columns_catalog_filter(cursor): + """Test columns with catalog filter""" try: - # First set up our test tables - test_columns_setup(cursor, db_connection) + # Get current database name + cursor.execute("SELECT DB_NAME() AS current_db") + current_db = cursor.fetchone().current_db - # Get all columns (no filters) - cols_cursor = cursor.columns() - cols = cols_cursor.fetchall() + # Get columns with current catalog + cols = cursor.columns( + table="columns_test", catalog=current_db, schema="pytest_cols_schema" + ).fetchall() - # Verify we got results - assert cols is not None, "columns() should return results" - assert len(cols) > 0, "columns() should return at least one column" + # Verify catalog filter worked + assert len(cols) > 0, "Should find columns with correct catalog" - # Verify our test tables' columns are in the results - # Use case-insensitive comparison to avoid driver case sensitivity issues - found_test_table = False + # Check catalog in results for col in cols: - if ( - hasattr(col, "table_name") - and col.table_name - and col.table_name.lower() == "columns_test" - and hasattr(col, "table_schem") - and col.table_schem - and col.table_schem.lower() == "pytest_cols_schema" - ): - found_test_table = True - break - - assert found_test_table, "Test table columns should be included in results" + # Some drivers might return None for catalog + if col.table_cat is not None: + assert col.table_cat.lower() == current_db.lower(), "Wrong table catalog" - # Verify structure of results - first_row = cols[0] - assert hasattr(first_row, "table_cat"), "Result should have table_cat column" - assert hasattr(first_row, "table_schem"), "Result should have table_schem column" - assert hasattr(first_row, "table_name"), "Result should have table_name column" - assert hasattr(first_row, "column_name"), "Result should have column_name column" - assert hasattr(first_row, "data_type"), "Result should have data_type column" - assert hasattr(first_row, "type_name"), "Result should have type_name column" - assert hasattr(first_row, "column_size"), "Result should have column_size column" - assert hasattr(first_row, "buffer_length"), "Result should have buffer_length column" - assert hasattr(first_row, "decimal_digits"), "Result should have decimal_digits column" - assert hasattr(first_row, "num_prec_radix"), "Result should have num_prec_radix column" - assert hasattr(first_row, "nullable"), "Result should have nullable column" - assert hasattr(first_row, "remarks"), "Result should have remarks column" - assert hasattr(first_row, "column_def"), "Result should have column_def column" - assert hasattr(first_row, "sql_data_type"), "Result should have sql_data_type column" - assert hasattr(first_row, "sql_datetime_sub"), "Result should have sql_datetime_sub column" - assert hasattr( - first_row, "char_octet_length" - ), "Result should have char_octet_length column" - assert hasattr(first_row, "ordinal_position"), "Result should have ordinal_position column" - assert hasattr(first_row, "is_nullable"), "Result should have is_nullable column" + # Test with non-existent catalog + fake_cols = cursor.columns( + table="columns_test", + catalog="nonexistent_db_xyz123", + schema="pytest_cols_schema", + ).fetchall() + assert len(fake_cols) == 0, "Should return empty list for non-existent catalog" finally: # Clean up happens in test_columns_cleanup pass -def test_columns_specific_table(cursor, db_connection): - """Test columns returns information about a specific table""" +def test_columns_schema_pattern(cursor): + """Test columns with schema name pattern""" try: - # Get columns for the test table - cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() + # Get columns with schema pattern + cols = cursor.columns(table="columns_test", schema="pytest_%").fetchall() - # Verify we got results - assert len(cols) == 9, "Should find exactly 9 columns in columns_test" + # Should find our test table columns + test_cols = [col for col in cols if col.table_name.lower() == "columns_test"] + assert len(test_cols) > 0, "Should find columns using schema pattern" - # Verify all column names are present (case insensitive) - col_names = [col.column_name.lower() for col in cols] - expected_names = [ - "id", - "name", - "description", - "price", - "created_date", - "is_active", - "binary_data", - "notes", - "computed_col", - ] - - for name in expected_names: - assert name in col_names, f"Column {name} should be in results" - - # Verify details of a specific column (id) - id_col = next(col for col in cols if col.column_name.lower() == "id") - assert id_col.nullable == 0, "id column should be non-nullable" - assert id_col.ordinal_position == 1, "id should be the first column" - assert id_col.is_nullable == "NO", "is_nullable should be NO for id column" - - # Check data types (but don't assume specific ODBC type codes since they vary by driver) - # Instead check that the type_name is correct - id_type = id_col.type_name.lower() - assert "int" in id_type, f"id column should be INTEGER type, got {id_type}" + # Try a more specific pattern + specific_cols = cursor.columns(table="columns_test", schema="pytest_cols%").fetchall() - # Check a nullable column - desc_col = next(col for col in cols if col.column_name.lower() == "description") - assert desc_col.nullable == 1, "description column should be nullable" - assert desc_col.is_nullable == "YES", "is_nullable should be YES for description column" + # Should still find our test table columns + test_cols = [col for col in specific_cols if col.table_name.lower() == "columns_test"] + assert len(test_cols) > 0, "Should find columns using specific schema pattern" finally: # Clean up happens in test_columns_cleanup pass -def test_columns_special_chars(cursor, db_connection): - """Test columns with special characters and edge cases""" +def test_columns_table_pattern(cursor): + """Test columns with table name pattern""" try: - # Get columns for the special table - cols = cursor.columns(table="columns_special_test", schema="pytest_cols_schema").fetchall() - - # Verify we got results - assert len(cols) == 9, "Should find exactly 9 columns in columns_special_test" - - # Check that special column names are handled correctly - col_names = [col.column_name for col in cols] + # Get columns with table pattern + cols = cursor.columns(table="columns_%", schema="pytest_cols_schema").fetchall() - # Create case-insensitive lookup - col_names_lower = [name.lower() if name else None for name in col_names] + # Should find columns from both test tables + tables_found = set() + for col in cols: + if col.table_name: + tables_found.add(col.table_name.lower()) - # Check for columns with special characters - note that column names might be - # returned with or without brackets/quotes depending on the driver - assert any( - "user name" in name.lower() for name in col_names - ), "Column with spaces should be in results" - assert any("id" == name.lower() for name in col_names), "ID column should be in results" - assert any( - "123_numeric_start" in name.lower() for name in col_names - ), "Column starting with numbers should be in results" - assert any("max" == name.lower() for name in col_names), "MAX column should be in results" - assert any( - "select" == name.lower() for name in col_names - ), "SELECT column should be in results" - assert any( - "column.with.dots" in name.lower() for name in col_names - ), "Column with dots should be in results" - assert any( - "column/with/slashes" in name.lower() for name in col_names - ), "Column with slashes should be in results" - assert any( - "column_with_underscores" in name.lower() for name in col_names - ), "Column with underscores should be in results" + assert "columns_test" in tables_found, "Should find columns_test with pattern columns_%" + assert ( + "columns_special_test" in tables_found + ), "Should find columns_special_test with pattern columns_%" finally: # Clean up happens in test_columns_cleanup pass -def test_columns_specific_column(cursor, db_connection): - """Test columns with specific column filter""" +def test_columns_ordinal_position(cursor): + """Test ordinal_position is correct in columns results""" try: - # Get specific column - cols = cursor.columns( - table="columns_test", schema="pytest_cols_schema", column="name" - ).fetchall() - - # Verify we got just one result - assert len(cols) == 1, "Should find exactly 1 column named 'name'" - - # Verify column details - col = cols[0] - assert col.column_name.lower() == "name", "Column name should be 'name'" - assert col.table_name.lower() == "columns_test", "Table name should be 'columns_test'" - assert ( - col.table_schem.lower() == "pytest_cols_schema" - ), "Schema should be 'pytest_cols_schema'" - assert col.nullable == 0, "name column should be non-nullable" - - # Get column using pattern (% wildcard) - pattern_cols = cursor.columns( - table="columns_test", schema="pytest_cols_schema", column="%date%" - ).fetchall() - - # Should find created_date column - assert len(pattern_cols) == 1, "Should find 1 column matching '%date%'" + # Get columns for the test table + cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() - assert ( - pattern_cols[0].column_name.lower() == "created_date" - ), "Should find created_date column" + # Sort by ordinal position + sorted_cols = sorted(cols, key=lambda col: col.ordinal_position) - # Get multiple columns with pattern - multi_cols = cursor.columns( - table="columns_test", - schema="pytest_cols_schema", - column="%d%", # Should match id, description, created_date - ).fetchall() + # Verify positions are consecutive starting from 1 + for i, col in enumerate(sorted_cols, 1): + assert ( + col.ordinal_position == i + ), f"Column {col.column_name} should have ordinal_position {i}" - # At least 3 columns should match this pattern - assert len(multi_cols) >= 3, "Should find at least 3 columns matching '%d%'" - match_names = [col.column_name.lower() for col in multi_cols] - assert "id" in match_names, "id should match '%d%'" - assert "description" in match_names, "description should match '%d%'" - assert "created_date" in match_names, "created_date should match '%d%'" + # First column should be id (primary key) + assert sorted_cols[0].column_name.lower() == "id", "First column should be id" finally: # Clean up happens in test_columns_cleanup pass -def test_columns_with_underscore_pattern(cursor): - """Test columns with underscore wildcard pattern""" +def test_columns_cleanup(cursor, db_connection): + """Clean up test tables after testing""" try: - # Get columns with underscore pattern (one character wildcard) - # Looking for 'id' (exactly 2 chars) - cols = cursor.columns( - table="columns_test", schema="pytest_cols_schema", column="__" - ).fetchall() - - # Should find 'id' column - id_found = False - for col in cols: - if col.column_name.lower() == "id" and col.table_name.lower() == "columns_test": - id_found = True - break - - assert id_found, "Should find 'id' column with pattern '__'" + # Drop all test tables + cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_test") + cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_special_test") - # Try a more complex pattern with both % and _ - # For example: '%_d%' matches any column with 'd' as the second or later character - pattern_cols = cursor.columns( - table="columns_test", schema="pytest_cols_schema", column="%_d%" - ).fetchall() + # Drop the test schema + cursor.execute("DROP SCHEMA IF EXISTS pytest_cols_schema") + db_connection.commit() + except Exception as e: + pytest.fail(f"Test cleanup failed: {e}") - # Should match 'id' (if considering case-insensitive) and 'created_date' - match_names = [ - col.column_name.lower() - for col in pattern_cols - if col.table_name.lower() == "columns_test" - ] - # At least 'created_date' should match this pattern - assert "created_date" in match_names, "created_date should match '%_d%'" +def test_lowercase_attribute(cursor, db_connection): + """Test that the lowercase attribute properly converts column names to lowercase""" - finally: - # Clean up happens in test_columns_cleanup - pass + # Store original value to restore after test + original_lowercase = mssql_python.lowercase + drop_cursor = None + try: + # Create a test table with mixed-case column names + cursor.execute(""" + CREATE TABLE #pytest_lowercase_test ( + ID INT PRIMARY KEY, + UserName VARCHAR(50), + EMAIL_ADDRESS VARCHAR(100), + PhoneNumber VARCHAR(20) + ) + """) + db_connection.commit() -def test_columns_nonexistent(cursor): - """Test columns with non-existent table or column""" - # Test with non-existent table - table_cols = cursor.columns(table="nonexistent_table_xyz123") - assert len(table_cols) == 0, "Should return empty list for non-existent table" + # Insert test data + cursor.execute(""" + INSERT INTO #pytest_lowercase_test (ID, UserName, EMAIL_ADDRESS, PhoneNumber) + VALUES (1, 'JohnDoe', 'john@example.com', '555-1234') + """) + db_connection.commit() - # Test with non-existent column in existing table - col_cols = cursor.columns( - table="columns_test", - schema="pytest_cols_schema", - column="nonexistent_column_xyz123", - ).fetchall() - assert len(col_cols) == 0, "Should return empty list for non-existent column" + # First test with lowercase=False (default) + mssql_python.lowercase = False + cursor1 = db_connection.cursor() + cursor1.execute("SELECT * FROM #pytest_lowercase_test") - # Test with non-existent schema - schema_cols = cursor.columns( - table="columns_test", schema="nonexistent_schema_xyz123" - ).fetchall() - assert len(schema_cols) == 0, "Should return empty list for non-existent schema" + # Description column names should preserve original case + column_names1 = [desc[0] for desc in cursor1.description] + assert "ID" in column_names1, "Column 'ID' should be present with original case" + assert "UserName" in column_names1, "Column 'UserName' should be present with original case" + # Make sure to consume all results and close the cursor + cursor1.fetchall() + cursor1.close() -def test_columns_data_types(cursor): - """Test columns returns correct data type information""" - try: - # Get all columns from test table - cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() + # Now test with lowercase=True + mssql_python.lowercase = True + cursor2 = db_connection.cursor() + cursor2.execute("SELECT * FROM #pytest_lowercase_test") - # Create a dictionary mapping column names to their details - col_dict = {col.column_name.lower(): col for col in cols} + # Description column names should be lowercase + column_names2 = [desc[0] for desc in cursor2.description] + assert "id" in column_names2, "Column names should be lowercase when lowercase=True" + assert "username" in column_names2, "Column names should be lowercase when lowercase=True" - # Check data types by name (case insensitive checks) - # Note: We're checking type_name as a string to avoid SQL type code inconsistencies - # between drivers + # Make sure to consume all results and close the cursor + cursor2.fetchall() + cursor2.close() - # INT column - assert "int" in col_dict["id"].type_name.lower(), "id should be INT type" + # Create a fresh cursor for cleanup + drop_cursor = db_connection.cursor() - # NVARCHAR column - assert any( - name in col_dict["name"].type_name.lower() - for name in ["nvarchar", "varchar", "char", "wchar"] - ), "name should be NVARCHAR type" - - # DECIMAL column - assert any( - name in col_dict["price"].type_name.lower() for name in ["decimal", "numeric", "money"] - ), "price should be DECIMAL type" - - # BIT column - assert any( - name in col_dict["is_active"].type_name.lower() for name in ["bit", "boolean"] - ), "is_active should be BIT type" - - # TEXT column - assert any( - name in col_dict["notes"].type_name.lower() for name in ["text", "char", "varchar"] - ), "notes should be TEXT type" - - # Check nullable flag - assert col_dict["id"].nullable == 0, "id should be non-nullable" - assert col_dict["description"].nullable == 1, "description should be nullable" - - # Check column size - assert col_dict["name"].column_size == 100, "name should have size 100" - - # Check decimal digits for numeric type - assert col_dict["price"].decimal_digits == 2, "price should have 2 decimal digits" - - finally: - # Clean up happens in test_columns_cleanup - pass - - -def test_columns_nonexistent(cursor): - """Test columns with non-existent table or column""" - # Test with non-existent table - table_cols = cursor.columns(table="nonexistent_table_xyz123").fetchall() - assert len(table_cols) == 0, "Should return empty list for non-existent table" - - # Test with non-existent column in existing table - col_cols = cursor.columns( - table="columns_test", - schema="pytest_cols_schema", - column="nonexistent_column_xyz123", - ).fetchall() - assert len(col_cols) == 0, "Should return empty list for non-existent column" - - # Test with non-existent schema - schema_cols = cursor.columns( - table="columns_test", schema="nonexistent_schema_xyz123" - ).fetchall() - assert len(schema_cols) == 0, "Should return empty list for non-existent schema" - - -def test_columns_catalog_filter(cursor): - """Test columns with catalog filter""" - try: - # Get current database name - cursor.execute("SELECT DB_NAME() AS current_db") - current_db = cursor.fetchone().current_db - - # Get columns with current catalog - cols = cursor.columns( - table="columns_test", catalog=current_db, schema="pytest_cols_schema" - ).fetchall() - - # Verify catalog filter worked - assert len(cols) > 0, "Should find columns with correct catalog" - - # Check catalog in results - for col in cols: - # Some drivers might return None for catalog - if col.table_cat is not None: - assert col.table_cat.lower() == current_db.lower(), "Wrong table catalog" - - # Test with non-existent catalog - fake_cols = cursor.columns( - table="columns_test", - catalog="nonexistent_db_xyz123", - schema="pytest_cols_schema", - ).fetchall() - assert len(fake_cols) == 0, "Should return empty list for non-existent catalog" - - finally: - # Clean up happens in test_columns_cleanup - pass - - -def test_columns_schema_pattern(cursor): - """Test columns with schema name pattern""" - try: - # Get columns with schema pattern - cols = cursor.columns(table="columns_test", schema="pytest_%").fetchall() - - # Should find our test table columns - test_cols = [col for col in cols if col.table_name.lower() == "columns_test"] - assert len(test_cols) > 0, "Should find columns using schema pattern" - - # Try a more specific pattern - specific_cols = cursor.columns(table="columns_test", schema="pytest_cols%").fetchall() - - # Should still find our test table columns - test_cols = [col for col in specific_cols if col.table_name.lower() == "columns_test"] - assert len(test_cols) > 0, "Should find columns using specific schema pattern" - - finally: - # Clean up happens in test_columns_cleanup - pass - - -def test_columns_table_pattern(cursor): - """Test columns with table name pattern""" - try: - # Get columns with table pattern - cols = cursor.columns(table="columns_%", schema="pytest_cols_schema").fetchall() - - # Should find columns from both test tables - tables_found = set() - for col in cols: - if col.table_name: - tables_found.add(col.table_name.lower()) - - assert "columns_test" in tables_found, "Should find columns_test with pattern columns_%" - assert ( - "columns_special_test" in tables_found - ), "Should find columns_special_test with pattern columns_%" - - finally: - # Clean up happens in test_columns_cleanup - pass - - -def test_columns_ordinal_position(cursor): - """Test ordinal_position is correct in columns results""" - try: - # Get columns for the test table - cols = cursor.columns(table="columns_test", schema="pytest_cols_schema").fetchall() - - # Sort by ordinal position - sorted_cols = sorted(cols, key=lambda col: col.ordinal_position) - - # Verify positions are consecutive starting from 1 - for i, col in enumerate(sorted_cols, 1): - assert ( - col.ordinal_position == i - ), f"Column {col.column_name} should have ordinal_position {i}" - - # First column should be id (primary key) - assert sorted_cols[0].column_name.lower() == "id", "First column should be id" - - finally: - # Clean up happens in test_columns_cleanup - pass - - -def test_columns_cleanup(cursor, db_connection): - """Clean up test tables after testing""" - try: - # Drop all test tables - cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_test") - cursor.execute("DROP TABLE IF EXISTS pytest_cols_schema.columns_special_test") - - # Drop the test schema - cursor.execute("DROP SCHEMA IF EXISTS pytest_cols_schema") - db_connection.commit() - except Exception as e: - pytest.fail(f"Test cleanup failed: {e}") - - -def test_lowercase_attribute(cursor, db_connection): - """Test that the lowercase attribute properly converts column names to lowercase""" - - # Store original value to restore after test - original_lowercase = mssql_python.lowercase - drop_cursor = None - - try: - # Create a test table with mixed-case column names - cursor.execute(""" - CREATE TABLE #pytest_lowercase_test ( - ID INT PRIMARY KEY, - UserName VARCHAR(50), - EMAIL_ADDRESS VARCHAR(100), - PhoneNumber VARCHAR(20) - ) - """) - db_connection.commit() - - # Insert test data - cursor.execute(""" - INSERT INTO #pytest_lowercase_test (ID, UserName, EMAIL_ADDRESS, PhoneNumber) - VALUES (1, 'JohnDoe', 'john@example.com', '555-1234') - """) - db_connection.commit() - - # First test with lowercase=False (default) - mssql_python.lowercase = False - cursor1 = db_connection.cursor() - cursor1.execute("SELECT * FROM #pytest_lowercase_test") - - # Description column names should preserve original case - column_names1 = [desc[0] for desc in cursor1.description] - assert "ID" in column_names1, "Column 'ID' should be present with original case" - assert "UserName" in column_names1, "Column 'UserName' should be present with original case" - - # Make sure to consume all results and close the cursor - cursor1.fetchall() - cursor1.close() - - # Now test with lowercase=True - mssql_python.lowercase = True - cursor2 = db_connection.cursor() - cursor2.execute("SELECT * FROM #pytest_lowercase_test") - - # Description column names should be lowercase - column_names2 = [desc[0] for desc in cursor2.description] - assert "id" in column_names2, "Column names should be lowercase when lowercase=True" - assert "username" in column_names2, "Column names should be lowercase when lowercase=True" - - # Make sure to consume all results and close the cursor - cursor2.fetchall() - cursor2.close() - - # Create a fresh cursor for cleanup - drop_cursor = db_connection.cursor() - - finally: - # Restore original value - mssql_python.lowercase = original_lowercase + finally: + # Restore original value + mssql_python.lowercase = original_lowercase try: # Use a separate cursor for cleanup @@ -15150,1102 +14383,689 @@ def test_decimal_scientific_notation_to_varchar(cursor, db_connection, values, d stored_val == expected_val ), f"{description}: Row {i} mismatch - expected {expected_val}, got {stored_val}" - finally: - try: - cursor.execute(f"DROP TABLE {table_name}") - db_connection.commit() - except: - pass - - -SMALL_XML = "1" -LARGE_XML = "" + "".join(f"{i}" for i in range(10000)) + "" -EMPTY_XML = "" -INVALID_XML = "" # malformed - - -def test_xml_basic_insert_fetch(cursor, db_connection): - """Test insert and fetch of a small XML value.""" - try: - cursor.execute( - "CREATE TABLE #pytest_xml_basic (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" - ) - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_xml_basic (xml_col) VALUES (?);", SMALL_XML) - db_connection.commit() - - row = cursor.execute("SELECT xml_col FROM #pytest_xml_basic;").fetchone() - assert row[0] == SMALL_XML - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_xml_basic;") - db_connection.commit() - - -def test_xml_empty_and_null(cursor, db_connection): - """Test insert and fetch of empty XML and NULL values.""" - try: - cursor.execute( - "CREATE TABLE #pytest_xml_empty_null (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" - ) - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_xml_empty_null (xml_col) VALUES (?);", EMPTY_XML) - cursor.execute("INSERT INTO #pytest_xml_empty_null (xml_col) VALUES (?);", None) - db_connection.commit() - - rows = [ - r[0] - for r in cursor.execute( - "SELECT xml_col FROM #pytest_xml_empty_null ORDER BY id;" - ).fetchall() - ] - assert rows[0] == EMPTY_XML - assert rows[1] is None - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_xml_empty_null;") - db_connection.commit() - - -def test_xml_large_insert(cursor, db_connection): - """Test insert and fetch of a large XML value to verify streaming/DAE.""" - try: - cursor.execute( - "CREATE TABLE #pytest_xml_large (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" - ) - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_xml_large (xml_col) VALUES (?);", LARGE_XML) - db_connection.commit() - - row = cursor.execute("SELECT xml_col FROM #pytest_xml_large;").fetchone() - assert row[0] == LARGE_XML - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_xml_large;") - db_connection.commit() - - -def test_xml_batch_insert(cursor, db_connection): - """Test batch insert (executemany) of multiple XML values.""" - try: - cursor.execute( - "CREATE TABLE #pytest_xml_batch (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" - ) - db_connection.commit() - - xmls = [f"{i}" for i in range(5)] - cursor.executemany( - "INSERT INTO #pytest_xml_batch (xml_col) VALUES (?);", [(x,) for x in xmls] - ) - db_connection.commit() - - rows = [ - r[0] - for r in cursor.execute("SELECT xml_col FROM #pytest_xml_batch ORDER BY id;").fetchall() - ] - assert rows == xmls - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_xml_batch;") - db_connection.commit() - - -def test_xml_malformed_input(cursor, db_connection): - """Verify driver raises error for invalid XML input.""" - try: - cursor.execute( - "CREATE TABLE #pytest_xml_invalid (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" - ) - db_connection.commit() - - with pytest.raises(Exception): - cursor.execute("INSERT INTO #pytest_xml_invalid (xml_col) VALUES (?);", INVALID_XML) - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_xml_invalid;") - db_connection.commit() - - -# ==================== CODE COVERAGE TEST CASES ==================== - - -def test_decimal_special_values_coverage(cursor): - """Non-finite Decimals are rejected explicitly by `_get_numeric_data`.""" - from decimal import Decimal - - # NaN reports exponent 'n', sNaN reports 'N', Infinity reports 'F'. None of - # them has a SQL NUMERIC encoding, so all three must raise ValueError rather - # than falling through to precision=38 and packing a silent zero. - test_values = [ - Decimal("NaN"), - Decimal("sNaN"), - Decimal("Infinity"), - Decimal("-Infinity"), - ] - - for special_val in test_values: - with pytest.raises(ValueError, match="non-finite"): - cursor._get_numeric_data(special_val) - - -def test_decimal_negative_exponent_edge_cases(cursor): - """Test decimal processing with negative exponents (Lines 230-239).""" - from decimal import Decimal - - # Test case where digits < abs(exponent) -> triggers lines 234-235 - # Example: 0.0001 -> digits=(1,), exponent=-4 -> precision=4, scale=4 - test_decimal = Decimal("0.0001") # digits=(1,), exponent=-4 - - try: - cursor._get_numeric_data(test_decimal) - except ValueError as e: - # This is expected - the method should process it and potentially raise precision error - pass - - -def test_decimal_string_conversion_edge_cases(cursor): - """Test decimal string conversion edge cases (Lines 248-262).""" - from decimal import Decimal - - # Test case 1: positive exponent (line 252) - decimal_with_pos_exp = Decimal("123E2") # Should add zeros - try: - cursor._get_numeric_data(decimal_with_pos_exp) - except ValueError: - pass # Expected for large values - - # Test case 2: negative exponent with padding needed (line 255) - decimal_with_neg_exp = Decimal("1E-10") # Should need zero padding - try: - cursor._get_numeric_data(decimal_with_neg_exp) - except ValueError: - pass - - # Test case 3: empty string case (line 258) - # This is harder to trigger directly, but the logic handles it - zero_decimal = Decimal("0") - cursor._get_numeric_data(zero_decimal) - - -def test_decimal_precision_special_values_executemany(cursor): - """Test _get_decimal_precision with special values (Lines 354-362).""" - from decimal import Decimal - - # Test special values in executemany context - test_values = [Decimal("NaN"), Decimal("Infinity"), Decimal("-Infinity")] - - for special_val in test_values: - try: - # This should trigger the special value handling (line 358) - precision = cursor._get_decimal_precision(special_val) - assert precision == 38 # Should return default precision - except Exception: - # Some special values might not be supported - pass - - -def test_cursor_close_connection_tracking_error(db_connection): - """Test cursor close with connection tracking error (Lines 578-586).""" - - cursor = db_connection.cursor() - - # Corrupt the connection's cursor tracking to cause error - original_cursors = db_connection._cursors - - # Replace with something that will cause an error on discard - class ErrorSet: - def discard(self, item): - raise RuntimeError("Simulated cursor tracking error") - - db_connection._cursors = ErrorSet() - - try: - # This should trigger the exception handling in close() (line 582) - cursor.close() - # Should complete without raising the tracking error - assert cursor.closed - finally: - # Restore original cursor tracking - db_connection._cursors = original_cursors - - -def test_setinputsizes_validation_errors(cursor): - """Test setinputsizes parameter validation (Lines 645-669).""" - from mssql_python.constants import ConstantsDDBC - - # Test invalid column_size (lines 649-651) - with pytest.raises(ValueError, match="Invalid column size"): - cursor.setinputsizes([(ConstantsDDBC.SQL_VARCHAR.value, -1, 0)]) - - with pytest.raises(ValueError, match="Invalid column size"): - cursor.setinputsizes([(ConstantsDDBC.SQL_VARCHAR.value, "invalid", 0)]) - - # Test invalid decimal_digits (lines 654-656) - with pytest.raises(ValueError, match="Invalid decimal digits"): - cursor.setinputsizes([(ConstantsDDBC.SQL_DECIMAL.value, 10, -1)]) - - with pytest.raises(ValueError, match="Invalid decimal digits"): - cursor.setinputsizes([(ConstantsDDBC.SQL_DECIMAL.value, 10, "invalid")]) - - # Test invalid SQL type (lines 665-667) - with pytest.raises(ValueError, match="Invalid SQL type"): - cursor.setinputsizes([99999]) # Invalid SQL type constant - - with pytest.raises(ValueError, match="Invalid SQL type"): - cursor.setinputsizes(["invalid"]) # Non-integer SQL type - - -def test_executemany_decimal_column_size_adjustment(cursor, db_connection): - """Test executemany decimal column size adjustment (Lines 739-747).""" - - try: - # Create table with decimal column - cursor.execute("CREATE TABLE #test_decimal_adjust (id INT, decimal_col DECIMAL(38,10))") - - # Test with decimal parameters that should trigger column size adjustment - params = [ - (1, decimal.Decimal("123.456")), - (2, decimal.Decimal("999.999")), - ] - - # This should trigger the decimal column size adjustment logic (lines 743-746) - cursor.executemany( - "INSERT INTO #test_decimal_adjust (id, decimal_col) VALUES (?, ?)", params - ) - - # Verify data was inserted correctly - cursor.execute("SELECT COUNT(*) FROM #test_decimal_adjust") - count = cursor.fetchone()[0] - assert count == 2 - - finally: - cursor.execute("DROP TABLE IF EXISTS #test_decimal_adjust") - - -def test_scroll_no_result_set_error(cursor): - """Test scroll without active result set (Lines 906-914, 2207-2215).""" - - # Test decrement rownumber without result set (lines 910-913) - cursor._rownumber = 5 - cursor._has_result_set = False - - with pytest.raises(mssql_python.InterfaceError, match="Cannot decrement rownumber"): - cursor._decrement_rownumber() - - # Test scroll without result set (lines 2211-2214) - with pytest.raises(mssql_python.ProgrammingError, match="No active result set"): - cursor.scroll(1) - - -def test_timeout_setting_and_logging(cursor): - """Test timeout setting with logging (Lines 1006-1014, 1678-1688).""" - - # Test timeout setting in execute (lines 1010, 1682-1684) - cursor.timeout = 30 - - try: - # This should trigger timeout setting and logging - cursor.execute("SELECT 1") - cursor.fetchall() - - # Test with executemany as well - cursor.executemany("SELECT ?", [(1,), (2,)]) - - except Exception: - # Timeout setting might fail in some environments, which is okay - # The important part is that we exercise the code path - pass - - -def test_column_description_validation(cursor): - """Test column description validation (Lines 1116-1124).""" - - # Execute query to get column descriptions - cursor.execute("SELECT CAST('test' AS NVARCHAR(50)) as col1, CAST(123 as INT) as col2") - - # The description should be populated and validated - assert cursor.description is not None - assert len(cursor.description) == 2 - - # Each description should have 7 elements per PEP-249 - for desc in cursor.description: - assert len(desc) == 7, f"Column description should have 7 elements, got {len(desc)}" - - -def test_column_metadata_error_handling(cursor): - """Test column metadata retrieval error handling (Lines 1156-1167).""" - - # Execute a complex query that might stress metadata retrieval - cursor.execute(""" - SELECT - CAST(1 as INT) as int_col, - CAST('test' as NVARCHAR(100)) as nvarchar_col, - CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col - """) - - # This should exercise the metadata retrieval code paths - # If there are any errors, they should be logged but not crash - description = cursor.description - assert description is not None - assert len(description) == 3 - - -def test_fetchone_column_mapping_coverage(cursor): - """Test fetchone with specialized column mapping (Lines 1185-1215).""" - - # Execute query that should trigger specialized mapping - cursor.execute("SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col") - - # This should trigger the UUID column mapping logic and fetchone specialization - row = cursor.fetchone() - assert row is not None - - # Test fetchmany and fetchall as well - cursor.execute( - "SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col UNION SELECT CAST(NEWID() as UNIQUEIDENTIFIER)" - ) - - # Test fetchmany (lines 1194-1200) - rows = cursor.fetchmany(1) - assert len(rows) == 1 - - # Test fetchall (lines 1202-1208) - cursor.execute( - "SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col UNION SELECT CAST(NEWID() as UNIQUEIDENTIFIER)" - ) - rows = cursor.fetchall() - assert len(rows) == 2 - - -def test_foreignkeys_parameter_validation(cursor): - """Test foreignkeys parameter validation (Lines 1365-1373).""" - - # Test with both table and foreignTable as None (should raise error) - with pytest.raises( - mssql_python.ProgrammingError, - match="Either table or foreignTable must be specified", - ): - cursor.foreignKeys(table=None, foreignTable=None) - - -def test_tables_error_handling(cursor): - """Test tables method error handling (Lines 2396-2404).""" - - # Call tables method - any errors should be logged and re-raised - try: - cursor.tables(catalog="invalid_catalog_that_does_not_exist_12345") - # If this doesn't error, that's fine - we're testing the error handling path - except Exception: - # Expected - the error should be logged and re-raised (line 2400) - pass - - -def test_callproc_not_supported_error(cursor): - """Test callproc NotSupportedError (Lines 2413-2421).""" - - # This should always raise NotSupportedError (lines 2417-2420) - with pytest.raises(mssql_python.NotSupportedError, match="callproc.*is not yet implemented"): - cursor.callproc("test_proc") - - -def test_setoutputsize_no_op(cursor): - """Test setoutputsize no-op behavior (Lines 2433-2438).""" - - # This should be a no-op (line 2437) - cursor.setoutputsize(1000) # Should not raise any errors - cursor.setoutputsize(1000, 1) # With column parameter - - -def test_cursor_del_cleanup_basic(db_connection): - """Test cursor cleanup and __del__ method existence (Lines 2186-2194).""" - - # Test that cursor has __del__ method and basic cleanup - cursor = db_connection.cursor() + finally: + try: + cursor.execute(f"DROP TABLE {table_name}") + db_connection.commit() + except: + pass - # Test that __del__ method exists - assert hasattr(cursor, "__del__"), "Cursor should have __del__ method" - # Close cursor normally - cursor.close() - assert cursor.closed, "Cursor should be closed" +SMALL_XML = "1" +LARGE_XML = "" + "".join(f"{i}" for i in range(10000)) + "" +EMPTY_XML = "" +INVALID_XML = "" # malformed - # Force garbage collection to potentially trigger __del__ cleanup paths - import gc - gc.collect() +def test_xml_basic_insert_fetch(cursor, db_connection): + """Test insert and fetch of a small XML value.""" + try: + cursor.execute( + "CREATE TABLE #pytest_xml_basic (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" + ) + db_connection.commit() + cursor.execute("INSERT INTO #pytest_xml_basic (xml_col) VALUES (?);", SMALL_XML) + db_connection.commit() -def test_scroll_invalid_parameters(cursor): - """Test scroll with invalid parameters.""" + row = cursor.execute("SELECT xml_col FROM #pytest_xml_basic;").fetchone() + assert row[0] == SMALL_XML + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_xml_basic;") + db_connection.commit() - cursor.execute("SELECT 1") - # Test invalid mode - with pytest.raises(mssql_python.ProgrammingError, match="Invalid scroll mode"): - cursor.scroll(1, mode="invalid") +def test_xml_empty_and_null(cursor, db_connection): + """Test insert and fetch of empty XML and NULL values.""" + try: + cursor.execute( + "CREATE TABLE #pytest_xml_empty_null (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" + ) + db_connection.commit() - # Test non-integer value - with pytest.raises(mssql_python.ProgrammingError, match="value must be an integer"): - cursor.scroll("invalid") + cursor.execute("INSERT INTO #pytest_xml_empty_null (xml_col) VALUES (?);", EMPTY_XML) + cursor.execute("INSERT INTO #pytest_xml_empty_null (xml_col) VALUES (?);", None) + db_connection.commit() + rows = [ + r[0] + for r in cursor.execute( + "SELECT xml_col FROM #pytest_xml_empty_null ORDER BY id;" + ).fetchall() + ] + assert rows[0] == EMPTY_XML + assert rows[1] is None + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_xml_empty_null;") + db_connection.commit() -def test_row_uuid_processing_with_braces(cursor, db_connection): - """Test Row UUID processing with braced GUID strings (Lines 95-103).""" +def test_xml_large_insert(cursor, db_connection): + """Test insert and fetch of a large XML value to verify streaming/DAE.""" try: - # Drop table if exists - drop_table_if_exists(cursor, "#pytest_uuid_braces") + cursor.execute( + "CREATE TABLE #pytest_xml_large (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" + ) + db_connection.commit() - # Create table with UNIQUEIDENTIFIER column - cursor.execute(""" - CREATE TABLE #pytest_uuid_braces ( - id INT IDENTITY(1,1), - guid_col UNIQUEIDENTIFIER - ) - """) + cursor.execute("INSERT INTO #pytest_xml_large (xml_col) VALUES (?);", LARGE_XML) + db_connection.commit() - # Insert a GUID with braces (this is how SQL Server often returns them) - test_guid = "12345678-1234-5678-9ABC-123456789ABC" - cursor.execute("INSERT INTO #pytest_uuid_braces (guid_col) VALUES (?)", [test_guid]) + row = cursor.execute("SELECT xml_col FROM #pytest_xml_large;").fetchone() + assert row[0] == LARGE_XML + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_xml_large;") db_connection.commit() - # Configure native_uuid=True to trigger UUID processing - original_setting = None - if hasattr(cursor.connection, "_settings") and "native_uuid" in cursor.connection._settings: - original_setting = cursor.connection._settings["native_uuid"] - cursor.connection._settings["native_uuid"] = True - # Fetch the data - this should trigger lines 95-103 in row.py - cursor.execute("SELECT guid_col FROM #pytest_uuid_braces") - row = cursor.fetchone() +def test_xml_batch_insert(cursor, db_connection): + """Test batch insert (executemany) of multiple XML values.""" + try: + cursor.execute( + "CREATE TABLE #pytest_xml_batch (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" + ) + db_connection.commit() - # The Row class should process the GUID and convert it to UUID object - # Line 99: clean_value = value.strip("{}") - # Line 100: processed_values[i] = uuid.UUID(clean_value) - assert row is not None, "Should return a row" + xmls = [f"{i}" for i in range(5)] + cursor.executemany( + "INSERT INTO #pytest_xml_batch (xml_col) VALUES (?);", [(x,) for x in xmls] + ) + db_connection.commit() - # The GUID should be processed correctly regardless of brace format - guid_value = row[0] + rows = [ + r[0] + for r in cursor.execute("SELECT xml_col FROM #pytest_xml_batch ORDER BY id;").fetchall() + ] + assert rows == xmls + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_xml_batch;") + db_connection.commit() - # Restore original setting - if original_setting is not None and hasattr(cursor.connection, "_settings"): - cursor.connection._settings["native_uuid"] = original_setting - except Exception as e: - pytest.fail(f"UUID processing with braces test failed: {e}") +def test_xml_malformed_input(cursor, db_connection): + """Verify driver raises error for invalid XML input.""" + try: + cursor.execute( + "CREATE TABLE #pytest_xml_invalid (id INT PRIMARY KEY IDENTITY(1,1), xml_col XML NULL);" + ) + db_connection.commit() + + with pytest.raises(Exception): + cursor.execute("INSERT INTO #pytest_xml_invalid (xml_col) VALUES (?);", INVALID_XML) finally: - drop_table_if_exists(cursor, "#pytest_uuid_braces") + cursor.execute("DROP TABLE IF EXISTS #pytest_xml_invalid;") db_connection.commit() -def test_row_uuid_processing_sql_guid_type(cursor, db_connection): - """Test Row UUID processing with SQL_GUID type detection (Lines 111-119).""" +# ==================== CODE COVERAGE TEST CASES ==================== - try: - # Drop table if exists - drop_table_if_exists(cursor, "#pytest_sql_guid_type") - # Create table with UNIQUEIDENTIFIER column - cursor.execute(""" - CREATE TABLE #pytest_sql_guid_type ( - id INT, - guid_col UNIQUEIDENTIFIER - ) - """) +def test_decimal_special_values_coverage(cursor): + """Non-finite Decimals are rejected explicitly by `_get_numeric_data`.""" + from decimal import Decimal - # Insert test data - test_guid = "ABCDEF12-3456-7890-ABCD-1234567890AB" - cursor.execute( - "INSERT INTO #pytest_sql_guid_type (id, guid_col) VALUES (?, ?)", - [1, test_guid], - ) - db_connection.commit() + # NaN reports exponent 'n', sNaN reports 'N', Infinity reports 'F'. None of + # them has a SQL NUMERIC encoding, so all three must raise ValueError rather + # than falling through to precision=38 and packing a silent zero. + test_values = [ + Decimal("NaN"), + Decimal("sNaN"), + Decimal("Infinity"), + Decimal("-Infinity"), + ] - # Configure native_uuid=True to trigger UUID processing - original_setting = None - if hasattr(cursor.connection, "_settings") and "native_uuid" in cursor.connection._settings: - original_setting = cursor.connection._settings["native_uuid"] - cursor.connection._settings["native_uuid"] = True + for special_val in test_values: + with pytest.raises(ValueError, match="non-finite"): + cursor._get_numeric_data(special_val) - # Fetch the data - this should trigger lines 111-119 in row.py - cursor.execute("SELECT id, guid_col FROM #pytest_sql_guid_type") - row = cursor.fetchone() - # Line 111: sql_type = description[i][1] - # Line 112: if sql_type == -11: # SQL_GUID - # Line 115: processed_values[i] = uuid.UUID(value.strip("{}")) - assert row is not None, "Should return a row" - assert row[0] == 1, "ID should be 1" +def test_decimal_negative_exponent_edge_cases(cursor): + """Test decimal processing with negative exponents (Lines 230-239).""" + from decimal import Decimal - # The GUID column should be processed - guid_value = row[1] + # Test case where digits < abs(exponent) -> triggers lines 234-235 + # Example: 0.0001 -> digits=(1,), exponent=-4 -> precision=4, scale=4 + test_decimal = Decimal("0.0001") # digits=(1,), exponent=-4 - # Restore original setting - if original_setting is not None and hasattr(cursor.connection, "_settings"): - cursor.connection._settings["native_uuid"] = original_setting + try: + cursor._get_numeric_data(test_decimal) + except ValueError as e: + # This is expected - the method should process it and potentially raise precision error + pass - except Exception as e: - pytest.fail(f"UUID processing SQL_GUID type test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_sql_guid_type") - db_connection.commit() +def test_decimal_string_conversion_edge_cases(cursor): + """Test decimal string conversion edge cases (Lines 248-262).""" + from decimal import Decimal -def test_row_output_converter_overflow_error(cursor, db_connection): - """Test Row output converter OverflowError handling (Lines 186-195).""" + # Test case 1: positive exponent (line 252) + decimal_with_pos_exp = Decimal("123E2") # Should add zeros + try: + cursor._get_numeric_data(decimal_with_pos_exp) + except ValueError: + pass # Expected for large values + # Test case 2: negative exponent with padding needed (line 255) + decimal_with_neg_exp = Decimal("1E-10") # Should need zero padding try: - # Create a table with integer column - drop_table_if_exists(cursor, "#pytest_overflow_test") - cursor.execute(""" - CREATE TABLE #pytest_overflow_test ( - id INT, - small_int TINYINT -- TINYINT can only hold 0-255 - ) - """) + cursor._get_numeric_data(decimal_with_neg_exp) + except ValueError: + pass - # Insert a valid value first - cursor.execute("INSERT INTO #pytest_overflow_test (id, small_int) VALUES (?, ?)", [1, 100]) - db_connection.commit() + # Test case 3: empty string case (line 258) + # This is harder to trigger directly, but the logic handles it + zero_decimal = Decimal("0") + cursor._get_numeric_data(zero_decimal) + + +def test_decimal_precision_special_values_executemany(cursor): + """Test _get_decimal_precision with special values (Lines 354-362).""" + from decimal import Decimal + + # Test special values in executemany context + test_values = [Decimal("NaN"), Decimal("Infinity"), Decimal("-Infinity")] + + for special_val in test_values: + try: + # This should trigger the special value handling (line 358) + precision = cursor._get_decimal_precision(special_val) + assert precision == 38 # Should return default precision + except Exception: + # Some special values might not be supported + pass - # Create a custom output converter that will cause OverflowError - def problematic_converter(value): - if isinstance(value, int) and value == 100: - # This will cause an OverflowError when trying to convert to bytes - # by simulating a value that's too large for the byte size - raise OverflowError("int too big to convert to bytes") - return value - # Add the converter to the connection (if supported) - if hasattr(cursor.connection, "_output_converters"): - # Create a converter that will trigger the overflow - original_converters = getattr(cursor.connection, "_output_converters", {}) - cursor.connection._output_converters = {-6: problematic_converter} # TINYINT SQL type +def test_cursor_close_connection_tracking_error(db_connection): + """Test cursor close with connection tracking error (Lines 578-586).""" - # Fetch the data - this should trigger lines 186-195 in row.py - cursor.execute("SELECT id, small_int FROM #pytest_overflow_test") - row = cursor.fetchone() + cursor = db_connection.cursor() - # Line 188: except OverflowError as e: - # Lines 190-194: if hasattr(self._cursor, "log"): self._cursor.log(...) - # Line 195: # Keep the original value in this case - assert row is not None, "Should return a row" - assert row[0] == 1, "ID should be 1" + # Corrupt the connection's cursor tracking to cause error + original_cursors = db_connection._cursors - # The overflow should be handled and original value kept - assert row[1] == 100, "Value should be kept as original due to overflow handling" + # Replace with something that will cause an error on discard + class ErrorSet: + def discard(self, item): + raise RuntimeError("Simulated cursor tracking error") - # Restore original converters - if hasattr(cursor.connection, "_output_converters"): - cursor.connection._output_converters = original_converters + db_connection._cursors = ErrorSet() - except Exception as e: - pytest.fail(f"Output converter OverflowError test failed: {e}") + try: + # This should trigger the exception handling in close() (line 582) + cursor.close() + # Should complete without raising the tracking error + assert cursor.closed finally: - drop_table_if_exists(cursor, "#pytest_overflow_test") - db_connection.commit() + # Restore original cursor tracking + db_connection._cursors = original_cursors -def test_row_output_converter_general_exception(cursor, db_connection): - """Test Row output converter general exception handling (Lines 198-206).""" +def test_setinputsizes_validation_errors(cursor): + """Test setinputsizes parameter validation (Lines 645-669).""" + from mssql_python.constants import ConstantsDDBC - # Snapshot converters up front so the finally can ALWAYS restore them, even if - # an assertion below fails. Otherwise the {12: failing_converter} entry would - # leak onto the shared connection and corrupt every later VARCHAR fetch. - had_converters_attr = hasattr(cursor.connection, "_output_converters") - original_converters = getattr(cursor.connection, "_output_converters", {}) + # Test invalid column_size (lines 649-651) + with pytest.raises(ValueError, match="Invalid column size"): + cursor.setinputsizes([(ConstantsDDBC.SQL_VARCHAR.value, -1, 0)]) - try: - # Create a table with string column - drop_table_if_exists(cursor, "#pytest_exception_test") - cursor.execute(""" - CREATE TABLE #pytest_exception_test ( - id INT, - text_col VARCHAR(50) - ) - """) + with pytest.raises(ValueError, match="Invalid column size"): + cursor.setinputsizes([(ConstantsDDBC.SQL_VARCHAR.value, "invalid", 0)]) - # Insert test data - cursor.execute( - "INSERT INTO #pytest_exception_test (id, text_col) VALUES (?, ?)", - [1, "test_value"], - ) - db_connection.commit() + # Test invalid decimal_digits (lines 654-656) + with pytest.raises(ValueError, match="Invalid decimal digits"): + cursor.setinputsizes([(ConstantsDDBC.SQL_DECIMAL.value, 10, -1)]) - # A converter that always raises, to exercise the "converter raised -> - # keep the original value" path. Registered under integer SQL type 12 - # (SQL_VARCHAR); after the GH #684 fix this integer key actually - # dispatches and string values arrive as UTF-16LE bytes, so we raise - # unconditionally rather than guarding on the decoded text. - def failing_converter(value): - raise RuntimeError("Custom converter error for testing") + with pytest.raises(ValueError, match="Invalid decimal digits"): + cursor.setinputsizes([(ConstantsDDBC.SQL_DECIMAL.value, 10, "invalid")]) - # Add the converter to the connection (if supported) - if had_converters_attr: - cursor.connection._output_converters = {12: failing_converter} # SQL_VARCHAR + # Test invalid SQL type (lines 665-667) + with pytest.raises(ValueError, match="Invalid SQL type"): + cursor.setinputsizes([99999]) # Invalid SQL type constant - # Fetch the data - this should trigger lines 198-206 in row.py - cursor.execute("SELECT id, text_col FROM #pytest_exception_test") - row = cursor.fetchone() + with pytest.raises(ValueError, match="Invalid SQL type"): + cursor.setinputsizes(["invalid"]) # Non-integer SQL type - # Line 199: except Exception as e: - # Lines 201-205: if hasattr(self._cursor, "log"): self._cursor.log(...) - # Line 206: # If conversion fails, keep the original value - assert row is not None, "Should return a row" - assert row[0] == 1, "ID should be 1" - # The exception should be handled and original value kept - assert row[1] == "test_value", "Value should be kept as original due to exception handling" +def test_executemany_decimal_column_size_adjustment(cursor, db_connection): + """Test executemany decimal column size adjustment (Lines 739-747).""" - except Exception as e: - pytest.fail(f"Output converter general exception test failed: {e}") - finally: - # Always restore converters (even on assertion failure) so a leaked - # converter can never poison subsequent tests on the shared connection. - if had_converters_attr: - cursor.connection._output_converters = original_converters - drop_table_if_exists(cursor, "#pytest_exception_test") - db_connection.commit() + try: + # Create table with decimal column + cursor.execute("CREATE TABLE #test_decimal_adjust (id INT, decimal_col DECIMAL(38,10))") + # Test with decimal parameters that should trigger column size adjustment + params = [ + (1, decimal.Decimal("123.456")), + (2, decimal.Decimal("999.999")), + ] -def test_row_cursor_log_method_availability(cursor, db_connection): - """Test Row checking for cursor.log method availability (Lines 190, 201).""" + # This should trigger the decimal column size adjustment logic (lines 743-746) + cursor.executemany( + "INSERT INTO #test_decimal_adjust (id, decimal_col) VALUES (?, ?)", params + ) - try: - # Create test data - drop_table_if_exists(cursor, "#pytest_log_check") - cursor.execute(""" - CREATE TABLE #pytest_log_check ( - id INT, - value_col INT - ) - """) + # Verify data was inserted correctly + cursor.execute("SELECT COUNT(*) FROM #test_decimal_adjust") + count = cursor.fetchone()[0] + assert count == 2 - cursor.execute("INSERT INTO #pytest_log_check (id, value_col) VALUES (?, ?)", [1, 42]) - db_connection.commit() + finally: + cursor.execute("DROP TABLE IF EXISTS #test_decimal_adjust") - # Test that cursor has log method or doesn't have it - # Lines 190 and 201: if hasattr(self._cursor, "log"): - cursor.execute("SELECT id, value_col FROM #pytest_log_check") - row = cursor.fetchone() - assert row is not None, "Should return a row" - assert row[0] == 1, "ID should be 1" - assert row[1] == 42, "Value should be 42" +def test_scroll_no_result_set_error(cursor): + """Test scroll without active result set (Lines 906-914, 2207-2215).""" - # The hasattr check should complete without error - # This covers the conditional log method availability checks + # Test decrement rownumber without result set (lines 910-913) + cursor._rownumber = 5 + cursor._has_result_set = False - except Exception as e: - pytest.fail(f"Cursor log method availability test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_log_check") - db_connection.commit() + with pytest.raises(mssql_python.InterfaceError, match="Cannot decrement rownumber"): + cursor._decrement_rownumber() + + # Test scroll without result set (lines 2211-2214) + with pytest.raises(mssql_python.ProgrammingError, match="No active result set"): + cursor.scroll(1) -def test_all_numeric_types_with_nulls(cursor, db_connection): - """Test NULL handling for all numeric types to ensure processor functions handle NULLs correctly""" +def test_timeout_setting_and_logging(cursor): + """Test timeout setting with logging (Lines 1006-1014, 1678-1688).""" + + # Test timeout setting in execute (lines 1010, 1682-1684) + cursor.timeout = 30 + try: - drop_table_if_exists(cursor, "#pytest_all_numeric_nulls") - cursor.execute(""" - CREATE TABLE #pytest_all_numeric_nulls ( - int_col INT, - bigint_col BIGINT, - smallint_col SMALLINT, - tinyint_col TINYINT, - bit_col BIT, - real_col REAL, - float_col FLOAT - ) - """) - db_connection.commit() + # This should trigger timeout setting and logging + cursor.execute("SELECT 1") + cursor.fetchall() - # Insert row with all NULLs - cursor.execute( - "INSERT INTO #pytest_all_numeric_nulls VALUES (NULL, NULL, NULL, NULL, NULL, NULL, NULL)" - ) - # Insert row with actual values - cursor.execute( - "INSERT INTO #pytest_all_numeric_nulls VALUES (42, 9223372036854775807, 32767, 255, 1, 3.14, 2.718281828)" - ) - db_connection.commit() + # Test with executemany as well + cursor.executemany("SELECT ?", [(1,), (2,)]) - cursor.execute("SELECT * FROM #pytest_all_numeric_nulls ORDER BY int_col ASC") - rows = cursor.fetchall() + except Exception: + # Timeout setting might fail in some environments, which is okay + # The important part is that we exercise the code path + pass - # First row should be all NULLs - assert len(rows) == 2, "Should have exactly 2 rows" - assert all(val is None for val in rows[0]), "First row should be all NULLs" - # Second row should have actual values - assert rows[1][0] == 42, "INT column should be 42" - assert rows[1][1] == 9223372036854775807, "BIGINT column should match" - assert rows[1][2] == 32767, "SMALLINT column should be 32767" - assert rows[1][3] == 255, "TINYINT column should be 255" - assert rows[1][4] == True, "BIT column should be True" - assert abs(rows[1][5] - 3.14) < 0.01, "REAL column should be approximately 3.14" - assert ( - abs(rows[1][6] - 2.718281828) < 0.0001 - ), "FLOAT column should be approximately 2.718281828" +def test_column_description_validation(cursor): + """Test column description validation (Lines 1116-1124).""" - except Exception as e: - pytest.fail(f"All numeric types NULL test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_all_numeric_nulls") - db_connection.commit() + # Execute query to get column descriptions + cursor.execute("SELECT CAST('test' AS NVARCHAR(50)) as col1, CAST(123 as INT) as col2") + # The description should be populated and validated + assert cursor.description is not None + assert len(cursor.description) == 2 + + # Each description should have 7 elements per PEP-249 + for desc in cursor.description: + assert len(desc) == 7, f"Column description should have 7 elements, got {len(desc)}" -def test_lob_data_types(cursor, db_connection): - """Test LOB (Large Object) data types to ensure LOB fallback paths are exercised""" - try: - drop_table_if_exists(cursor, "#pytest_lob_test") - cursor.execute(""" - CREATE TABLE #pytest_lob_test ( - id INT, - text_lob VARCHAR(MAX), - ntext_lob NVARCHAR(MAX), - binary_lob VARBINARY(MAX) - ) - """) - db_connection.commit() - # Create large data that will trigger LOB handling - large_text = "A" * 10000 # 10KB text - large_ntext = "B" * 10000 # 10KB unicode text - large_binary = b"\x01\x02\x03\x04" * 2500 # 10KB binary +def test_column_metadata_error_handling(cursor): + """Test column metadata retrieval error handling (Lines 1156-1167).""" - cursor.execute( - "INSERT INTO #pytest_lob_test VALUES (?, ?, ?, ?)", - (1, large_text, large_ntext, large_binary), - ) - db_connection.commit() + # Execute a complex query that might stress metadata retrieval + cursor.execute(""" + SELECT + CAST(1 as INT) as int_col, + CAST('test' as NVARCHAR(100)) as nvarchar_col, + CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col + """) - cursor.execute("SELECT id, text_lob, ntext_lob, binary_lob FROM #pytest_lob_test") - row = cursor.fetchone() + # This should exercise the metadata retrieval code paths + # If there are any errors, they should be logged but not crash + description = cursor.description + assert description is not None + assert len(description) == 3 - assert row[0] == 1, "ID should be 1" - assert row[1] == large_text, "VARCHAR(MAX) LOB data should match" - assert row[2] == large_ntext, "NVARCHAR(MAX) LOB data should match" - assert row[3] == large_binary, "VARBINARY(MAX) LOB data should match" - except Exception as e: - pytest.fail(f"LOB data types test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_lob_test") - db_connection.commit() +def test_fetchone_column_mapping_coverage(cursor): + """Test fetchone with specialized column mapping (Lines 1185-1215).""" + # Execute query that should trigger specialized mapping + cursor.execute("SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col") -def test_lob_char_column_types(cursor, db_connection): - """Test LOB fetching specifically for CHAR/VARCHAR columns (covers lines 3313-3314)""" - try: - drop_table_if_exists(cursor, "#pytest_lob_char") - cursor.execute(""" - CREATE TABLE #pytest_lob_char ( - id INT, - char_lob VARCHAR(MAX) - ) - """) - db_connection.commit() + # This should trigger the UUID column mapping logic and fetchone specialization + row = cursor.fetchone() + assert row is not None - # Create data large enough to trigger LOB path (>8000 bytes) - large_char_data = "X" * 20000 # 20KB text + # Test fetchmany and fetchall as well + cursor.execute( + "SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col UNION SELECT CAST(NEWID() as UNIQUEIDENTIFIER)" + ) - cursor.execute("INSERT INTO #pytest_lob_char VALUES (?, ?)", (1, large_char_data)) - db_connection.commit() + # Test fetchmany (lines 1194-1200) + rows = cursor.fetchmany(1) + assert len(rows) == 1 - cursor.execute("SELECT id, char_lob FROM #pytest_lob_char") - row = cursor.fetchone() + # Test fetchall (lines 1202-1208) + cursor.execute( + "SELECT CAST(NEWID() as UNIQUEIDENTIFIER) as guid_col UNION SELECT CAST(NEWID() as UNIQUEIDENTIFIER)" + ) + rows = cursor.fetchall() + assert len(rows) == 2 - assert row[0] == 1, "ID should be 1" - assert row[1] == large_char_data, "VARCHAR(MAX) LOB data should match" - assert len(row[1]) == 20000, "VARCHAR(MAX) should be 20000 chars" - except Exception as e: - pytest.fail(f"LOB CHAR column test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_lob_char") - db_connection.commit() +def test_foreignkeys_parameter_validation(cursor): + """Test foreignkeys parameter validation (Lines 1365-1373).""" + # Test with both table and foreignTable as None (should raise error) + with pytest.raises( + mssql_python.ProgrammingError, + match="Either table or foreignTable must be specified", + ): + cursor.foreignKeys(table=None, foreignTable=None) -def test_lob_wchar_column_types(cursor, db_connection): - """Test LOB fetching specifically for WCHAR/NVARCHAR columns (covers lines 3358-3359)""" + +def test_tables_error_handling(cursor): + """Test tables method error handling (Lines 2396-2404).""" + + # Call tables method - any errors should be logged and re-raised try: - drop_table_if_exists(cursor, "#pytest_lob_wchar") - cursor.execute(""" - CREATE TABLE #pytest_lob_wchar ( - id INT, - wchar_lob NVARCHAR(MAX) - ) - """) - db_connection.commit() + cursor.tables(catalog="invalid_catalog_that_does_not_exist_12345") + # If this doesn't error, that's fine - we're testing the error handling path + except Exception: + # Expected - the error should be logged and re-raised (line 2400) + pass - # Create unicode data large enough to trigger LOB path (>4000 characters for NVARCHAR) - large_wchar_data = "🔥" * 5000 + "Unicode™" * 1000 # Mix of emoji and special chars - cursor.execute("INSERT INTO #pytest_lob_wchar VALUES (?, ?)", (1, large_wchar_data)) - db_connection.commit() +def test_callproc_not_supported_error(cursor): + """Test callproc NotSupportedError (Lines 2413-2421).""" - cursor.execute("SELECT id, wchar_lob FROM #pytest_lob_wchar") - row = cursor.fetchone() + # This should always raise NotSupportedError (lines 2417-2420) + with pytest.raises(mssql_python.NotSupportedError, match="callproc.*is not yet implemented"): + cursor.callproc("test_proc") - assert row[0] == 1, "ID should be 1" - assert row[1] == large_wchar_data, "NVARCHAR(MAX) LOB data should match" - assert "🔥" in row[1], "Should contain emoji characters" - except Exception as e: - pytest.fail(f"LOB WCHAR column test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_lob_wchar") - db_connection.commit() +def test_setoutputsize_no_op(cursor): + """Test setoutputsize no-op behavior (Lines 2433-2438).""" + # This should be a no-op (line 2437) + cursor.setoutputsize(1000) # Should not raise any errors + cursor.setoutputsize(1000, 1) # With column parameter -def test_lob_binary_column_types(cursor, db_connection): - """Test LOB fetching specifically for BINARY/VARBINARY columns (covers lines 3384-3385)""" - try: - drop_table_if_exists(cursor, "#pytest_lob_binary") - cursor.execute(""" - CREATE TABLE #pytest_lob_binary ( - id INT, - binary_lob VARBINARY(MAX) - ) - """) - db_connection.commit() - # Create binary data large enough to trigger LOB path (>8000 bytes) - large_binary_data = bytes(range(256)) * 100 # 25.6KB of varied binary data +def test_cursor_del_cleanup_basic(db_connection): + """Test cursor cleanup and __del__ method existence (Lines 2186-2194).""" - cursor.execute("INSERT INTO #pytest_lob_binary VALUES (?, ?)", (1, large_binary_data)) - db_connection.commit() + # Test that cursor has __del__ method and basic cleanup + cursor = db_connection.cursor() - cursor.execute("SELECT id, binary_lob FROM #pytest_lob_binary") - row = cursor.fetchone() + # Test that __del__ method exists + assert hasattr(cursor, "__del__"), "Cursor should have __del__ method" - assert row[0] == 1, "ID should be 1" - assert row[1] == large_binary_data, "VARBINARY(MAX) LOB data should match" - assert len(row[1]) == 25600, "VARBINARY(MAX) should be 25600 bytes" + # Close cursor normally + cursor.close() + assert cursor.closed, "Cursor should be closed" - except Exception as e: - pytest.fail(f"LOB BINARY column test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_lob_binary") - db_connection.commit() + # Force garbage collection to potentially trigger __del__ cleanup paths + import gc + gc.collect() -def test_zero_length_complex_types(cursor, db_connection): - """Test zero-length data for complex types (covers lines 3531-3533)""" - try: - drop_table_if_exists(cursor, "#pytest_zero_length") - cursor.execute(""" - CREATE TABLE #pytest_zero_length ( - id INT, - empty_varchar VARCHAR(100), - empty_nvarchar NVARCHAR(100), - empty_binary VARBINARY(100) - ) - """) - db_connection.commit() - # Insert empty (non-NULL) values - cursor.execute("INSERT INTO #pytest_zero_length VALUES (?, ?, ?, ?)", (1, "", "", b"")) - db_connection.commit() +def test_scroll_invalid_parameters(cursor): + """Test scroll with invalid parameters.""" - cursor.execute( - "SELECT id, empty_varchar, empty_nvarchar, empty_binary FROM #pytest_zero_length" - ) - row = cursor.fetchone() + cursor.execute("SELECT 1") - assert row[0] == 1, "ID should be 1" - assert row[1] == "", "Empty VARCHAR should be empty string" - assert row[2] == "", "Empty NVARCHAR should be empty string" - assert row[3] == b"", "Empty VARBINARY should be empty bytes" + # Test invalid mode + with pytest.raises(mssql_python.ProgrammingError, match="Invalid scroll mode"): + cursor.scroll(1, mode="invalid") - except Exception as e: - pytest.fail(f"Zero-length complex types test failed: {e}") - finally: - drop_table_if_exists(cursor, "#pytest_zero_length") - db_connection.commit() + # Test non-integer value + with pytest.raises(mssql_python.ProgrammingError, match="value must be an integer"): + cursor.scroll("invalid") -def test_guid_with_nulls(cursor, db_connection): - """Test GUID type with NULL values""" +def test_row_uuid_processing_with_braces(cursor, db_connection): + """Test Row UUID processing with braced GUID strings (Lines 95-103).""" + try: - drop_table_if_exists(cursor, "#pytest_guid_nulls") + # Drop table if exists + drop_table_if_exists(cursor, "#pytest_uuid_braces") + + # Create table with UNIQUEIDENTIFIER column cursor.execute(""" - CREATE TABLE #pytest_guid_nulls ( - id INT, + CREATE TABLE #pytest_uuid_braces ( + id INT IDENTITY(1,1), guid_col UNIQUEIDENTIFIER ) - """) - db_connection.commit() + """) - # Insert NULL GUID - cursor.execute("INSERT INTO #pytest_guid_nulls VALUES (1, NULL)") - # Insert actual GUID - cursor.execute("INSERT INTO #pytest_guid_nulls VALUES (2, NEWID())") + # Insert a GUID with braces (this is how SQL Server often returns them) + test_guid = "12345678-1234-5678-9ABC-123456789ABC" + cursor.execute("INSERT INTO #pytest_uuid_braces (guid_col) VALUES (?)", [test_guid]) db_connection.commit() - cursor.execute("SELECT id, guid_col FROM #pytest_guid_nulls ORDER BY id") - rows = cursor.fetchall() + # Configure native_uuid=True to trigger UUID processing + original_setting = None + if hasattr(cursor.connection, "_settings") and "native_uuid" in cursor.connection._settings: + original_setting = cursor.connection._settings["native_uuid"] + cursor.connection._settings["native_uuid"] = True - assert len(rows) == 2, "Should have exactly 2 rows" - assert rows[0][1] is None, "First GUID should be NULL" - assert rows[1][1] is not None, "Second GUID should not be NULL" + # Fetch the data - this should trigger lines 95-103 in row.py + cursor.execute("SELECT guid_col FROM #pytest_uuid_braces") + row = cursor.fetchone() + + # The Row class should process the GUID and convert it to UUID object + # Line 99: clean_value = value.strip("{}") + # Line 100: processed_values[i] = uuid.UUID(clean_value) + assert row is not None, "Should return a row" + + # The GUID should be processed correctly regardless of brace format + guid_value = row[0] + + # Restore original setting + if original_setting is not None and hasattr(cursor.connection, "_settings"): + cursor.connection._settings["native_uuid"] = original_setting except Exception as e: - pytest.fail(f"GUID with NULLs test failed: {e}") + pytest.fail(f"UUID processing with braces test failed: {e}") finally: - drop_table_if_exists(cursor, "#pytest_guid_nulls") + drop_table_if_exists(cursor, "#pytest_uuid_braces") db_connection.commit() -def test_datetimeoffset_with_nulls(cursor, db_connection): - """Test DATETIMEOFFSET type with NULL values""" +def test_row_uuid_processing_sql_guid_type(cursor, db_connection): + """Test Row UUID processing with SQL_GUID type detection (Lines 111-119).""" + try: - drop_table_if_exists(cursor, "#pytest_dto_nulls") + # Drop table if exists + drop_table_if_exists(cursor, "#pytest_sql_guid_type") + + # Create table with UNIQUEIDENTIFIER column cursor.execute(""" - CREATE TABLE #pytest_dto_nulls ( + CREATE TABLE #pytest_sql_guid_type ( id INT, - dto_col DATETIMEOFFSET + guid_col UNIQUEIDENTIFIER ) - """) - db_connection.commit() + """) - # Insert NULL DATETIMEOFFSET - cursor.execute("INSERT INTO #pytest_dto_nulls VALUES (1, NULL)") - # Insert actual DATETIMEOFFSET - cursor.execute("INSERT INTO #pytest_dto_nulls VALUES (2, SYSDATETIMEOFFSET())") + # Insert test data + test_guid = "ABCDEF12-3456-7890-ABCD-1234567890AB" + cursor.execute( + "INSERT INTO #pytest_sql_guid_type (id, guid_col) VALUES (?, ?)", + [1, test_guid], + ) db_connection.commit() - cursor.execute("SELECT id, dto_col FROM #pytest_dto_nulls ORDER BY id") - rows = cursor.fetchall() + # Configure native_uuid=True to trigger UUID processing + original_setting = None + if hasattr(cursor.connection, "_settings") and "native_uuid" in cursor.connection._settings: + original_setting = cursor.connection._settings["native_uuid"] + cursor.connection._settings["native_uuid"] = True - assert len(rows) == 2, "Should have exactly 2 rows" - assert rows[0][1] is None, "First DATETIMEOFFSET should be NULL" - assert rows[1][1] is not None, "Second DATETIMEOFFSET should not be NULL" + # Fetch the data - this should trigger lines 111-119 in row.py + cursor.execute("SELECT id, guid_col FROM #pytest_sql_guid_type") + row = cursor.fetchone() + + # Line 111: sql_type = description[i][1] + # Line 112: if sql_type == -11: # SQL_GUID + # Line 115: processed_values[i] = uuid.UUID(value.strip("{}")) + assert row is not None, "Should return a row" + assert row[0] == 1, "ID should be 1" + + # The GUID column should be processed + guid_value = row[1] + + # Restore original setting + if original_setting is not None and hasattr(cursor.connection, "_settings"): + cursor.connection._settings["native_uuid"] = original_setting except Exception as e: - pytest.fail(f"DATETIMEOFFSET with NULLs test failed: {e}") + pytest.fail(f"UUID processing SQL_GUID type test failed: {e}") finally: - drop_table_if_exists(cursor, "#pytest_dto_nulls") + drop_table_if_exists(cursor, "#pytest_sql_guid_type") db_connection.commit() -def test_decimal_conversion_edge_cases(cursor, db_connection): - """Test DECIMAL/NUMERIC type conversion including edge cases""" +def test_row_output_converter_overflow_error(cursor, db_connection): + """Test Row output converter OverflowError handling (Lines 186-195).""" + try: - drop_table_if_exists(cursor, "#pytest_decimal_edge") + # Create a table with integer column + drop_table_if_exists(cursor, "#pytest_overflow_test") cursor.execute(""" - CREATE TABLE #pytest_decimal_edge ( + CREATE TABLE #pytest_overflow_test ( id INT, - dec_col DECIMAL(18, 4) + small_int TINYINT -- TINYINT can only hold 0-255 ) - """) - db_connection.commit() + """) - # Insert various decimal values including edge cases - test_values = [ - (1, "123.4567"), - (2, "0.0001"), - (3, "-999999999999.9999"), - (4, "999999999999.9999"), - (5, "0.0000"), - ] + # Insert a valid value first + cursor.execute("INSERT INTO #pytest_overflow_test (id, small_int) VALUES (?, ?)", [1, 100]) + db_connection.commit() - for id_val, dec_val in test_values: - cursor.execute( - "INSERT INTO #pytest_decimal_edge VALUES (?, ?)", (id_val, decimal.Decimal(dec_val)) - ) + # Create a custom output converter that will cause OverflowError + def problematic_converter(value): + if isinstance(value, int) and value == 100: + # This will cause an OverflowError when trying to convert to bytes + # by simulating a value that's too large for the byte size + raise OverflowError("int too big to convert to bytes") + return value - # Also insert NULL - cursor.execute("INSERT INTO #pytest_decimal_edge VALUES (6, NULL)") - db_connection.commit() + # Add the converter to the connection (if supported) + if hasattr(cursor.connection, "_output_converters"): + # Create a converter that will trigger the overflow + original_converters = getattr(cursor.connection, "_output_converters", {}) + cursor.connection._output_converters = {-6: problematic_converter} # TINYINT SQL type - cursor.execute("SELECT id, dec_col FROM #pytest_decimal_edge ORDER BY id") - rows = cursor.fetchall() + # Fetch the data - this should trigger lines 186-195 in row.py + cursor.execute("SELECT id, small_int FROM #pytest_overflow_test") + row = cursor.fetchone() - assert len(rows) == 6, "Should have exactly 6 rows" + # Line 188: except OverflowError as e: + # Lines 190-194: if hasattr(self._cursor, "log"): self._cursor.log(...) + # Line 195: # Keep the original value in this case + assert row is not None, "Should return a row" + assert row[0] == 1, "ID should be 1" - # Verify the values - for i, (id_val, expected_str) in enumerate(test_values): - assert rows[i][0] == id_val, f"Row {i} ID should be {id_val}" - assert rows[i][1] == decimal.Decimal( - expected_str - ), f"Row {i} decimal should match {expected_str}" + # The overflow should be handled and original value kept + assert row[1] == 100, "Value should be kept as original due to overflow handling" - # Verify NULL - assert rows[5][0] == 6, "Last row ID should be 6" - assert rows[5][1] is None, "Last decimal should be NULL" + # Restore original converters + if hasattr(cursor.connection, "_output_converters"): + cursor.connection._output_converters = original_converters except Exception as e: - pytest.fail(f"Decimal conversion edge cases test failed: {e}") + pytest.fail(f"Output converter OverflowError test failed: {e}") finally: - drop_table_if_exists(cursor, "#pytest_decimal_edge") + drop_table_if_exists(cursor, "#pytest_overflow_test") db_connection.commit() -def test_fixed_length_char_type(cursor, db_connection): - """Test SQL_CHAR (fixed-length CHAR) column processor path (Lines 3464-3467)""" - try: - cursor.execute("CREATE TABLE #pytest_char_test (id INT, char_col CHAR(10))") - cursor.execute("INSERT INTO #pytest_char_test VALUES (1, 'hello')") - cursor.execute("INSERT INTO #pytest_char_test VALUES (2, 'world')") +def test_row_output_converter_general_exception(cursor, db_connection): + """Test Row output converter general exception handling (Lines 198-206).""" - cursor.execute("SELECT char_col FROM #pytest_char_test ORDER BY id") - rows = cursor.fetchall() + # Snapshot converters up front so the finally can ALWAYS restore them, even if + # an assertion below fails. Otherwise the {12: failing_converter} entry would + # leak onto the shared connection and corrupt every later VARCHAR fetch. + had_converters_attr = hasattr(cursor.connection, "_output_converters") + original_converters = getattr(cursor.connection, "_output_converters", {}) - # CHAR pads with spaces to fixed length - assert len(rows) == 2, "Should fetch 2 rows" - assert rows[0][0].rstrip() == "hello", "First CHAR value should be 'hello'" - assert rows[1][0].rstrip() == "world", "Second CHAR value should be 'world'" + try: + # Create a table with string column + drop_table_if_exists(cursor, "#pytest_exception_test") + cursor.execute(""" + CREATE TABLE #pytest_exception_test ( + id INT, + text_col VARCHAR(50) + ) + """) - cursor.execute("DROP TABLE #pytest_char_test") - except Exception as e: - pytest.fail(f"Fixed-length CHAR test failed: {e}") + # Insert test data + cursor.execute( + "INSERT INTO #pytest_exception_test (id, text_col) VALUES (?, ?)", + [1, "test_value"], + ) + db_connection.commit() + # A converter that always raises, to exercise the "converter raised -> + # keep the original value" path. Registered under integer SQL type 12 + # (SQL_VARCHAR); after the GH #684 fix this integer key actually + # dispatches and string values arrive as UTF-16LE bytes, so we raise + # unconditionally rather than guarding on the decoded text. + def failing_converter(value): + raise RuntimeError("Custom converter error for testing") -def test_fixed_length_nchar_type(cursor, db_connection): - """Test SQL_WCHAR (fixed-length NCHAR) column processor path (Lines 3469-3472)""" - try: - cursor.execute("CREATE TABLE #pytest_nchar_test (id INT, nchar_col NCHAR(10))") - cursor.execute("INSERT INTO #pytest_nchar_test VALUES (1, N'hello')") - cursor.execute("INSERT INTO #pytest_nchar_test VALUES (2, N'世界')") # Unicode test + # Add the converter to the connection (if supported) + if had_converters_attr: + cursor.connection._output_converters = {12: failing_converter} # SQL_VARCHAR - cursor.execute("SELECT nchar_col FROM #pytest_nchar_test ORDER BY id") - rows = cursor.fetchall() + # Fetch the data - this should trigger lines 198-206 in row.py + cursor.execute("SELECT id, text_col FROM #pytest_exception_test") + row = cursor.fetchone() - # NCHAR pads with spaces to fixed length - assert len(rows) == 2, "Should fetch 2 rows" - assert rows[0][0].rstrip() == "hello", "First NCHAR value should be 'hello'" - assert rows[1][0].rstrip() == "世界", "Second NCHAR value should be '世界'" + # Line 199: except Exception as e: + # Lines 201-205: if hasattr(self._cursor, "log"): self._cursor.log(...) + # Line 206: # If conversion fails, keep the original value + assert row is not None, "Should return a row" + assert row[0] == 1, "ID should be 1" + + # The exception should be handled and original value kept + assert row[1] == "test_value", "Value should be kept as original due to exception handling" - cursor.execute("DROP TABLE #pytest_nchar_test") except Exception as e: - pytest.fail(f"Fixed-length NCHAR test failed: {e}") + pytest.fail(f"Output converter general exception test failed: {e}") + finally: + # Always restore converters (even on assertion failure) so a leaked + # converter can never poison subsequent tests on the shared connection. + if had_converters_attr: + cursor.connection._output_converters = original_converters + drop_table_if_exists(cursor, "#pytest_exception_test") + db_connection.commit() -def test_fixed_length_binary_type(cursor, db_connection): - """Test SQL_BINARY (fixed-length BINARY) column processor path (Lines 3474-3477)""" +def test_row_cursor_log_method_availability(cursor, db_connection): + """Test Row checking for cursor.log method availability (Lines 190, 201).""" + try: - cursor.execute("CREATE TABLE #pytest_binary_test (id INT, binary_col BINARY(8))") - cursor.execute("INSERT INTO #pytest_binary_test VALUES (1, 0x0102030405)") - cursor.execute("INSERT INTO #pytest_binary_test VALUES (2, 0xAABBCCDD)") + # Create test data + drop_table_if_exists(cursor, "#pytest_log_check") + cursor.execute(""" + CREATE TABLE #pytest_log_check ( + id INT, + value_col INT + ) + """) - cursor.execute("SELECT binary_col FROM #pytest_binary_test ORDER BY id") - rows = cursor.fetchall() + cursor.execute("INSERT INTO #pytest_log_check (id, value_col) VALUES (?, ?)", [1, 42]) + db_connection.commit() - # BINARY pads with zeros to fixed length (8 bytes) - assert len(rows) == 2, "Should fetch 2 rows" - assert len(rows[0][0]) == 8, "BINARY(8) should be 8 bytes" - assert len(rows[1][0]) == 8, "BINARY(8) should be 8 bytes" - # First 5 bytes should match, rest padded with zeros - assert ( - rows[0][0][:5] == b"\x01\x02\x03\x04\x05" - ), "First BINARY value should start with inserted bytes" - assert rows[0][0][5:] == b"\x00\x00\x00", "BINARY should be zero-padded" + # Test that cursor has log method or doesn't have it + # Lines 190 and 201: if hasattr(self._cursor, "log"): + cursor.execute("SELECT id, value_col FROM #pytest_log_check") + row = cursor.fetchone() - cursor.execute("DROP TABLE #pytest_binary_test") - except Exception as e: - pytest.fail(f"Fixed-length BINARY test failed: {e}") - # The hasattr check should complete without error - # This covers the conditional log method availability checks + assert row is not None, "Should return a row" + assert row[0] == 1, "ID should be 1" + assert row[1] == 42, "Value should be 42" + + # The hasattr check should complete without error + # This covers the conditional log method availability checks except Exception as e: pytest.fail(f"Cursor log method availability test failed: {e}") diff --git a/tests/test_008_auth.py b/tests/test_008_auth.py index 6fd2d6574..5c160b2a4 100644 --- a/tests/test_008_auth.py +++ b/tests/test_008_auth.py @@ -1116,17 +1116,6 @@ def test_credential_kwargs_persisted_for_user_assigned_msi(self, mock_ddbc_conn) assert "UID=" not in conn.connection_str conn.close() - @patch("mssql_python.connection.ddbc_bindings.Connection") - def test_credential_kwargs_none_for_system_assigned_msi(self, mock_ddbc_conn): - """System-assigned MSI: no UID → _credential_kwargs stays None.""" - mock_ddbc_conn.return_value = MagicMock() - from mssql_python import connect - - conn = connect("Server=test;Database=testdb;Authentication=ActiveDirectoryMSI") - assert conn._auth_type == "msi" - assert conn._credential_kwargs is None - conn.close() - @patch("mssql_python.connection.ddbc_bindings.Connection") def test_credential_kwargs_none_for_non_msi_auth(self, mock_ddbc_conn): """Non-MSI auth types must not pick up credential_kwargs even if diff --git a/tests/test_010_connection_string_parser.py b/tests/test_010_connection_string_parser.py index d632092d1..604677eda 100644 --- a/tests/test_010_connection_string_parser.py +++ b/tests/test_010_connection_string_parser.py @@ -328,14 +328,15 @@ def test_error_multiple_issues_collected(self): assert "Empty value for keyword 'database'" in errors_str assert "Empty keyword" in errors_str - def test_error_unknown_keyword_with_allowlist(self): + @pytest.mark.parametrize("keyword", ["UnknownParam", "UnsupportedParam", "MadeUpKeyword"]) + def test_error_unknown_keyword_with_allowlist(self, keyword): """Test that unknown keywords are flagged when validation is enabled.""" parser = _ConnectionStringParser(validate_keywords=True) with pytest.raises(ConnectionStringParseError) as exc_info: - parser._parse("Server=localhost;UnknownParam=value") + parser._parse(f"Server=localhost;{keyword}=value") - assert "Unknown keyword 'unknownparam'" in str(exc_info.value) + assert f"Unknown keyword '{keyword.lower()}'" in str(exc_info.value) def test_error_multiple_unknown_keywords(self): """Test that multiple unknown keywords are all flagged.""" diff --git a/tests/test_012_connection_string_integration.py b/tests/test_012_connection_string_integration.py index d03c9308b..066a0b3dd 100644 --- a/tests/test_012_connection_string_integration.py +++ b/tests/test_012_connection_string_integration.py @@ -44,17 +44,6 @@ def test_parse_filter_build_simple(self): assert "Encrypt=yes" in result assert "APP=MSSQL-Python" in result - def test_parse_filter_build_with_unsupported_param(self): - """Test that unsupported parameters are flagged as errors with allowlist.""" - # Parse with allowlist - parser = _ConnectionStringParser(validate_keywords=True) - - # Should raise error for unknown keyword - with pytest.raises(ConnectionStringParseError) as exc_info: - parser._parse("Server=localhost;Database=mydb;UnsupportedParam=value") - - assert "Unknown keyword 'unsupportedparam'" in str(exc_info.value) - def test_parse_filter_build_with_braced_values(self): """Test complete flow with braced values and special characters.""" # Parse @@ -182,16 +171,6 @@ def test_parse_error_incomplete_specification(self): assert "Incomplete specification" in str(exc_info.value) assert "'server localhost'" in str(exc_info.value).lower() - def test_parse_error_unclosed_brace(self): - """Test that unclosed braces raise errors.""" - parser = _ConnectionStringParser() - - # Unclosed brace raises error - with pytest.raises(ConnectionStringParseError) as exc_info: - parser._parse("PWD={unclosed;Server=localhost") - - assert "Unclosed braced value" in str(exc_info.value) - def test_parse_error_duplicate_keywords(self): """Test that duplicate keywords raise errors.""" parser = _ConnectionStringParser() @@ -276,24 +255,6 @@ def test_multiple_errors_collected(self): assert "Incomplete specification" in str(exc_info.value) assert "Duplicate keyword" in str(exc_info.value) - def test_parser_without_allowlist_accepts_unknown(self): - """Test that parser without allowlist accepts unknown keywords.""" - parser = _ConnectionStringParser() # No allowlist - - # Should parse successfully even with unknown keywords - result = parser._parse("Server=localhost;MadeUpKeyword=value") - assert result == {"server": "localhost", "madeupkeyword": "value"} - - def test_parser_with_allowlist_rejects_unknown(self): - """Test that parser with allowlist rejects unknown keywords.""" - parser = _ConnectionStringParser(validate_keywords=True) - - # Should raise error for unknown keyword - with pytest.raises(ConnectionStringParseError) as exc_info: - parser._parse("Server=localhost;MadeUpKeyword=value") - - assert "Unknown keyword 'madeupkeyword'" in str(exc_info.value) - class TestConnectAPIIntegration: """Integration tests for the connect() API with connection string validation.""" diff --git a/tests/test_013_encoding_decoding.py b/tests/test_013_encoding_decoding.py index 5559aadcf..29a9ffa50 100644 --- a/tests/test_013_encoding_decoding.py +++ b/tests/test_013_encoding_decoding.py @@ -3312,34 +3312,6 @@ def test_big5_encoding_chinese_traditional(db_connection): cursor.close() -def test_euc_kr_encoding_korean(db_connection): - """Test EUC-KR encoding for Korean characters.""" - db_connection.setencoding(encoding="euc-kr", ctype=SQL_CHAR) - db_connection.setdecoding(SQL_CHAR, encoding="euc-kr", ctype=SQL_CHAR) - - cursor = db_connection.cursor() - try: - cursor.execute("CREATE TABLE #test_euckr (id INT, data VARCHAR(200))") - - korean_tests = [ - ("안녕하세요", "Hello"), - ("서울", "Seoul"), - ("한글", "Hangul"), - ] - - for korean_text, meaning in korean_tests: - if is_encoding_compatible_with_data("euc-kr", korean_text): - cursor.execute("DELETE FROM #test_euckr") - cursor.execute("INSERT INTO #test_euckr VALUES (?, ?)", 1, korean_text) - cursor.execute("SELECT data FROM #test_euckr WHERE id = 1") - result = cursor.fetchone() - else: - pass - - finally: - cursor.close() - - # ==================================================================================== # SINGLE-BYTE ENCODING TESTS (Latin-1, CP1252, ISO-8859-*, etc.) # ==================================================================================== diff --git a/tests/test_014_ddbc_bindings_coverage.py b/tests/test_014_ddbc_bindings_coverage.py deleted file mode 100644 index 65f99c7b8..000000000 --- a/tests/test_014_ddbc_bindings_coverage.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -Additional coverage tests for ddbc_bindings.h UTF conversion edge cases. - -This test file focuses on specific uncovered paths in: -- IsValidUnicodeScalar (lines 74-78) -- SQLWCHARToWString UTF-32 path (lines 120-130) -- WStringToSQLWCHAR UTF-32 path (lines 159-167) -- WideToUTF8 Unix path (lines 415-453) -- Utf8ToWString decodeUtf8 lambda (lines 462-530) -""" - -import pytest -import sys -import platform - - -class TestIsValidUnicodeScalar: - """Test the IsValidUnicodeScalar function (ddbc_bindings.h lines 74-78).""" - - @pytest.mark.parametrize( - "char", - [ - "\u0000", # NULL - "\u007f", # Last ASCII - "\u0080", # First 2-byte - "\u07ff", # Last 2-byte - "\u0800", # First 3-byte - "\ud7ff", # Just before surrogate range - "\ue000", # Just after surrogate range - "\uffff", # Last BMP - "\U00010000", # First supplementary - "\U0010ffff", # Last valid Unicode - ], - ) - def test_valid_scalar_values(self, char): - """Test valid Unicode scalar values using Binary() for faster execution.""" - from mssql_python.type import Binary - - # Test through Binary() which exercises the conversion code - result = Binary(char) - assert len(result) > 0 - - def test_boundary_codepoints(self): - """Test boundary code points including max valid and surrogate range.""" - from mssql_python.type import Binary - - # Test valid maximum (line 76) - max_valid = "\U0010ffff" - result = Binary(max_valid) - assert len(result) > 0 - - # Test surrogate boundaries (line 77) - before_surrogate = "\ud7ff" - result = Binary(before_surrogate) - assert len(result) > 0 - - after_surrogate = "\ue000" - result = Binary(after_surrogate) - assert len(result) > 0 - - # Invalid UTF-8 that would decode to > 0x10FFFF - invalid_above_max = b"\xf4\x90\x80\x80" # Would be 0x110000 - result = invalid_above_max.decode("utf-8", errors="replace") - assert len(result) > 0 - - -@pytest.mark.skipif(platform.system() == "Windows", reason="Tests Unix-specific UTF-32 path") -class TestUTF32ConversionPaths: - """Test UTF-32 conversion paths for SQLWCHARToWString and WStringToSQLWCHAR (lines 120-130, 159-167).""" - - @pytest.mark.parametrize( - "test_str", ["ASCII", "Hello", "Café", "中文", "中文测试", "😀", "😀🌍", "\U0010ffff"] - ) - def test_utf32_valid_scalars(self, test_str): - """Test UTF-32 path with valid scalar values using Binary() for faster execution.""" - from mssql_python.type import Binary - - # Valid scalars should be copied directly - result = Binary(test_str) - assert len(result) > 0 - # Verify round-trip - decoded = result.decode("utf-8") - assert decoded == test_str - - @pytest.mark.parametrize( - "test_input,description", - [ - (b"Test\xed\xa0\x80", "high_surrogate_at_end"), # UTF-8 encoded surrogate - (b"\xed\xb0\x80Test", "low_surrogate_at_start"), # UTF-8 encoded surrogate - (b"A\xed\xa0\x80B", "high_surrogate_in_middle"), # UTF-8 encoded surrogate - (b"\xed\xb0\x80C", "low_surrogate_at_start2"), # UTF-8 encoded surrogate - ], - ) - def test_utf32_invalid_scalars(self, test_input, description): - """Test UTF-32 path with invalid scalar values (surrogates) using Binary().""" - from mssql_python.type import Binary - - # Test with raw bytes containing invalid UTF-8 sequences (encoded surrogates) - # Binary() should handle these gracefully (reject or replace with U+FFFD) - try: - result = Binary(test_input) - assert len(result) > 0 - except (UnicodeDecodeError, UnicodeEncodeError, ValueError): - # It's acceptable to reject invalid UTF-8 sequences - pass - - -@pytest.mark.skipif(platform.system() == "Windows", reason="Tests Unix-specific WideToUTF8 path") -class TestWideToUTF8UnixPath: - """Test WideToUTF8 Unix path (lines 415-453).""" - - def test_all_utf8_byte_lengths(self): - """Test 1-4 byte UTF-8 encoding (lines 424-445).""" - from mssql_python.type import Binary - - # Combined test for all UTF-8 byte lengths - all_tests = [ - # 1-byte (ASCII, lines 424-427) - ("A", b"A"), - ("0", b"0"), - (" ", b" "), - ("~", b"~"), - ("\x00", b"\x00"), - ("\x7f", b"\x7f"), - # 2-byte (lines 428-432) - ("\u0080", b"\xc2\x80"), # Minimum 2-byte - ("\u00a9", b"\xc2\xa9"), # Copyright © - ("\u00ff", b"\xc3\xbf"), # ÿ - ("\u07ff", b"\xdf\xbf"), # Maximum 2-byte - # 3-byte (lines 433-438) - ("\u0800", b"\xe0\xa0\x80"), # Minimum 3-byte - ("\u4e2d", b"\xe4\xb8\xad"), # 中 - ("\u20ac", b"\xe2\x82\xac"), # € - ("\uffff", b"\xef\xbf\xbf"), # Maximum 3-byte - # 4-byte (lines 439-445) - ("\U00010000", b"\xf0\x90\x80\x80"), # Minimum 4-byte - ("\U0001f600", b"\xf0\x9f\x98\x80"), # 😀 - ("\U0001f30d", b"\xf0\x9f\x8c\x8d"), # 🌍 - ("\U0010ffff", b"\xf4\x8f\xbf\xbf"), # Maximum Unicode - ] - - for char, expected in all_tests: - result = Binary(char) - assert result == expected, f"UTF-8 encoding failed for {char!r}" - - -@pytest.mark.skipif(platform.system() == "Windows", reason="Tests Unix-specific Utf8ToWString path") -class TestUtf8ToWStringUnixPath: - """Test Utf8ToWString decodeUtf8 lambda (lines 462-530).""" - - @pytest.mark.parametrize( - "test_str,expected", - [ - ("HelloWorld123", b"HelloWorld123"), # Pure ASCII - ("Hello😀", "Hello😀".encode("utf-8")), # Mixed ASCII + emoji - ], - ) - def test_fast_path_ascii(self, test_str, expected): - """Test fast path for ASCII-only prefix (lines 539-542).""" - from mssql_python.type import Binary - - result = Binary(test_str) - assert result == expected - - def test_1byte_and_2byte_decode(self): - """Test 1-byte and 2-byte sequence decoding (lines 472-488).""" - from mssql_python.type import Binary - - # 1-byte decode tests (lines 472-475) - one_byte_tests = [ - (b"A", "A"), - (b"Hello", "Hello"), - (b"\x00\x7f", "\x00\x7f"), - ] - - for utf8_bytes, expected in one_byte_tests: - result = Binary(expected) - assert result == utf8_bytes - - # 2-byte valid decode tests (lines 481-484) - two_byte_tests = [ - (b"\xc2\x80", "\u0080"), - (b"\xc2\xa9", "\u00a9"), - (b"\xdf\xbf", "\u07ff"), - ] - - for utf8_bytes, expected in two_byte_tests: - result = utf8_bytes.decode("utf-8") - assert result == expected - encoded = Binary(expected) - assert encoded == utf8_bytes - - # 2-byte invalid tests - invalid_2byte = b"\xc2\x00" # Invalid continuation (lines 477-480) - result = invalid_2byte.decode("utf-8", errors="replace") - assert "\ufffd" in result, "Invalid 2-byte should produce replacement char" - - overlong_2byte = b"\xc0\x80" # Overlong encoding (lines 486-487) - result = overlong_2byte.decode("utf-8", errors="replace") - assert "\ufffd" in result, "Overlong 2-byte should produce replacement char" - - def test_3byte_and_4byte_decode_paths(self): - """Test 3-byte and 4-byte sequence decoding paths (lines 490-527).""" - from mssql_python.type import Binary - - # 3-byte valid decode tests (lines 499-502) - valid_3byte = [ - (b"\xe0\xa0\x80", "\u0800"), - (b"\xe4\xb8\xad", "\u4e2d"), # 中 - (b"\xed\x9f\xbf", "\ud7ff"), # Before surrogates - (b"\xee\x80\x80", "\ue000"), # After surrogates - ] - - for utf8_bytes, expected in valid_3byte: - result = utf8_bytes.decode("utf-8") - assert result == expected - encoded = Binary(expected) - assert encoded == utf8_bytes - - # 4-byte valid decode tests (lines 519-522) - valid_4byte = [ - (b"\xf0\x90\x80\x80", "\U00010000"), - (b"\xf0\x9f\x98\x80", "\U0001f600"), # 😀 - (b"\xf4\x8f\xbf\xbf", "\U0010ffff"), - ] - - for utf8_bytes, expected in valid_4byte: - result = utf8_bytes.decode("utf-8") - assert result == expected - encoded = Binary(expected) - assert encoded == utf8_bytes - - # Invalid continuation bytes tests - invalid_tests = [ - # 3-byte invalid (lines 492-495) - b"\xe0\x00\x80", # Second byte invalid - b"\xe0\xa0\x00", # Third byte invalid - # 4-byte invalid (lines 512-514) - b"\xf0\x00\x80\x80", # Second byte invalid - b"\xf0\x90\x00\x80", # Third byte invalid - b"\xf0\x90\x80\x00", # Fourth byte invalid - ] - - for test_bytes in invalid_tests: - result = test_bytes.decode("utf-8", errors="replace") - assert ( - "\ufffd" in result - ), f"Invalid sequence {test_bytes.hex()} should produce replacement" - - # Surrogate encoding rejection (lines 500-503) - for test_bytes in [b"\xed\xa0\x80", b"\xed\xbf\xbf"]: - result = test_bytes.decode("utf-8", errors="replace") - assert len(result) > 0 - - # Overlong encoding rejection (lines 504-505, 524-525) - for test_bytes in [b"\xe0\x80\x80", b"\xf0\x80\x80\x80"]: - result = test_bytes.decode("utf-8", errors="replace") - assert "\ufffd" in result, f"Overlong {test_bytes.hex()} should produce replacement" - - # Out-of-range rejection (lines 524-525) - out_of_range = b"\xf4\x90\x80\x80" # 0x110000 - result = out_of_range.decode("utf-8", errors="replace") - assert len(result) > 0, "Out-of-range 4-byte should produce some output" - - def test_invalid_sequence_fallback(self): - """Test invalid sequence fallback (lines 528-529).""" - # Invalid start bytes - invalid_starts = [ - b"\xf8\x80\x80\x80", # Invalid start byte - b"\xfc\x80\x80\x80", - b"\xfe\x80\x80\x80", - b"\xff", - ] - - for test_bytes in invalid_starts: - result = test_bytes.decode("utf-8", errors="replace") - assert ( - "\ufffd" in result - ), f"Invalid sequence {test_bytes.hex()} should produce replacement" - - -class TestUtf8ToWStringAlwaysPush: - """Test that decodeUtf8 always pushes the result (lines 547-550).""" - - def test_always_push_result(self): - """Test that decoded characters are always pushed, including legitimate U+FFFD.""" - from mssql_python.type import Binary - - # Test legitimate U+FFFD in input - legitimate_fffd = "Test\ufffdValue" - result = Binary(legitimate_fffd) - expected = legitimate_fffd.encode("utf-8") # Should encode to valid UTF-8 - assert result == expected, "Legitimate U+FFFD should be preserved" - - # Test that it decodes back correctly - decoded = result.decode("utf-8") - assert decoded == legitimate_fffd, "Round-trip should preserve U+FFFD" - - # Multiple U+FFFD characters - multi_fffd = "\ufffd\ufffd\ufffd" - result = Binary(multi_fffd) - expected = multi_fffd.encode("utf-8") - assert result == expected, "Multiple U+FFFD should be preserved" - - -class TestEdgeCases: - """Test edge cases and error paths.""" - - @pytest.mark.parametrize( - "test_input,expected,description", - [ - ("", b"", "Empty string"), - ("\x00", b"\x00", "NULL character"), - ("A\x00B", b"A\x00B", "NULL in middle"), - ("Valid\ufffdText", "Valid\ufffdText", "Mixed valid/U+FFFD"), - ("A\u00a9\u4e2d\U0001f600", "A\u00a9\u4e2d\U0001f600", "All UTF-8 ranges"), - ], - ) - def test_special_characters(self, test_input, expected, description): - """Test special character handling including NULL and replacement chars.""" - from mssql_python.type import Binary - - result = Binary(test_input) - if isinstance(expected, str): - # For strings, encode and compare - assert result == expected.encode("utf-8"), f"{description} should work" - # Verify round-trip - decoded = result.decode("utf-8") - assert decoded == test_input - else: - assert result == expected, f"{description} should produce expected bytes" - - @pytest.mark.parametrize( - "char,count,expected_len", - [ - ("A", 1000, 1000), # 1-byte chars - reduced from 10000 for speed - ("中", 500, 1500), # 3-byte chars - reduced from 5000 for speed - ("😀", 200, 800), # 4-byte chars - reduced from 2000 for speed - ], - ) - def test_long_strings(self, char, count, expected_len): - """Test long strings with reduced size for faster execution.""" - from mssql_python.type import Binary - - long_str = char * count - result = Binary(long_str) - assert len(result) == expected_len, f"Long {char!r} string should encode correctly" diff --git a/tests/test_015_pyformat_parameters.py b/tests/test_015_pyformat_parameters.py index 918041cf6..a1e2af235 100644 --- a/tests/test_015_pyformat_parameters.py +++ b/tests/test_015_pyformat_parameters.py @@ -1302,14 +1302,6 @@ def test_window_function_query(self): class TestBackwardCompatibility: """Test that qmark style (existing functionality) still works perfectly.""" - def test_qmark_single_param(self): - """Test backward compatibility: single qmark parameter.""" - sql = "SELECT * FROM users WHERE id = ?" - params = (42,) - result_sql, result_params = detect_and_convert_parameters(sql, params) - assert result_sql == sql - assert result_params == params - def test_qmark_multiple_params(self): """Test backward compatibility: multiple qmark parameters.""" sql = "INSERT INTO users (name, age, city) VALUES (?, ?, ?)" @@ -1326,13 +1318,6 @@ def test_qmark_with_list(self): assert result_sql == sql assert result_params == params - def test_qmark_no_params(self): - """Test backward compatibility: query with no parameters.""" - sql = "SELECT * FROM users" - result_sql, result_params = detect_and_convert_parameters(sql, None) - assert result_sql == sql - assert result_params is None - def test_qmark_complex_query(self): """Test backward compatibility: complex query with qmark.""" sql = """ @@ -2050,16 +2035,6 @@ def test_empty_parameter_name_in_pyformat(self, db_connection): assert result[0] == 42 cursor.close() - def test_parameter_wrapping_with_none_value(self, db_connection): - """Test that None values are properly wrapped""" - cursor = db_connection.cursor() - - # None as single parameter should be wrapped to (None,) - cursor.execute("SELECT ?", None) - result = cursor.fetchone() - assert result[0] is None - cursor.close() - def test_very_long_parameter_value(self, db_connection): """Test parameter with very long string value""" cursor = db_connection.cursor() @@ -2235,15 +2210,6 @@ def test_execute_with_single_value_wrapping(self, db_connection): cursor.close() - def test_execute_normal_tuple_not_unwrapped(self, db_connection): - """Test that normal single-item tuple stays as-is (lines 1253-1254).""" - cursor = db_connection.cursor() - # (42,) should stay as (42,) not unwrap to 42 - cursor.execute("SELECT ?", (42,)) - result = cursor.fetchone() - assert result[0] == 42 - cursor.close() - def test_execute_with_list_conversion(self, db_connection): """Test list parameter conversion (line 1263).""" cursor = db_connection.cursor() diff --git a/tests/test_test_definitions.py b/tests/test_test_definitions.py new file mode 100644 index 000000000..5fcde9041 --- /dev/null +++ b/tests/test_test_definitions.py @@ -0,0 +1,25 @@ +"""Guard against tests silently disappearing when a later definition replaces them.""" + +import ast +from pathlib import Path + + +def test_test_names_are_unique_within_each_scope(): + duplicates = [] + for path in sorted(Path(__file__).parent.rglob("test_*.py")): + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + scopes = [tree] + [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] + for scope in scopes: + definitions = {} + for node in scope.body: + if isinstance( + node, (ast.FunctionDef, ast.AsyncFunctionDef) + ) and node.name.startswith("test_"): + if node.name in definitions: + duplicates.append( + f"{path.name}:{node.lineno}: {node.name} replaces line " + f"{definitions[node.name]} in {getattr(scope, 'name', '')}" + ) + definitions[node.name] = node.lineno + + assert not duplicates, "\n".join(duplicates) From 578844841f00384770ed80ab0f3a9a1c0844c98f Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 5 Oct 2026 16:28:33 +0530 Subject: [PATCH 2/4] FIX: Detect duplicate tests inside scope-level control flow Keep nested namespaces separate and cover module/class control-flow cases. Document the parser-only localhost scanner suppression without changing test inputs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_010_connection_string_parser.py | 3 +- tests/test_test_definitions.py | 118 ++++++++++++++++++--- 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/tests/test_010_connection_string_parser.py b/tests/test_010_connection_string_parser.py index 604677eda..ebe9f0bf2 100644 --- a/tests/test_010_connection_string_parser.py +++ b/tests/test_010_connection_string_parser.py @@ -334,7 +334,8 @@ def test_error_unknown_keyword_with_allowlist(self, keyword): parser = _ConnectionStringParser(validate_keywords=True) with pytest.raises(ConnectionStringParseError) as exc_info: - parser._parse(f"Server=localhost;{keyword}=value") + # Parser-only input: no connection is opened. + parser._parse(f"Server=localhost;{keyword}=value") # DevSkim: ignore DS162092 assert f"Unknown keyword '{keyword.lower()}'" in str(exc_info.value) diff --git a/tests/test_test_definitions.py b/tests/test_test_definitions.py index 5fcde9041..b6325a105 100644 --- a/tests/test_test_definitions.py +++ b/tests/test_test_definitions.py @@ -2,24 +2,116 @@ import ast from pathlib import Path +from textwrap import indent + +import pytest + + +def _scope_test_definitions(scope): + """Descend through control flow, but not into a new Python namespace.""" + for node in ast.iter_child_nodes(scope): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name.startswith("test_"): + yield node + elif not isinstance(node, (ast.ClassDef, ast.Lambda)): + yield from _scope_test_definitions(node) + + +def _duplicate_test_names(tree): + duplicates = [] + scopes = [tree] + [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] + for scope in scopes: + definitions = {} + for node in _scope_test_definitions(scope): + if node.name in definitions: + duplicates.append( + f"{node.lineno}: {node.name} replaces line " + f"{definitions[node.name]} in {getattr(scope, 'name', '')}" + ) + definitions[node.name] = node.lineno + return duplicates def test_test_names_are_unique_within_each_scope(): duplicates = [] for path in sorted(Path(__file__).parent.rglob("test_*.py")): tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) - scopes = [tree] + [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] - for scope in scopes: - definitions = {} - for node in scope.body: - if isinstance( - node, (ast.FunctionDef, ast.AsyncFunctionDef) - ) and node.name.startswith("test_"): - if node.name in definitions: - duplicates.append( - f"{path.name}:{node.lineno}: {node.name} replaces line " - f"{definitions[node.name]} in {getattr(scope, 'name', '')}" - ) - definitions[node.name] = node.lineno + duplicates.extend(f"{path.name}:{duplicate}" for duplicate in _duplicate_test_names(tree)) assert not duplicates, "\n".join(duplicates) + + +@pytest.mark.parametrize("in_class", [False, True], ids=["module", "class"]) +@pytest.mark.parametrize( + "block", + [ + "if enabled:\n{test}", + "if enabled:\n pass\nelse:\n{test}", + "try:\n{test}\nexcept Exception:\n pass", + "try:\n pass\nexcept Exception:\n{test}", + "try:\n pass\nfinally:\n{test}", + "with context:\n{test}", + "for item in items:\n{test}", + "while enabled:\n{test}", + "if enabled:\n with context:\n{test}", + ], + ids=["if", "else", "try", "except", "finally", "with", "for", "while", "nested"], +) +def test_duplicate_guard_checks_control_flow(block, in_class): + nested = " " if block.startswith("if enabled:\n with") else "" + definition = "async def test_example():\n pass\n" + source = block.format(test=indent(definition, " " + nested)) + source += "\ndef test_example():\n pass\n" + scope_name = "TestExample" if in_class else "" + if in_class: + source = "class TestExample:\n" + indent(source, " ") + + tree = ast.parse(source) + definitions = sorted( + node.lineno + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ) + assert _duplicate_test_names(tree) == [ + f"{definitions[1]}: test_example replaces line {definitions[0]} in {scope_name}" + ] + + +def test_duplicate_guard_keeps_namespaces_separate(): + tree = ast.parse(""" +def test_example(): + def test_example(): + pass + +def helper(): + def test_example(): + pass + return lambda: None + +class TestFirst: + def test_example(self): + pass + + class TestNested: + def test_example(self): + pass + +if enabled: + class TestSecond: + async def test_example(self): + pass +""") + assert _duplicate_test_names(tree) == [] + + +def test_duplicate_guard_checks_classes_inside_control_flow(): + tree = ast.parse(""" +if enabled: + class TestExample: + def test_example(self): + pass + with context: + def test_example(self): + pass +""") + assert _duplicate_test_names(tree) == ["7: test_example replaces line 4 in TestExample"] From c17b980f7f4a54f9318f5416a40cde42fef1e7bf Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 5 Oct 2026 17:17:21 +0530 Subject: [PATCH 3/4] FIX: Guard duplicate test classes and restore Korean VARCHAR coverage Detect Test-prefixed class redefinitions within module and class control flow. Restore the shadowed Korean SQL_CHAR/EUC-KR scenario with isolated connection state and byte/Unicode storage assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_013_encoding_decoding.py | 27 +++++++++++++++ tests/test_test_definitions.py | 53 ++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/tests/test_013_encoding_decoding.py b/tests/test_013_encoding_decoding.py index 29a9ffa50..d23b726b8 100644 --- a/tests/test_013_encoding_decoding.py +++ b/tests/test_013_encoding_decoding.py @@ -6837,6 +6837,33 @@ def test_shift_jis_encoding_japanese(db_connection): cursor.close() +def test_euc_kr_encoding_korean_varchar(conn_str): + """Preserve Korean VARCHAR storage with the legacy SQL_CHAR/EUC-KR settings.""" + korean_strings = ["안녕하세요", "서울", "한글"] + with connect(conn_str) as conn: + with pytest.warns(UserWarning, match="UTF-16LE.*SQL_C_WCHAR"): + conn.setencoding(encoding="euc-kr", ctype=SQL_CHAR) + conn.setdecoding(SQL_CHAR, encoding="euc-kr", ctype=SQL_CHAR) + + with conn.cursor() as cursor: + # CP949 contains the EUC-KR characters used here; avoid the database's default code page. + cursor.execute( + "CREATE TABLE #test_euc_kr_varchar " + "(id INT, data VARCHAR(200) COLLATE Korean_Wansung_CI_AS)" + ) + for index, text in enumerate(korean_strings): + cursor.execute("INSERT INTO #test_euc_kr_varchar VALUES (?, ?)", index, text) + + cursor.execute( + "SELECT data, CONVERT(VARBINARY(200), data), CONVERT(NVARCHAR(200), data) " + "FROM #test_euc_kr_varchar ORDER BY id" + ) + rows = cursor.fetchall() + # Keep the narrow fetch, but verify fidelity without Windows ANSI code-page conversion. + assert [row[1] for row in rows] == [text.encode("euc-kr") for text in korean_strings] + assert [row[2] for row in rows] == korean_strings + + def test_euc_kr_encoding_korean(db_connection): """Test EUC-KR encoding/decoding round-trip with Korean characters using NVARCHAR.""" # Set encoding for INSERT (EUC-KR) and decoding for SELECT (UTF-16LE from NVARCHAR) diff --git a/tests/test_test_definitions.py b/tests/test_test_definitions.py index b6325a105..2dfe0e308 100644 --- a/tests/test_test_definitions.py +++ b/tests/test_test_definitions.py @@ -13,7 +13,10 @@ def _scope_test_definitions(scope): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if node.name.startswith("test_"): yield node - elif not isinstance(node, (ast.ClassDef, ast.Lambda)): + elif isinstance(node, ast.ClassDef): + if node.name.startswith("Test"): + yield node + elif not isinstance(node, ast.Lambda): yield from _scope_test_definitions(node) @@ -42,6 +45,14 @@ def test_test_names_are_unique_within_each_scope(): @pytest.mark.parametrize("in_class", [False, True], ids=["module", "class"]) +@pytest.mark.parametrize( + "definition, name", + [ + ("async def test_example():\n pass\n", "test_example"), + ("class TestExample:\n def test_method(self):\n pass\n", "TestExample"), + ], + ids=["function", "test-class"], +) @pytest.mark.parametrize( "block", [ @@ -57,23 +68,44 @@ def test_test_names_are_unique_within_each_scope(): ], ids=["if", "else", "try", "except", "finally", "with", "for", "while", "nested"], ) -def test_duplicate_guard_checks_control_flow(block, in_class): +def test_duplicate_guard_checks_control_flow(block, in_class, definition, name): nested = " " if block.startswith("if enabled:\n with") else "" - definition = "async def test_example():\n pass\n" source = block.format(test=indent(definition, " " + nested)) - source += "\ndef test_example():\n pass\n" - scope_name = "TestExample" if in_class else "" + source += "\n" + definition + scope_name = "TestContainer" if in_class else "" if in_class: - source = "class TestExample:\n" + indent(source, " ") + source = "class TestContainer:\n" + indent(source, " ") tree = ast.parse(source) definitions = sorted( node.lineno for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and node.name == name ) assert _duplicate_test_names(tree) == [ - f"{definitions[1]}: test_example replaces line {definitions[0]} in {scope_name}" + f"{definitions[1]}: {name} replaces line {definitions[0]} in {scope_name}" + ] + + +@pytest.mark.parametrize("in_class", [False, True], ids=["module", "class"]) +def test_duplicate_guard_checks_redefined_test_classes(in_class): + source = ( + "class TestExample:\n" + " def test_first(self):\n" + " pass\n" + "class TestExample:\n" + " def test_second(self):\n" + " pass\n" + ) + if in_class: + source = "class TestContainer:\n" + indent(source, " ") + assert _duplicate_test_names(ast.parse(source)) == [ + ( + "5: TestExample replaces line 2 in TestContainer" + if in_class + else "4: TestExample replaces line 1 in " + ) ] @@ -86,6 +118,8 @@ def test_example(): def helper(): def test_example(): pass + class TestNested: + pass return lambda: None class TestFirst: @@ -100,6 +134,9 @@ def test_example(self): class TestSecond: async def test_example(self): pass + class TestNested: + def test_example(self): + pass """) assert _duplicate_test_names(tree) == [] From ef9bc0fe19601be3ced66196dda1124721e342a9 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 6 Oct 2026 13:12:44 +0530 Subject: [PATCH 4/4] Fix review gaps in duplicate guard and RHEL test prerequisites Recognize directly inherited unittest TestCase classes and cover scope isolation. Install glibc-gconv-extra in both RHEL CI jobs so ODBC can convert Korean CP949 results without empty batch data; keep all fidelity assertions unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/prompts/setup-dev-env.prompt.md | 6 +++- eng/pipelines/pr-validation-pipeline.yml | 6 ++-- tests/test_test_definitions.py | 45 ++++++++++++++++++------ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/prompts/setup-dev-env.prompt.md b/.github/prompts/setup-dev-env.prompt.md index ce126008b..7be799a2d 100644 --- a/.github/prompts/setup-dev-env.prompt.md +++ b/.github/prompts/setup-dev-env.prompt.md @@ -228,9 +228,13 @@ sudo apt-get install -y cmake build-essential python3-dev **If missing (RHEL/CentOS/Fedora):** ```bash -sudo dnf install -y cmake gcc-c++ python3-devel +sudo dnf install -y cmake gcc-c++ python3-devel glibc-gconv-extra ``` +`glibc-gconv-extra` supplies ODBC's iconv conversions for legacy code pages such +as Korean CP949. Minimal UBI/RHEL images omit these modules; install them before +running the encoding tests on either x64 or ARM64. + **If missing (SUSE):** ```bash sudo zypper install -y cmake gcc-c++ python3-devel diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 99507c8e6..2107a5bfe 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -1338,7 +1338,8 @@ jobs: # Install Python 3.12 (available in RHEL 9.4+) and development tools # Note: curl and wget omitted to avoid conflicts with curl-minimal - dnf install -y python3.12 python3.12-pip python3.12-devel python3.12-libs gnupg2 glibc-devel kernel-headers + # ODBC needs the extra iconv modules for legacy code pages such as Korean CP949. + dnf install -y python3.12 python3.12-pip python3.12-devel python3.12-libs gnupg2 glibc-devel glibc-gconv-extra kernel-headers dnf install -y gcc gcc-c++ make binutils cmake # If that doesn't work, try installing from different repositories if ! which gcc; then @@ -1551,7 +1552,8 @@ jobs: # Install Python 3.12 (available in RHEL 9.4+) and development tools # Note: curl and wget omitted to avoid conflicts with curl-minimal - dnf install -y python3.12 python3.12-pip python3.12-devel python3.12-libs gnupg2 glibc-devel kernel-headers + # ODBC needs the extra iconv modules for legacy code pages such as Korean CP949. + dnf install -y python3.12 python3.12-pip python3.12-devel python3.12-libs gnupg2 glibc-devel glibc-gconv-extra kernel-headers dnf install -y gcc gcc-c++ make binutils cmake # If that doesn't work, try installing from different repositories if ! which gcc; then diff --git a/tests/test_test_definitions.py b/tests/test_test_definitions.py index 2dfe0e308..89a379059 100644 --- a/tests/test_test_definitions.py +++ b/tests/test_test_definitions.py @@ -14,7 +14,15 @@ def _scope_test_definitions(scope): if node.name.startswith("test_"): yield node elif isinstance(node, ast.ClassDef): - if node.name.startswith("Test"): + if node.name.startswith("Test") or any( + isinstance(base, ast.Name) + and base.id == "TestCase" + or isinstance(base, ast.Attribute) + and isinstance(base.value, ast.Name) + and base.value.id == "unittest" + and base.attr == "TestCase" + for base in node.bases + ): yield node elif not isinstance(node, ast.Lambda): yield from _scope_test_definitions(node) @@ -50,8 +58,13 @@ def test_test_names_are_unique_within_each_scope(): [ ("async def test_example():\n pass\n", "test_example"), ("class TestExample:\n def test_method(self):\n pass\n", "TestExample"), + ("class RunnerTests(TestCase):\n def test_method(self):\n pass\n", "RunnerTests"), + ( + "class RunnerTests(unittest.TestCase):\n def test_method(self):\n pass\n", + "RunnerTests", + ), ], - ids=["function", "test-class"], + ids=["function", "test-class", "testcase", "qualified-testcase"], ) @pytest.mark.parametrize( "block", @@ -89,12 +102,20 @@ def test_duplicate_guard_checks_control_flow(block, in_class, definition, name): @pytest.mark.parametrize("in_class", [False, True], ids=["module", "class"]) -def test_duplicate_guard_checks_redefined_test_classes(in_class): +@pytest.mark.parametrize( + "declaration, name", + [ + ("TestExample", "TestExample"), + ("RunnerTests(TestCase)", "RunnerTests"), + ("PipelineContractTests(unittest.TestCase)", "PipelineContractTests"), + ], +) +def test_duplicate_guard_checks_redefined_test_classes(in_class, declaration, name): source = ( - "class TestExample:\n" + f"class {declaration}:\n" " def test_first(self):\n" " pass\n" - "class TestExample:\n" + f"class {declaration}:\n" " def test_second(self):\n" " pass\n" ) @@ -102,15 +123,18 @@ def test_duplicate_guard_checks_redefined_test_classes(in_class): source = "class TestContainer:\n" + indent(source, " ") assert _duplicate_test_names(ast.parse(source)) == [ ( - "5: TestExample replaces line 2 in TestContainer" + f"5: {name} replaces line 2 in TestContainer" if in_class - else "4: TestExample replaces line 1 in " + else f"4: {name} replaces line 1 in " ) ] -def test_duplicate_guard_keeps_namespaces_separate(): - tree = ast.parse(""" +@pytest.mark.parametrize( + "declaration", ["TestNested", "RunnerTests(TestCase)", "RunnerTests(unittest.TestCase)"] +) +def test_duplicate_guard_keeps_namespaces_separate(declaration): + source = """ def test_example(): def test_example(): pass @@ -137,7 +161,8 @@ async def test_example(self): class TestNested: def test_example(self): pass -""") +""" + tree = ast.parse(source.replace("class TestNested:", f"class {declaration}:")) assert _duplicate_test_names(tree) == []