Skip to content

FIX: Preserve leading BOM-like characters in bounded text fetches - #793

Merged
Jahnvi Thakkar (jahnvi480) merged 3 commits into
mainfrom
jahnvi/fix-fetch-bounded-text
Sep 18, 2026
Merged

Jahnvi Thakkar (jahnvi480) merged 3 commits into
mainfrom
jahnvi/fix-fetch-bounded-text

Conversation

@jahnvi480

@jahnvi480 Jahnvi Thakkar (jahnvi480) commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

ADO Bug: AB#48253
GitHub Issue: #794


Summary

This pull request improves the handling of bounded text columns fetched as UTF-16, ensuring that leading U+FEFF and U+FFFE code points are preserved as payload rather than being treated as byte-order markers (BOMs). This change affects row-wise fetching methods and batch decoding on Linux/macOS, and is covered by new regression tests. The implementation introduces a new utility for consistent UTF-16 decoding and updates the codebase to use it.

Bounded text payload handling:

  • Bounded UTF-16 text fetched from the database now preserves leading U+FEFF and U+FFFE as payload, fixing previous behavior where these were sometimes interpreted as BOMs and removed. This affects fetchone(), fetchmany(), and fetchall() results, including when bounded columns are fetched alongside a MAX column.
  • Added comprehensive regression tests in tests/test_017_fetch_bounded_text.py to verify correct handling of BOMs, surrogate pairs, and payload fidelity for bounded text columns.

Decoding implementation changes:

  • Introduced a new fetch_text.hpp utility with FetchText::decode_utf16_native and FetchText::from_utf16_native for correct, platform-consistent UTF-16 decoding that treats leading BOM-like code points as payload.
  • Updated all relevant code paths in ddbc_bindings.cpp and ddbc_bindings.h to use the new decoding utility instead of direct calls to PyUnicode_DecodeUTF16, ensuring consistent handling across the codebase.
  • Included the new utility header in affected files.

Decode bounded ODBC wide-text payloads strictly in native byte order without consuming leading payload characters. Add regression coverage and document the intentional correctness change and unchanged MAX limitations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 17, 2026 12:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The native-endian selection has a correctness defect on big-endian builds, and cross-platform validation remains pending.

Pull request overview

Preserves leading BOM-like characters in bounded text fetches through strict native-endian decoding.

Changes:

  • Added shared UTF-16 decoding helper.
  • Updated bounded text conversion paths.
  • Added regression tests and changelog documentation.
File summaries
File Summary
tests/test_017_fetch_bounded_text.py Adds bounded text regression coverage.
mssql_python/pybind/fetch_text.hpp Adds strict UTF-16 decoding helper.
mssql_python/pybind/ddbc_bindings.cpp Uses the helper for bounded conversions.
CHANGELOG.md Documents BOM-like character preservation.
Review details

Suppressed comments (1)

mssql_python/pybind/fetch_text.hpp:14

  • PY_LITTLE_ENDIAN is an endian tag value, not a boolean predicate (CPython defines it as a nonzero constant such as 1234). Consequently this expression selects -1 on both little- and big-endian builds, so the helper would decode every buffer as little-endian on a big-endian target. Compare PY_BYTE_ORDER with PY_LITTLE_ENDIAN (or use an equivalent compile-time endianness check) before choosing the decoder byte order.
    int byteorder = PY_LITTLE_ENDIAN ? -1 : 1;
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Share nonthrowing native-endian decoding with the two Unix batch processors while retaining their existing fallback behavior and the checked row-wise wrapper. Add platform-aware malformed NVARCHAR batch controls.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 17, 2026 12:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Batch decoder changes conflict with the stated scope and require correction or updated scope and validation.

Review details

Suppressed comments (2)

mssql_python/pybind/ddbc_bindings.h:598

  • This hunk also changes the batch ProcessChar decoder from BOM-autodetecting mode to explicit native-endian mode, so it changes fetchmany()/fetchall() behavior outside the stated scope of replacing only the two bounded SQLGetData_wrap expressions while leaving batch conversion unchanged. Either keep the batch path unchanged or update the PR description and validation/scope claims to explicitly include this behavior change.
            PyObject* pyStr =
                FetchText::decode_utf16_native(reinterpret_cast<const char*>(wcharData),
                                              numCharsInData * sizeof(SQLWCHAR));

