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_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..ebe9f0bf2 100644
--- a/tests/test_010_connection_string_parser.py
+++ b/tests/test_010_connection_string_parser.py
@@ -328,14 +328,16 @@ 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-only input: no connection is opened.
+ parser._parse(f"Server=localhost;{keyword}=value") # DevSkim: ignore DS162092
- 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..d23b726b8 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.)
# ====================================================================================
@@ -6865,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_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..89a379059
--- /dev/null
+++ b/tests/test_test_definitions.py
@@ -0,0 +1,179 @@
+"""Guard against tests silently disappearing when a later definition replaces them."""
+
+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 isinstance(node, ast.ClassDef):
+ 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)
+
+
+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))
+ 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(
+ "definition, name",
+ [
+ ("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", "testcase", "qualified-testcase"],
+)
+@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, definition, name):
+ nested = " " if block.startswith("if enabled:\n with") else ""
+ source = block.format(test=indent(definition, " " + nested))
+ source += "\n" + definition
+ scope_name = "TestContainer" if in_class else ""
+ if in_class:
+ 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, ast.ClassDef))
+ and node.name == name
+ )
+ assert _duplicate_test_names(tree) == [
+ f"{definitions[1]}: {name} replaces line {definitions[0]} in {scope_name}"
+ ]
+
+
+@pytest.mark.parametrize("in_class", [False, True], ids=["module", "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 = (
+ f"class {declaration}:\n"
+ " def test_first(self):\n"
+ " pass\n"
+ f"class {declaration}:\n"
+ " def test_second(self):\n"
+ " pass\n"
+ )
+ if in_class:
+ source = "class TestContainer:\n" + indent(source, " ")
+ assert _duplicate_test_names(ast.parse(source)) == [
+ (
+ f"5: {name} replaces line 2 in TestContainer"
+ if in_class
+ else f"4: {name} replaces line 1 in "
+ )
+ ]
+
+
+@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
+
+def helper():
+ def test_example():
+ pass
+ class TestNested:
+ 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
+ class TestNested:
+ def test_example(self):
+ pass
+"""
+ tree = ast.parse(source.replace("class TestNested:", f"class {declaration}:"))
+ 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"]