From 31ac5a7a2d0145420e3fd930b0eedd45be3f0136 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 17 Sep 2026 21:21:01 +0530 Subject: [PATCH] REFACTOR: Keep fetchmany column metadata native and call-local Remove native metadata dictionary roundtrips while preserving eager Unicode names and fresh per-call descriptions. Add behavior and profiling regression coverage. Performance acceptance remains unresolved after the bounded local study. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + mssql_python/pybind/ddbc_bindings.cpp | 117 +++++-- tests/test_040_fetch_native_metadata.py | 387 ++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 28 deletions(-) create mode 100644 tests/test_040_fetch_native_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aab046b3a..eee11e3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), does not change the default provider or ship any Rust driver binaries. ### Changed +- `fetchmany()` keeps freshly described column types and sizes in call-local native + metadata instead of round-tripping them through Python dictionaries. Column names + retain eager Unicode conversion; public column descriptions and fetch behavior + are unchanged. - `mssql-python` now depends on `mssql-python-rs==0.1.0` for `mssql_py_core` instead of embedding files owned by that separately published distribution. - **GH-769 deprecation policy:** The misplaced `GetInfoConstants` members diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 35a3b6da4..3a7ed8a67 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -3022,9 +3022,51 @@ SQLSMALLINT SQLNumResultCols_wrap(SqlHandlePtr statementHandle) { return columnCount; } -// Wrap SQLDescribeCol -SQLRETURN SQLDescribeCol_wrap(SqlHandlePtr StatementHandle, py::list& ColumnMetadata) { - PERF_TIMER("SQLDescribeCol_wrap"); +namespace { + +struct FetchColumnMetadata { + py::object name; + SQLSMALLINT dataType; + SQLULEN columnSize; + SQLSMALLINT decimalDigits; + SQLSMALLINT nullable; +}; + +py::dict GetFetchColumnMetadata(const py::list& columns, size_t index) { + return columns[index].cast(); +} + +const FetchColumnMetadata& GetFetchColumnMetadata( + const std::vector& columns, size_t index) { + return columns.at(index); +} + +SQLSMALLINT GetFetchColumnType(const py::dict& column) { + return column["DataType"].cast(); +} + +SQLSMALLINT GetFetchColumnType(const FetchColumnMetadata& column) { + return column.dataType; +} + +SQLULEN GetFetchColumnSize(const py::dict& column) { + return column["ColumnSize"].cast(); +} + +SQLULEN GetFetchColumnSize(const FetchColumnMetadata& column) { + return column.columnSize; +} + +std::string GetFetchColumnName(const py::dict& column) { + return column["ColumnName"].cast(); +} + +std::string GetFetchColumnName(const FetchColumnMetadata& column) { + return column.name.cast(); +} + +template +SQLRETURN DescribeColumns(SqlHandlePtr StatementHandle, AppendColumn&& appendColumn) { LOG("SQLDescribeCol: Getting column descriptions for statement_handle=%p", (void*)StatementHandle->get()); if (!SQLDescribeCol_ptr) { @@ -3052,14 +3094,12 @@ SQLRETURN SQLDescribeCol_wrap(SqlHandlePtr StatementHandle, py::list& ColumnMeta &ColumnSize, &DecimalDigits, &Nullable); if (SQL_SUCCEEDED(retcode)) { - // Append a named py::dict to ColumnMetadata - // TODO: Should we define a struct for this task instead of dict? - ColumnMetadata.append( - py::dict("ColumnName"_a = dupeSqlWCharAsUtf16Le( - ColumnName, std::min(static_cast(NameLength), - (sizeof(ColumnName) / sizeof(SQLWCHAR)) - 1)), - "DataType"_a = DataType, "ColumnSize"_a = ColumnSize, - "DecimalDigits"_a = DecimalDigits, "Nullable"_a = Nullable)); + // Own the name and preserve eager UTF-16 conversion, including codec errors. + auto name = py::cast(dupeSqlWCharAsUtf16Le( + ColumnName, std::min(static_cast(NameLength), + (sizeof(ColumnName) / sizeof(SQLWCHAR)) - 1))); + appendColumn(FetchColumnMetadata{ + std::move(name), DataType, ColumnSize, DecimalDigits, Nullable}); } else { return retcode; } @@ -3067,6 +3107,19 @@ SQLRETURN SQLDescribeCol_wrap(SqlHandlePtr StatementHandle, py::list& ColumnMeta return SQL_SUCCESS; } +} // namespace + +// Wrap SQLDescribeCol +SQLRETURN SQLDescribeCol_wrap(SqlHandlePtr StatementHandle, py::list& ColumnMetadata) { + PERF_TIMER("SQLDescribeCol_wrap"); + return DescribeColumns(StatementHandle, [&](FetchColumnMetadata column) { + ColumnMetadata.append( + py::dict("ColumnName"_a = column.name, "DataType"_a = column.dataType, + "ColumnSize"_a = column.columnSize, "DecimalDigits"_a = column.decimalDigits, + "Nullable"_a = column.nullable)); + }); +} + SQLRETURN SQLSpecialColumns_wrap(SqlHandlePtr StatementHandle, SQLSMALLINT identifierType, const py::object& catalogObj, const py::object& schemaObj, const std::u16string& table, SQLSMALLINT scope, @@ -3999,16 +4052,17 @@ SQLRETURN SQLFetchScroll_wrap(SqlHandlePtr StatementHandle, SQLSMALLINT FetchOri // For column in the result set, binds a buffer to retrieve column data // TODO: Move to anonymous namespace, since it is not used outside this file -SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& columnNames, +template +SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, const Metadata& columnNames, SQLUSMALLINT numCols, int fetchSize, int charCtype = SQL_C_WCHAR) { PERF_TIMER("SQLBindColums"); SQLRETURN ret = SQL_SUCCESS; const bool useWideChar = (charCtype == SQL_C_WCHAR); // Bind columns based on their data types for (SQLUSMALLINT col = 1; col <= numCols; col++) { - auto columnMeta = columnNames[col - 1].cast(); - SQLSMALLINT dataType = columnMeta["DataType"].cast(); - SQLULEN columnSize = columnMeta["ColumnSize"].cast(); + const auto& columnMeta = GetFetchColumnMetadata(columnNames, col - 1); + SQLSMALLINT dataType = GetFetchColumnType(columnMeta); + SQLULEN columnSize = GetFetchColumnSize(columnMeta); switch (dataType) { case SQL_CHAR: @@ -4140,7 +4194,7 @@ SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& column buffers.indicators[col - 1].data()); break; default: - std::string columnName = columnMeta["ColumnName"].cast(); + std::string columnName = GetFetchColumnName(columnMeta); std::ostringstream errorString; errorString << "Unsupported data type for column - " << columnName.c_str() << ", Type - " << dataType << ", column ID - " << col; @@ -4149,7 +4203,7 @@ SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& column break; } if (!SQL_SUCCEEDED(ret)) { - std::string columnName = columnMeta["ColumnName"].cast(); + std::string columnName = GetFetchColumnName(columnMeta); std::ostringstream errorString; errorString << "Failed to bind column - " << columnName.c_str() << ", Type - " << dataType << ", column ID - " << col; @@ -4163,7 +4217,8 @@ SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& column // Fetch rows in batches // TODO: Move to anonymous namespace, since it is not used outside this file -SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& columnNames, +template +SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, const Metadata& columnNames, py::list& rows, SQLUSMALLINT numCols, SQLULEN& numRowsFetched, const std::vector& lobColumns, const std::string& charEncoding = "utf-16le", @@ -4213,9 +4268,9 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum { PERF_TIMER("FetchBatchData::cache_column_metadata"); for (SQLUSMALLINT col = 0; col < numCols; col++) { - const auto& columnMeta = columnNames[col].cast(); - columnInfos[col].dataType = columnMeta["DataType"].cast(); - columnInfos[col].columnSize = columnMeta["ColumnSize"].cast(); + const auto& columnMeta = GetFetchColumnMetadata(columnNames, col); + columnInfos[col].dataType = GetFetchColumnType(columnMeta); + columnInfos[col].columnSize = GetFetchColumnSize(columnMeta); columnInfos[col].isLob = std::find(lobColumns.begin(), lobColumns.end(), col + 1) != lobColumns.end(); columnInfos[col].processedColumnSize = columnInfos[col].columnSize; @@ -4504,8 +4559,8 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum break; } default: { - const auto& columnMeta = columnNames[col - 1].cast(); - std::string columnName = columnMeta["ColumnName"].cast(); + const auto& columnMeta = GetFetchColumnMetadata(columnNames, col - 1); + std::string columnName = GetFetchColumnName(columnMeta); std::ostringstream errorString; errorString << "Unsupported data type for column - " << columnName.c_str() << ", Type - " << dataType << ", column ID - " << col; @@ -4650,18 +4705,24 @@ SQLRETURN FetchMany_wrap(SqlHandlePtr StatementHandle, py::list& rows, int fetch SQLSMALLINT numCols = SQLNumResultCols_wrap(StatementHandle); // Retrieve column metadata - py::list columnNames; - ret = SQLDescribeCol_wrap(StatementHandle, columnNames); + std::vector columnNames; + ret = DescribeColumns(StatementHandle, [&](FetchColumnMetadata column) { + columnNames.push_back(std::move(column)); + }); if (!SQL_SUCCEEDED(ret)) { LOG("FetchMany_wrap: Failed to get column descriptions - SQLRETURN=%d", ret); return ret; } + if (numCols < 0 || columnNames.size() != static_cast(numCols)) { + LOG("FetchMany_wrap: Column metadata count does not match result column count"); + ThrowStdException("Column metadata count does not match result column count"); + } std::vector lobColumns; for (SQLSMALLINT i = 0; i < numCols; i++) { - auto colMeta = columnNames[i].cast(); - SQLSMALLINT dataType = colMeta["DataType"].cast(); - SQLULEN columnSize = colMeta["ColumnSize"].cast(); + const auto& colMeta = GetFetchColumnMetadata(columnNames, i); + SQLSMALLINT dataType = GetFetchColumnType(colMeta); + SQLULEN columnSize = GetFetchColumnSize(colMeta); if (IsLobOrVariantColumn(dataType, columnSize)) { lobColumns.push_back(i + 1); // 1-based diff --git a/tests/test_040_fetch_native_metadata.py b/tests/test_040_fetch_native_metadata.py new file mode 100644 index 000000000..eb46976e4 --- /dev/null +++ b/tests/test_040_fetch_native_metadata.py @@ -0,0 +1,387 @@ +"""Call-local fetch metadata must preserve public descriptions and fetch state.""" + +import datetime as dt +import os +from pathlib import Path +import subprocess +import sys +import textwrap +from decimal import Decimal +from uuid import UUID + +import pytest + +import mssql_python +from mssql_python import ddbc_bindings + + +@pytest.fixture +def metadata_cursor(conn_str): + with mssql_python.connect(conn_str) as connection: + with connection.cursor() as cursor: + yield cursor + + +def _query(columns, count=20): + values = ",".join(f"({i})" for i in range(1, 21)) + return ( + f"SELECT {','.join(columns)} FROM (VALUES {values}) AS source(id) " + f"WHERE id<={count} ORDER BY id" + ) + + +def _assert_rows(rows, expected): + assert [tuple(row) for row in rows] == expected + assert [[type(value) for value in row] for row in rows] == [ + [type(value) for value in row] for row in expected + ] + + +def _describe(cursor): + result = [] + assert ddbc_bindings.DDBCSQLDescribeCol(cursor.hstmt, result) == 0 + for column in result: + assert set(column) == {"ColumnName", "DataType", "ColumnSize", "DecimalDigits", "Nullable"} + assert type(column["ColumnName"]) is str + for key in ("DataType", "ColumnSize", "DecimalDigits", "Nullable"): + assert type(column[key]) is int + return result + + +@pytest.mark.parametrize("width", [3, 24]) +@pytest.mark.parametrize("size", [None, 1, 10, 1000, "varied"]) +@pytest.mark.parametrize("count", [0, 20]) +def test_fetchmany_shape_sizes_and_eof(metadata_cursor, width, size, count): + cursor = metadata_cursor + expressions = ["id", "CONVERT(NVARCHAR(30),N'row')", "CONVERT(FLOAT,id)*0.25"] + columns = [f"{expressions[i % 3]} AS c{i}" for i in range(width)] + cursor.execute(_query(columns, count)) + assert cursor.arraysize == 1 + description = cursor.description + assert all(len(column) == 7 for column in description) + assert [column[0] for column in description] == [f"c{i}" for i in range(width)] + metadata = _describe(cursor) + output = [] + iteration = 0 + while True: + fetch_size = (1, 10, 3, 1000)[iteration % 4] if size == "varied" else size + batch = cursor.fetchmany() if fetch_size is None else cursor.fetchmany(fetch_size) + assert cursor.description == description + if not batch: + break + output.extend(batch) + iteration += 1 + _assert_rows(output, [(i, "row", i * 0.25) * (width // 3) for i in range(1, count + 1)]) + assert _describe(cursor) == metadata + assert cursor.fetchmany(1) == [] + assert cursor.fetchone() is None + assert cursor.fetchall() == [] + + +@pytest.mark.parametrize("method", ["fetchmany", "fetchall", "arrow_batch"]) +def test_public_metadata_names_and_fields(metadata_cursor, method): + cursor = metadata_cursor + names = [ + "duplicate", + "duplicate", + "\u03a9\u540d", + "emoji_\U0001f600", + "bracket]name", + "x" * 128, + ] + columns = [f"CONVERT(INT,id) AS [{name.replace(']', ']]')}]" for name in names] + cursor.execute(_query(columns, 1)) + metadata = _describe(cursor) + assert metadata == [ + {"ColumnName": name, "DataType": 4, "ColumnSize": 10, "DecimalDigits": 0, "Nullable": 1} + for name in names + ] + assert [column[0] for column in cursor.description] == names + if method == "arrow_batch": + pytest.importorskip("pyarrow") + batch = cursor.arrow_batch(1) + assert batch.schema.names == names + assert [column.to_pylist() for column in batch.columns] == [[1]] * len(names) + else: + rows = cursor.fetchmany(1) if method == "fetchmany" else cursor.fetchall() + _assert_rows(rows, [(1,) * len(names)]) + assert _describe(cursor) == metadata + + +_TYPES = [ + ("INT", "7", 7), + ("SMALLINT", "-7", -7), + ("BIGINT", "2147483649", 2147483649), + ("TINYINT", "255", 255), + ("BIT", "1", True), + ("REAL", "1.5", 1.5), + ("FLOAT", "2.25", 2.25), + ("DECIMAL(20,4)", "123.4500", Decimal("123.4500")), + ("NUMERIC(28,8)", "-0.125", Decimal("-0.125")), + ("MONEY", "4.25", Decimal("4.25")), + ("DATE", "'2001-02-03'", dt.date(2001, 2, 3)), + ("TIME(7)", "'12:34:56.1234567'", dt.time(12, 34, 56, 123456)), + ("DATETIME2(7)", "'2001-02-03T12:34:56.1234567'", dt.datetime(2001, 2, 3, 12, 34, 56, 123456)), + ("DATETIME", "'2001-02-03T12:34:56'", dt.datetime(2001, 2, 3, 12, 34, 56)), + ( + "DATETIMEOFFSET(7)", + "'2001-02-03T12:34:56.1234567+05:30'", + dt.datetime(2001, 2, 3, 12, 34, 56, 123456, dt.timezone(dt.timedelta(minutes=330))), + ), + ( + "UNIQUEIDENTIFIER", + "'12345678-1234-5678-1234-567812345678'", + UUID("12345678-1234-5678-1234-567812345678"), + ), + ("VARCHAR(20)", "'ascii'", "ascii"), + ("CHAR(8)", "'ascii'", "ascii "), + ("NVARCHAR(30)", "N'\u03a9\U0001f600'", "\u03a9\U0001f600"), + ("NCHAR(5)", "N'\u03a9'", "\u03a9 "), + ("VARBINARY(10)", "0x00010200", b"\x00\x01\x02\x00"), + ("BINARY(4)", "0x00010203", b"\x00\x01\x02\x03"), + ("VARCHAR(1)", "''", ""), + ("NVARCHAR(1)", "N''", ""), +] + + +@pytest.mark.parametrize("size", [1, 10, 1000]) +def test_fetchmany_typed_nulls_and_values(metadata_cursor, size): + columns = [ + f"CASE WHEN id%3=0 THEN CAST(NULL AS {sqltype}) " + f"ELSE CAST({literal} AS {sqltype}) END AS c{i}" + for i, (sqltype, literal, _) in enumerate(_TYPES) + ] + cursor = metadata_cursor + cursor.execute(_query(columns)) + description = cursor.description + metadata = _describe(cursor) + assert len(metadata) == 24 + output = [] + while batch := cursor.fetchmany(size): + output.extend(batch) + values = tuple(value for _, _, value in _TYPES) + _assert_rows(output, [(None,) * 24 if i % 3 == 0 else values for i in range(1, 21)]) + assert cursor.description == description + assert _describe(cursor) == metadata + + +def test_reexecute_and_nextset_change_shape(metadata_cursor): + cursor = metadata_cursor + for _ in range(3): + cursor.execute("SELECT 1 AS first_name; SELECT N'new' AS second_name, 2 AS extra") + _assert_rows(cursor.fetchmany(1), [(1,)]) + assert cursor.nextset() + assert [col[0] for col in cursor.description] == ["second_name", "extra"] + _assert_rows(cursor.fetchmany(10), [("new", 2)]) + assert not cursor.nextset() + cursor.execute("SELECT CAST(3.5 AS DECIMAL(6,2)) AS replacement") + assert _describe(cursor)[0]["ColumnName"] == "replacement" + _assert_rows(cursor.fetchmany(), [(Decimal("3.50"),)]) + + +def test_converter_changes_on_execute_and_live_decoding(metadata_cursor): + cursor = metadata_cursor + connection = cursor.connection + cursor.execute(_query(["id", "CAST('ascii' AS VARCHAR(12)) AS txt"], 4)) + _assert_rows(cursor.fetchmany(1), [(1, "ascii")]) + calls = [] + + def convert(value): + calls.append(value) + return value + 100 + + connection.add_output_converter(mssql_python.SQL_INTEGER, convert) + connection.setdecoding(mssql_python.SQL_CHAR, "utf-8", mssql_python.SQL_CHAR) + cursor.execute(_query(["id", "CAST('ascii' AS VARCHAR(12)) AS txt"], 4)) + _assert_rows(cursor.fetchmany(1), [(101, "ascii")]) + assert calls == [1] + connection.setdecoding(mssql_python.SQL_CHAR, "latin1", mssql_python.SQL_CHAR) + _assert_rows(cursor.fetchmany(1), [(102, "ascii")]) + assert calls == [1, 2] + connection.remove_output_converter(mssql_python.SQL_INTEGER) + cursor.execute(_query(["id", "CAST('ascii' AS VARCHAR(12)) AS txt"], 1)) + _assert_rows(cursor.fetchmany(1), [(1, "ascii")]) + connection.setdecoding(mssql_python.SQL_CHAR) + cursor.execute(_query(["id", "CAST('ascii' AS VARCHAR(12)) AS txt"], 1)) + _assert_rows(cursor.fetchmany(10), [(1, "ascii")]) + + +@pytest.mark.parametrize("size", [1, 10]) +def test_fetchmany_lob_and_xml_typed_nulls(metadata_cursor, size): + cursor = metadata_cursor + columns = [ + "CASE WHEN id%2=0 THEN CAST(NULL AS NVARCHAR(MAX)) ELSE " + "REPLICATE(CAST(N'x' AS NVARCHAR(MAX)),9001) END AS txt", + "CASE WHEN id%2=0 THEN CAST(NULL AS VARBINARY(MAX)) ELSE " + "CAST(REPLICATE(CAST('a' AS VARCHAR(MAX)),10003) AS VARBINARY(MAX)) END AS bin", + "CASE WHEN id%2=0 THEN CAST(NULL AS XML) ELSE CAST('value' AS XML) END AS xml", + ] + cursor.execute(_query(columns, 4)) + metadata = _describe(cursor) + rows = [] + while batch := cursor.fetchmany(size): + rows.extend(batch) + values = ("x" * 9001, b"a" * 10003, "value") + _assert_rows(rows, [values, (None, None, None), values, (None, None, None)]) + assert _describe(cursor) == metadata + + +def _isolated(script, tmp_path): + environment = dict(os.environ) + root = str(Path(mssql_python.__file__).resolve().parent.parent) + environment["PYTHONPATH"] = os.pathsep.join([root, environment.get("PYTHONPATH", "")]) + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(script)], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=45, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_interleaving_movement_and_variant_freshness(tmp_path): + _isolated( + """ + import os + import gc + import mssql_python as db + with db.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + query = "SELECT id FROM (VALUES(1),(2),(3),(4),(5),(6),(7)) s(id) ORDER BY id" + for _ in range(4): + cursor.execute(query) + assert cursor.fetchmany(1)[0][0] == 1 + gc.collect() + assert cursor.fetchone()[0] == 2 + cursor.scroll(1) + assert cursor.fetchmany(1)[0][0] == 4 + cursor.skip(1) + assert [tuple(row) for row in cursor.fetchall()] == [(6,), (7,)] + cursor.execute("CREATE TABLE #metadata_variant(id INT, v SQL_VARIANT)") + cursor.execute( + "INSERT INTO #metadata_variant VALUES " + "(1,CAST('abc' AS VARCHAR(3)))," + "(2,CAST('abcdefgh' AS VARCHAR(8)))," + "(3,CAST(REPLICATE('x',30) AS VARCHAR(30)))" + ) + for method in ("fetchmany", "fetchall"): + cursor.execute("SELECT v FROM #metadata_variant ORDER BY id") + assert cursor.fetchone()[0] == "abc" + if method == "fetchmany": + assert cursor.fetchmany(1)[0][0] == "abcdefgh" + assert cursor.fetchmany(1)[0][0] == "x"*30 + else: + assert [r[0] for r in cursor.fetchall()] == ["abcdefgh", "x"*30] + """, + tmp_path, + ) + + +def test_closure_and_error_recovery(tmp_path): + _isolated( + """ + import os + import mssql_python as db + from mssql_python import Cursor, InterfaceError, ProgrammingError, DatabaseError + with db.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT 1 AS c") + assert cursor.fetchmany(0) == [] + assert cursor.fetchmany(-1) == [] + assert cursor.fetchmany(1)[0][0] == 1 + try: + cursor.execute("SELECT invalid_column FROM (VALUES(1)) t(c)") + except DatabaseError: + pass + else: + raise AssertionError("invalid query did not raise") + cursor.execute("SELECT 2 AS changed") + assert cursor.fetchmany(1)[0][0] == 2 + try: + cursor.fetchmany(1) + except ProgrammingError: + pass + else: + raise AssertionError("closed cursor did not raise") + connection = db.connect(os.environ["DB_CONNECTION_STRING"]) + cursor = Cursor(connection) + cursor.execute("SELECT 1") + connection.close() + try: + cursor.fetchmany(1) + except (InterfaceError, ProgrammingError): + pass + else: + raise AssertionError("closed connection did not raise") + cursor.close() + """, + tmp_path, + ) + + +def test_malformed_column_name_fails_before_fetch(tmp_path): + _isolated( + """ + import os + import mssql_python as db + from mssql_python import ddbc_bindings as native + with db.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + cursor.execute( + "DECLARE @s NVARCHAR(200) = N'SELECT 1 AS [' + " + "CAST(0x00D8 AS NVARCHAR(1)) + N']'; EXEC(@s)" + ) + for operation in ( + lambda: native.DDBCSQLDescribeCol(cursor.hstmt, []), + lambda: cursor.fetchmany(1), + ): + try: + operation() + except UnicodeDecodeError: + pass + else: + raise AssertionError("malformed UTF-16 column name did not raise") + assert native.DDBCSQLFetch(cursor.hstmt) == 0 + assert native.DDBCSQLFetch(cursor.hstmt) == 100 + """, + tmp_path, + ) + + +@pytest.mark.skipif( + not hasattr(ddbc_bindings, "profiling"), reason="requires native profiling instrumentation" +) +def test_fetchmany_avoids_python_description_roundtrip(tmp_path): + _isolated( + """ + import os + import mssql_python as db + from mssql_python import ddbc_bindings as native + with db.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT id FROM (VALUES(1),(2)) s(id) ORDER BY id") + native.profiling.reset() + native.profiling.enable() + try: + metadata = [] + native.DDBCSQLDescribeCol(cursor.hstmt, metadata) + finally: + native.profiling.disable() + assert native.profiling.get_stats()["ddbc::SQLDescribeCol_wrap"]["calls"] == 1 + assert len(metadata) == 1 + native.profiling.reset() + native.profiling.enable() + try: + assert cursor.fetchmany(1)[0][0] == 1 + assert cursor.fetchmany(1)[0][0] == 2 + assert cursor.fetchmany(1) == [] + finally: + native.profiling.disable() + stats = native.profiling.get_stats() + assert stats["ddbc::FetchMany_wrap"]["calls"] == 3 + assert stats.get("ddbc::SQLDescribeCol_wrap", {}).get("calls", 0) == 0 + """, + tmp_path, + )