mssql_python/pybind/ddbc_bindings.h:712

  • This hunk likewise changes the batch ProcessWChar decoder, even though the PR description says batch conversion remains unchanged and only the two bounded SQLGetData_wrap expressions are being replaced. This changes bounded fetchmany()/fetchall() semantics and should either be reverted here or explicitly added to the documented scope and cross-platform validation plan.
        SQLWCHAR* wcharData = &buffers.wcharBuffers[col - 1][rowIdx * colInfo->fetchBufferSize];
        PyObject* pyStr = FetchText::decode_utf16_native(
            reinterpret_cast<const char*>(wcharData), numCharsInData * sizeof(SQLWCHAR));
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

100%


🎯 Overall Coverage

83%


📈 Total Lines Covered: 8455 out of 10100
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/pybind/ddbc_bindings.cpp (100%)
  • mssql_python/pybind/ddbc_bindings.h (100%)
  • mssql_python/pybind/fetch_text.hpp (100%)

Summary

  • Total: 18 lines
  • Missing: 0 lines
  • Coverage: 100%

📋 Files Needing Attention

📉 Files with overall lowest coverage (click to expand)
mssql_python.pybind.performance_counter.hpp: 0.7%
mssql_python.pybind.logger_bridge.cpp: 57.9%
mssql_python.pybind.ddbc_bindings.h: 64.1%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.row.py: 77.6%
mssql_python.pybind.ddbc_bindings.cpp: 77.7%
mssql_python.pybind.connection.connection_pool.cpp: 81.8%
mssql_python.logging.py: 86.2%
mssql_python.pooling.py: 90.1%
mssql_python.pybind.py_type_cache.hpp: 91.6%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

Copilot AI review requested due to automatic review settings September 18, 2026 07:48
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

PR Performance Report

This PR consistently slows common table expression queries on macOS / SQL Server 2022 by 29.2%.

Environment Affected task Before After Change
macOS / SQL Server 2022 Common table expression queries 48.191 ms 58.665 ms +29.2%

The largest recorded phase increases for these tasks are shown below. Phase timings are supporting evidence, not root-cause proof.
1 additional inconsistent slowdown also need review.

Coverage: 5 of 5 environments completed. Advisory result; does not block merging.

Environment Status
Windows / SQL Server 2022 Completed
Windows / SQL Server 2025 Completed
macOS / SQL Server 2022 Completed
macOS / SQL Server 2025 Completed
Linux / SQL Server 2022 Completed
Affected phases and call counts

Phase times are inclusive diagnostics and must not be added together. They identify where measured time changed, not why it changed.

macOS / SQL Server 2022

SELECT queries: ddbc::SQLExecDirect_wrap +4.910 ms; py::execute::cpp_call +4.909 ms; ddbc::SQLGetAllDiagRecords +0.213 ms.
Common table expression queries: py::execute::cpp_call +5.514 ms; ddbc::SQLExecDirect_wrap +5.511 ms; py::fetchall::diag_records +4.095 ms.

All database tasks and timings

Windows / SQL Server 2022

Database task Before After Paired change Result
Connection opening 25.851 ms 20.437 ms -22.6% no signal
SELECT queries 1.960 ms 1.427 ms -26.9% no signal
Row insertion 34.020 ms 29.236 ms +3.1% no signal
Executemany inserts 339.475 ms 210.535 ms -44.0% no signal
Fetch-all queries 350.862 ms 252.919 ms -26.7% no signal
Row-by-row fetching 63.372 ms 29.509 ms -32.8% no signal
Batched row fetching 383.373 ms 257.034 ms -35.8% no signal
Transaction commit and rollback 122.001 ms 89.689 ms -25.9% no signal
Arrow row fetching 241.724 ms 170.415 ms -24.0% no signal
100,000-row insertion 918.012 ms 900.470 ms -17.6% no signal
Row fetching in batches of 100 417.709 ms 283.577 ms -28.6% no signal
Row fetching in batches of 10,000 419.942 ms 271.916 ms -36.4% no signal
Repeated positional queries 29.060 ms 29.217 ms -0.9% no signal
Repeated named-parameter queries 32.102 ms 31.809 ms -0.9% no signal
Legacy 100,000-row insertion 694.335 ms 476.237 ms -31.4% no signal
Insertion with explicit input sizes 5183.968 ms 3183.154 ms -42.3% no signal
Joined aggregation queries 293.647 ms 179.828 ms -26.2% no signal
Large joined-result fetching 456.253 ms 295.961 ms -34.1% no signal
1.2-million-row fetching 9707.315 ms 7229.019 ms -2.6% no signal
Common table expression queries 5.405 ms 7.777 ms +6.6% no signal

