Skip to content

Commit dffadd6

Browse files
committed
Use first-wins for synonym keys to match ODBC behaviour
_normalize_params and connstr_to_pycore_params now skip a canonical key that was already set by an earlier synonym. Matches ODBC Driver 18 fFromAttrOrProp first-wins semantics confirmed via live test against sqlcconn.cpp. Add TestSynonymFirstWins (12 tests) covering server/addr/ address, trustservercertificate snake_case, and packetsize with-space synonym groups.
1 parent 8c2a696 commit dffadd6

File tree

3 files changed

+111
-5
lines changed

3 files changed

+111
-5
lines changed

mssql_python/connection_string_parser.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,10 @@ def _normalize_params(params: Dict[str, str], warn_rejected: bool = True) -> Dic
108108
if normalized_key in _RESERVED_PARAMETERS:
109109
continue
110110

111-
# Parameter is allowed
112-
filtered[normalized_key] = value
111+
# First-wins: match ODBC behaviour where the first
112+
# occurrence of a synonym group takes precedence.
113+
if normalized_key not in filtered:
114+
filtered[normalized_key] = value
113115
else:
114116
# Parameter is not in allow-list
115117
# Note: In normal flow, this should be empty since parser validates first

mssql_python/helpers.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,12 @@ def connstr_to_pycore_params(params: dict) -> dict:
266266
strings — ``connection.rs`` validates Yes/No and rejects invalid values.
267267
Unrecognised keys are silently dropped.
268268
"""
269-
# Only keys present in _ALLOWED_CONNECTION_STRING_PARAMS are mapped.
270-
# Reserved/unsupported keys (app, workstationid, language, connect_timeout,
271-
# mars_connection) are intentionally excluded — the parser rejects them.
269+
# Only keys listed below are forwarded to py-core.
270+
# Unknown/reserved keys (app, workstationid, language, connect_timeout,
271+
# mars_connection) are silently dropped here. In the normal connect()
272+
# path the parser validates keywords first (validate_keywords=True),
273+
# but bulkcopy parses with validation off, so this mapping is the
274+
# authoritative filter in that path.
272275
key_map = {
273276
# auth / credentials
274277
"uid": "user_name",
@@ -316,6 +319,11 @@ def connstr_to_pycore_params(params: dict) -> dict:
316319
if raw_value is None:
317320
continue
318321

322+
# First-wins: match ODBC behaviour — first synonym in the
323+
# connection string takes precedence (e.g. Addr before Server).
324+
if pycore_key in pycore_params:
325+
continue
326+
319327
# ODBC values are always strings; py-core expects native types for int keys.
320328
# Boolean params (trust_server_certificate, multi_subnet_failover) are passed
321329
# as strings — all Yes/No validation is in connection.rs for single-location

tests/test_010_connection_string_parser.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,99 @@ def test_incomplete_entry_recovery(self):
440440
# Should have error about incomplete 'Server'
441441
errors = exc_info.value.errors
442442
assert any("Server" in err and "Incomplete specification" in err for err in errors)
443+
444+
445+
class TestSynonymFirstWins:
446+
"""
447+
Verify that _normalize_params uses first-wins for synonym keys.
448+
449+
ODBC Driver 18 behaviour (confirmed via live test against sqlcconn.cpp):
450+
- Same key repeated → first-wins (fFromAttrOrProp guard)
451+
- Addr vs Address → same KEY_ADDR slot, first-wins
452+
- Addr/Address vs Server → separate slots, Addr/Address takes priority
453+
454+
_ConnectionStringParser._parse() rejects exact duplicate keys outright.
455+
These tests cover synonyms that map to the same canonical key during
456+
normalization (e.g. addr/address/server → "Server").
457+
"""
458+
459+
@staticmethod
460+
def _normalize(raw: dict) -> dict:
461+
"""Shorthand for calling _normalize_params with warnings suppressed."""
462+
return _ConnectionStringParser._normalize_params(raw, warn_rejected=False)
463+
464+
# ---- server / addr / address synonyms --------------------------------
465+
466+
def test_server_then_addr_first_wins(self):
467+
"""Server=A;Addr=B → first-wins keeps A."""
468+
result = self._normalize({"server": "hostA", "addr": "hostB"})
469+
assert result["Server"] == "hostA"
470+
471+
def test_addr_then_server_first_wins(self):
472+
"""Addr=A;Server=B → first-wins keeps A."""
473+
result = self._normalize({"addr": "hostA", "server": "hostB"})
474+
assert result["Server"] == "hostA"
475+
476+
def test_address_then_server_first_wins(self):
477+
"""Address=A;Server=B → first-wins keeps A."""
478+
result = self._normalize({"address": "hostA", "server": "hostB"})
479+
assert result["Server"] == "hostA"
480+
481+
def test_addr_then_address_first_wins(self):
482+
"""Addr=A;Address=B → first-wins keeps A."""
483+
result = self._normalize({"addr": "hostA", "address": "hostB"})
484+
assert result["Server"] == "hostA"
485+
486+
def test_all_three_server_synonyms_first_wins(self):
487+
"""Addr=A;Address=B;Server=C → first-wins keeps A."""
488+
result = self._normalize({"addr": "hostA", "address": "hostB", "server": "hostC"})
489+
assert result["Server"] == "hostA"
490+
491+
def test_server_only_no_synonyms(self):
492+
"""Single key has no conflict."""
493+
result = self._normalize({"server": "hostA"})
494+
assert result["Server"] == "hostA"
495+
496+
# ---- trustservercertificate / trust_server_certificate synonyms ------
497+
498+
def test_trustservercertificate_then_snake_case_first_wins(self):
499+
"""trustservercertificate=Yes;trust_server_certificate=No → first-wins keeps Yes."""
500+
result = self._normalize(
501+
{"trustservercertificate": "Yes", "trust_server_certificate": "No"}
502+
)
503+
assert result["TrustServerCertificate"] == "Yes"
504+
505+
def test_snake_case_then_trustservercertificate_first_wins(self):
506+
"""trust_server_certificate=No;trustservercertificate=Yes → first-wins keeps No."""
507+
result = self._normalize(
508+
{"trust_server_certificate": "No", "trustservercertificate": "Yes"}
509+
)
510+
assert result["TrustServerCertificate"] == "No"
511+
512+
# ---- packetsize / "packet size" synonyms -----------------------------
513+
514+
def test_packetsize_then_packet_space_first_wins(self):
515+
"""packetsize=8192;packet size=4096 → first-wins keeps 8192."""
516+
result = self._normalize({"packetsize": "8192", "packet size": "4096"})
517+
assert result["PacketSize"] == "8192"
518+
519+
def test_packet_space_then_packetsize_first_wins(self):
520+
"""packet size=4096;packetsize=8192 → first-wins keeps 4096."""
521+
result = self._normalize({"packet size": "4096", "packetsize": "8192"})
522+
assert result["PacketSize"] == "4096"
523+
524+
# ---- non-synonym keys are unaffected ---------------------------------
525+
526+
def test_different_keys_both_kept(self):
527+
"""Non-synonym keys should both be present."""
528+
result = self._normalize({"server": "host", "database": "mydb", "uid": "sa"})
529+
assert result == {"Server": "host", "Database": "mydb", "UID": "sa"}
530+
531+
# ---- reserved keys filtered regardless of order ----------------------
532+
533+
def test_reserved_keys_always_filtered(self):
534+
"""Driver and APP are always stripped, even when first."""
535+
result = self._normalize({"driver": "foo", "server": "host", "app": "bar"})
536+
assert "Driver" not in result
537+
assert "APP" not in result
538+
assert result["Server"] == "host"

0 commit comments

Comments
 (0)