Windows / SQL Server 2025

Database task Before After Paired change Result
Connection opening 230.316 ms 166.060 ms -35.2% no signal
SELECT queries 1.941 ms 1.549 ms -2.8% no signal
Row insertion 31.856 ms 30.893 ms -0.3% no signal
Executemany inserts 248.607 ms 211.667 ms -7.9% no signal
Fetch-all queries 353.131 ms 274.327 ms -22.3% no signal
Row-by-row fetching 47.285 ms 30.015 ms -36.3% no signal
Batched row fetching 374.821 ms 273.525 ms -28.7% no signal
Transaction commit and rollback 98.735 ms 98.304 ms -0.8% no signal
Arrow row fetching 211.906 ms 174.694 ms -17.6% no signal
100,000-row insertion 694.583 ms 589.275 ms -12.7% no signal
Row fetching in batches of 100 429.206 ms 294.536 ms -31.4% no signal
Row fetching in batches of 10,000 391.919 ms 290.513 ms -25.9% no signal
Repeated positional queries 37.401 ms 30.566 ms -17.5% no signal
Repeated named-parameter queries 44.548 ms 33.742 ms -24.3% no signal
Legacy 100,000-row insertion 663.904 ms 498.177 ms -22.1% no signal
Insertion with explicit input sizes 4713.903 ms 3277.555 ms -30.3% no signal
Joined aggregation queries 275.062 ms 181.043 ms -34.2% no signal
Large joined-result fetching 469.396 ms 326.212 ms -30.9% no signal
1.2-million-row fetching 10816.989 ms 8593.816 ms -12.4% no signal
Common table expression queries 5.491 ms 6.828 ms +1.6% no signal

macOS / SQL Server 2022

Database task Before After Paired change Result
Connection opening 170.807 ms 152.111 ms -12.2% no signal
SELECT queries 36.813 ms 46.348 ms +25.0% inconsistent slowdown
Row insertion 1343.681 ms 1286.600 ms +5.3% no signal
Executemany inserts 684.468 ms 693.045 ms -14.1% no signal
Fetch-all queries 1193.244 ms 1196.873 ms +4.3% no signal
Row-by-row fetching 20894.580 ms 21536.327 ms -0.9% no signal
Batched row fetching 1533.240 ms 1587.073 ms +4.3% no signal
Transaction commit and rollback 5595.138 ms 5478.546 ms -11.2% no signal
Arrow row fetching 1117.975 ms 833.703 ms -25.7% no signal
100,000-row insertion 4290.928 ms 3771.401 ms -6.7% no signal
Row fetching in batches of 100 12002.966 ms 12935.928 ms +1.8% no signal
Row fetching in batches of 10,000 973.045 ms 875.951 ms -10.0% no signal
Repeated positional queries 3540.924 ms 3765.578 ms +1.3% no signal
Repeated named-parameter queries 3741.059 ms 3598.943 ms -5.3% no signal
Legacy 100,000-row insertion 3079.702 ms 3298.600 ms +10.9% no signal
Insertion with explicit input sizes 9053.498 ms 8310.913 ms -8.2% no signal
Joined aggregation queries 687.248 ms 666.310 ms -7.7% no signal
Large joined-result fetching 1184.715 ms 1156.571 ms +18.1% no signal
1.2-million-row fetching 31862.803 ms 35369.042 ms +10.3% no signal
Common table expression queries 48.191 ms 58.665 ms +29.2% consistent slowdown

macOS / SQL Server 2025

Database task Before After Paired change Result
Connection opening 484.198 ms 560.229 ms -12.8% no signal
SELECT queries 42.904 ms 47.727 ms +19.3% no signal
Row insertion 1694.657 ms 1570.144 ms -15.6% no signal
Executemany inserts 1378.969 ms 874.002 ms -40.4% no signal
Fetch-all queries 2054.917 ms 1557.321 ms -35.9% no signal
Row-by-row fetching 24099.487 ms 24676.908 ms +1.9% no signal
Batched row fetching 2571.289 ms 2602.507 ms +5.9% no signal
Transaction commit and rollback 7036.720 ms 6713.962 ms -7.2% no signal
Arrow row fetching 1430.328 ms 1396.404 ms -2.2% no signal
100,000-row insertion 5372.276 ms 5609.429 ms +3.2% no signal
Row fetching in batches of 100 14990.350 ms 15037.552 ms -0.6% no signal
Row fetching in batches of 10,000 1240.282 ms 1339.640 ms -12.6% no signal
Repeated positional queries 4932.612 ms 4177.644 ms -4.5% no signal
Repeated named-parameter queries 4061.456 ms 4359.960 ms -1.3% no signal
Legacy 100,000-row insertion 5395.454 ms 3950.782 ms -6.7% no signal
Insertion with explicit input sizes 9534.188 ms 9780.051 ms +2.6% no signal
Joined aggregation queries 564.720 ms 878.464 ms -11.7% no signal
Large joined-result fetching 1828.276 ms 1567.330 ms -0.1% no signal
1.2-million-row fetching 43882.586 ms 47902.554 ms -2.8% no signal
Common table expression queries 62.274 ms 64.225 ms -8.1% no signal

Linux / SQL Server 2022

Database task Before After Paired change Result
Connection opening 10.494 ms 10.603 ms +1.0% no signal
SELECT queries 1.153 ms 1.142 ms -1.0% no signal
Row insertion 32.858 ms 33.382 ms +0.3% no signal
Executemany inserts 156.654 ms 157.589 ms -1.6% no signal
Fetch-all queries 167.648 ms 165.613 ms -2.2% no signal
Row-by-row fetching 60.800 ms 59.504 ms -2.4% no signal
Batched row fetching 160.532 ms 159.901 ms -0.6% no signal
Transaction commit and rollback 109.263 ms 109.176 ms -0.3% no signal
Arrow row fetching 93.537 ms 92.794 ms -0.2% no signal
100,000-row insertion 440.754 ms 434.997 ms -2.6% no signal
Row fetching in batches of 100 221.967 ms 216.470 ms -3.3% no signal
Row fetching in batches of 10,000 181.292 ms 182.631 ms +0.7% no signal
Repeated positional queries 40.375 ms 40.735 ms -0.2% no signal
Repeated named-parameter queries 42.862 ms 43.108 ms +1.1% no signal
Legacy 100,000-row insertion 353.599 ms 352.907 ms -1.8% no signal
Insertion with explicit input sizes 2441.072 ms 2338.214 ms -3.6% no signal
Joined aggregation queries 179.603 ms 181.119 ms -0.7% no signal
Large joined-result fetching 206.333 ms 207.446 ms -0.4% no signal
1.2-million-row fetching 4998.619 ms 4938.610 ms -1.3% no signal
Common table expression queries 5.421 ms 5.351 ms -1.5% no signal
Build, commits and measurement details

ADO build 176410

PR head: aed705be73aed4daaf3bc8caa5d46eeb06ee0b38
Base: 375a5bfc822709d8099f5f0ccd146d9d2477a83f
Measured merge: 0271c9e708dc075c688ef903c20d12093b008e68

  • Windows / SQL Server 2022: Python 3.13.15, amd64, SQL 16.0.1000.6; 5 paired comparisons and 1 warmup.
  • Windows / SQL Server 2025: Python 3.14.7, amd64, SQL 17.0.1000.7; 5 paired comparisons and 1 warmup.
  • macOS / SQL Server 2022: Python 3.13.15, x86_64, SQL 16.0.4295.3; 5 paired comparisons and 1 warmup.
  • macOS / SQL Server 2025: Python 3.14.7, x86_64, SQL 17.0.5005.3; 5 paired comparisons and 1 warmup.
  • Linux / SQL Server 2022: Python 3.12.3, x86_64, SQL 16.0.4295.3; 5 paired comparisons and 1 warmup.

A consistent slowdown requires more than 20% median paired slowdown, at least 1 ms between the median runtimes, and at least 80% of pairs exceeding the relative threshold. An inconsistent slowdown crosses the first two thresholds without enough pair agreement.

The displayed change is the median of paired before-and-after ratios. It is not recalculated from the two displayed median runtimes.

Both revisions use profiling-enabled builds on the same agent and database, with alternating order and discarded warmups. Results are diagnostic and do not represent production-wheel latency.

Raw samples and logs are attached to the ADO run as profiler-* artifacts.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The regression test incorrectly treats empty strings as SQL NULL, causing empty-string cases to fail before exercising the decoder.

Review details

Suppressed comments (1)

tests/test_017_fetch_bounded_text.py:61

  • UNICODE('') returns 0 in SQL Server, while this expression records None for both None and "". As a result, every parametrized case containing the empty string will fail the independent evidence assertion before exercising the decoder. Distinguish SQL NULL from an empty payload when constructing wanted_evidence.
                    ord(payload[0]) if payload else None,
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: medium Moderate update size

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants