Skip to content

Commit c5fe142

Browse files
jahnvi480Copilot
andcommitted
CHORE: Harden native metadata regression coverage
Require successful scalar NULL handling and exact describe counts. Add production-header cache, failure, concurrency, allocation and lifetime tests with active Release assertions, plus Windows/Linux/macOS CTest CI and test guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent abd4f4b commit c5fe142

6 files changed

Lines changed: 345 additions & 12 deletions

File tree

‎.github/prompts/run-tests.prompt.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,27 @@ Help the developer run tests to validate their changes. Follow this process base
122122

123123
## STEP 1: Choose What to Test
124124

125+
### Native metadata invariants (no database)
126+
127+
The standalone CMake tests in `tests/native` exercise the production metadata
128+
cache and child-handle invalidation helper without importing the Python package
129+
or connecting to SQL Server. They require a C++17 compiler, CMake, and ODBC
130+
headers (Windows SDK, `unixodbc-dev` on Linux, or `unixodbc` on macOS).
131+
The Native Metadata Tests workflow runs them on Windows, Linux, and macOS.
132+
133+
```bash
134+
cmake -S tests/native -B build/native-metadata -DCMAKE_BUILD_TYPE=Release
135+
cmake --build build/native-metadata --config Release --parallel 2
136+
ctest --test-dir build/native-metadata -C Release --output-on-failure
137+
```
138+
139+
Assertions remain enabled in Release. Cases cover stale-generation rejection,
140+
held snapshots, failure/EOF guards, concurrent invalidation, child isolation,
141+
reserve failure before strong-reference acquisition, and last-owner destruction
142+
outside the child-list lock. These native checks supplement, not replace, the
143+
live transaction tests, which skip when the driver does not preserve cursors.
144+
Native-only tests do not require the Python-test prerequisites above.
145+
125146
### Test Categories
126147

127148
| Category | Description | When to Use |
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Native Metadata Tests
2+
3+
on:
4+
pull_request:
5+
types: [opened, reopened, synchronize, ready_for_review]
6+
paths:
7+
- 'mssql_python/pybind/result_metadata.hpp'
8+
- 'mssql_python/pybind/connection/connection.cpp'
9+
- 'tests/native/**'
10+
- '.github/workflows/native-metadata-tests.yml'
11+
push:
12+
branches: [main]
13+
paths:
14+
- 'mssql_python/pybind/result_metadata.hpp'
15+
- 'mssql_python/pybind/connection/connection.cpp'
16+
- 'tests/native/**'
17+
- '.github/workflows/native-metadata-tests.yml'
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
native-metadata:
24+
name: Native metadata (${{ matrix.os }})
25+
runs-on: ${{ matrix.os }}
26+
timeout-minutes: 10
27+
strategy:
28+
fail-fast: false
29+
matrix:
30+
os: [ubuntu-latest, windows-latest, macos-latest]
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
34+
with:
35+
persist-credentials: false
36+
- name: Install ODBC headers (Linux)
37+
if: runner.os == 'Linux'
38+
run: |
39+
sudo apt-get update
40+
sudo apt-get install -y unixodbc-dev
41+
- name: Install ODBC headers (macOS)
42+
if: runner.os == 'macOS'
43+
run: brew install unixodbc
44+
- name: Configure
45+
run: cmake -S tests/native -B build/native-metadata -DCMAKE_BUILD_TYPE=Release
46+
- name: Build
47+
run: cmake --build build/native-metadata --config Release --parallel 2
48+
- name: Test
49+
run: ctest --test-dir build/native-metadata -C Release --output-on-failure

‎tests/native/CMakeLists.txt‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
cmake_minimum_required(VERSION 3.15)
2+
project(mssql_python_native_tests LANGUAGES CXX)
3+
4+
enable_testing()
5+
find_package(Threads REQUIRED)
6+
7+
add_executable(result_metadata_tests result_metadata_tests.cpp allocation_failure.cpp)
8+
target_compile_features(result_metadata_tests PRIVATE cxx_std_17)
9+
target_include_directories(result_metadata_tests PRIVATE ../../mssql_python/pybind)
10+
target_link_libraries(result_metadata_tests PRIVATE Threads::Threads)
11+
12+
if(WIN32)
13+
target_compile_definitions(result_metadata_tests PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
14+
else()
15+
find_path(ODBC_INCLUDE_DIR sql.h PATHS /opt/homebrew/include /usr/local/include)
16+
if(NOT ODBC_INCLUDE_DIR)
17+
message(FATAL_ERROR "ODBC headers are required: install unixodbc-dev or unixodbc.")
18+
endif()
19+
target_include_directories(result_metadata_tests PRIVATE "${ODBC_INCLUDE_DIR}")
20+
endif()
21+
22+
if(MSVC)
23+
target_compile_options(result_metadata_tests PRIVATE /W4 /WX /UNDEBUG)
24+
else()
25+
target_compile_options(result_metadata_tests PRIVATE -Wall -Wextra -Werror -UNDEBUG)
26+
endif()
27+
28+
foreach(case_name IN ITEMS snapshots failures concurrent children allocation last_owner)
29+
add_test(NAME result_metadata_${case_name} COMMAND result_metadata_tests ${case_name})
30+
set_tests_properties(result_metadata_${case_name} PROPERTIES TIMEOUT 20)
31+
endforeach()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
#include <cstdlib>
5+
#include <memory>
6+
#include <new>
7+
8+
struct TestHandle;
9+
extern std::weak_ptr<TestHandle> observedHandle;
10+
extern bool failAllocation;
11+
extern long ownersAtFailure;
12+
13+
// Keep replacement allocation functions opaque to optimized test call sites.
14+
void* operator new(std::size_t size) {
15+
if (failAllocation) {
16+
failAllocation = false;
17+
ownersAtFailure = observedHandle.use_count();
18+
throw std::bad_alloc();
19+
}
20+
if (void* memory = std::malloc(size ? size : 1)) {
21+
return memory;
22+
}
23+
throw std::bad_alloc();
24+
}
25+
26+
void operator delete(void* memory) noexcept {
27+
std::free(memory);
28+
}
29+
30+
void operator delete(void* memory, std::size_t) noexcept {
31+
std::free(memory);
32+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
#ifdef _WIN32
5+
#include <Windows.h>
6+
#endif
7+
#include "result_metadata.hpp"
8+
9+
#include <cassert>
10+
#include <cstdio>
11+
#include <cstring>
12+
#include <new>
13+
#include <stdexcept>
14+
#include <thread>
15+
16+
#ifdef NDEBUG
17+
#error Native metadata tests require assertions, including Release builds.
18+
#endif
19+
20+
struct TestHandle {
21+
ResultMetadataCache resultMetadata;
22+
std::mutex* childMutex = nullptr;
23+
24+
~TestHandle() {
25+
if (childMutex) {
26+
bool acquired = false;
27+
std::thread observer([&] {
28+
acquired = childMutex->try_lock();
29+
if (acquired) {
30+
childMutex->unlock();
31+
}
32+
});
33+
observer.join();
34+
assert(acquired);
35+
}
36+
}
37+
};
38+
39+
std::weak_ptr<TestHandle> observedHandle;
40+
bool failAllocation = false;
41+
long ownersAtFailure = -1;
42+
43+
static std::shared_ptr<const ResultMetadata> MakeMetadata() {
44+
auto metadata = std::make_shared<ResultMetadata>();
45+
metadata->columns.push_back({u"owned", SQL_INTEGER, 10, 0, 1});
46+
return metadata;
47+
}
48+
49+
static void Populate(ResultMetadataCache& cache) {
50+
const auto snapshot = cache.snapshot();
51+
cache.publish(snapshot.generation, MakeMetadata());
52+
}
53+
54+
static void TestSnapshots() {
55+
ResultMetadataCache cache;
56+
const auto initial = cache.snapshot();
57+
assert(!initial.metadata);
58+
auto metadata = MakeMetadata();
59+
std::weak_ptr<const ResultMetadata> weak = metadata;
60+
cache.publish(initial.generation, metadata);
61+
auto held = cache.snapshot();
62+
assert(held.metadata == metadata);
63+
cache.clear();
64+
assert(!cache.snapshot().metadata);
65+
assert(cache.snapshot().generation != initial.generation);
66+
67+
Populate(cache);
68+
const auto replacement = cache.snapshot();
69+
cache.publish(initial.generation, metadata);
70+
assert(cache.snapshot().metadata == replacement.metadata);
71+
assert(held.metadata->columns.at(0).name == u"owned");
72+
metadata.reset();
73+
assert(!weak.expired());
74+
held.metadata.reset();
75+
assert(weak.expired());
76+
}
77+
78+
static void TestFailures() {
79+
ResultMetadataCache cache;
80+
const SQLRETURN results[] = {SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_NO_DATA,
81+
SQL_ERROR, SQL_INVALID_HANDLE};
82+
for (SQLRETURN result : results) {
83+
Populate(cache);
84+
const auto before = cache.snapshot();
85+
{
86+
ResultMetadataFailureGuard guard(cache, result);
87+
}
88+
const auto after = cache.snapshot();
89+
if (SQL_SUCCEEDED(result) || result == SQL_NO_DATA) {
90+
assert(after.metadata == before.metadata);
91+
assert(after.generation == before.generation);
92+
} else {
93+
assert(!after.metadata);
94+
assert(after.generation != before.generation);
95+
}
96+
}
97+
Populate(cache);
98+
SQLRETURN result = SQL_SUCCESS;
99+
try {
100+
ResultMetadataFailureGuard guard(cache, result);
101+
throw std::runtime_error("conversion failure");
102+
} catch (const std::runtime_error&) {
103+
assert(!cache.snapshot().metadata);
104+
}
105+
}
106+
107+
static void TestConcurrentInvalidation() {
108+
ResultMetadataCache cache;
109+
const auto metadata = MakeMetadata();
110+
std::thread invalidator([&] {
111+
for (int i = 0; i < 1000; ++i) {
112+
cache.clear();
113+
}
114+
});
115+
for (int i = 0; i < 1000; ++i) {
116+
const auto snapshot = cache.snapshot();
117+
cache.publish(snapshot.generation, metadata);
118+
if (snapshot.metadata) {
119+
assert(snapshot.metadata->columns.at(0).name == u"owned");
120+
}
121+
}
122+
invalidator.join();
123+
cache.clear();
124+
assert(!cache.snapshot().metadata);
125+
}
126+
127+
static void TestChildren() {
128+
std::mutex childMutex;
129+
auto first = std::make_shared<TestHandle>();
130+
auto second = std::make_shared<TestHandle>();
131+
auto unrelated = std::make_shared<TestHandle>();
132+
std::vector<std::weak_ptr<TestHandle>> children{first, {}, second};
133+
Populate(first->resultMetadata);
134+
Populate(second->resultMetadata);
135+
Populate(unrelated->resultMetadata);
136+
const auto held = first->resultMetadata.snapshot();
137+
ClearChildResultMetadata(childMutex, children);
138+
assert(!first->resultMetadata.snapshot().metadata);
139+
assert(!second->resultMetadata.snapshot().metadata);
140+
assert(unrelated->resultMetadata.snapshot().metadata);
141+
assert(held.metadata->columns.at(0).name == u"owned");
142+
ClearChildResultMetadata(childMutex, children);
143+
}
144+
145+
static void TestAllocationFailure() {
146+
std::mutex childMutex;
147+
auto owner = std::make_shared<TestHandle>();
148+
observedHandle = owner;
149+
std::vector<std::weak_ptr<TestHandle>> children{owner};
150+
Populate(owner->resultMetadata);
151+
const auto before = owner->resultMetadata.snapshot();
152+
failAllocation = true;
153+
try {
154+
ClearChildResultMetadata(childMutex, children);
155+
assert(false);
156+
} catch (const std::bad_alloc&) {
157+
assert(ownersAtFailure == 1);
158+
assert(owner->resultMetadata.snapshot().metadata == before.metadata);
159+
assert(childMutex.try_lock());
160+
childMutex.unlock();
161+
}
162+
assert(!failAllocation);
163+
ClearChildResultMetadata(childMutex, children);
164+
assert(!owner->resultMetadata.snapshot().metadata);
165+
}
166+
167+
static void TestLastOwner() {
168+
std::mutex childMutex;
169+
auto owner = std::make_shared<TestHandle>();
170+
owner->childMutex = &childMutex;
171+
const std::weak_ptr<TestHandle> weak = owner;
172+
std::vector<std::weak_ptr<TestHandle>> children{owner};
173+
// Drop the external owner during invalidation, leaving only the helper's snapshot.
174+
auto metadata = std::shared_ptr<ResultMetadata>(new ResultMetadata, [&](auto* value) {
175+
owner.reset();
176+
delete value;
177+
});
178+
const auto generation = owner->resultMetadata.snapshot().generation;
179+
owner->resultMetadata.publish(generation, std::move(metadata));
180+
ClearChildResultMetadata(childMutex, children);
181+
assert(!owner && weak.expired());
182+
}
183+
184+
int main(int argc, char** argv) {
185+
if (argc != 2) {
186+
std::fputs("Expected one native metadata test case\n", stderr);
187+
return 2;
188+
}
189+
const char* name = argv[1];
190+
if (std::strcmp(name, "snapshots") == 0) {
191+
TestSnapshots();
192+
} else if (std::strcmp(name, "failures") == 0) {
193+
TestFailures();
194+
} else if (std::strcmp(name, "concurrent") == 0) {
195+
TestConcurrentInvalidation();
196+
} else if (std::strcmp(name, "children") == 0) {
197+
TestChildren();
198+
} else if (std::strcmp(name, "allocation") == 0) {
199+
TestAllocationFailure();
200+
} else if (std::strcmp(name, "last_owner") == 0) {
201+
TestLastOwner();
202+
} else {
203+
std::fprintf(stderr, "Unknown native metadata test case: %s\n", name);
204+
return 2;
205+
}
206+
std::printf("%s passed\n", name);
207+
return 0;
208+
}

‎tests/test_040_fetch_native_metadata.py‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def test_result_metadata_transaction_preserved_cursor(metadata_cursor, operation
485485
else mssql_python.SQL_CURSOR_COMMIT_BEHAVIOR
486486
)
487487
if connection.getinfo(info) != 2: # SQL_CB_PRESERVE
488-
pytest.skip("Driver does not preserve cursors; native helper coverage is required")
488+
pytest.skip("Driver does not preserve cursors; cache/helper coverage is in tests/native")
489489
cursor.execute(_query(["id"], 3))
490490
_assert_rows([cursor.fetchone()], [(1,)])
491491
if operation == "autocommit":
@@ -734,15 +734,9 @@ def test_result_metadata_all_null_rows(tmp_path, method):
734734
scalar_null = []
735735
scalar_status = native.DDBCSQLFetchOne(cursor.hstmt, scalar_null)
736736
diagnostics = native.DDBCSQLGetAllDiagRecords(cursor.hstmt)
737+
assert scalar_status == 0, (scalar_status, diagnostics)
737738
assert scalar_null == [None]
738-
if scalar_status == 0:
739-
assert diagnostics == []
740-
recovery_descriptions = 0
741-
else:
742-
assert scalar_status == -1
743-
assert len(diagnostics) == 1 and "22002" in diagnostics[0][0], diagnostics
744-
assert "Indicator variable required but not supplied" in diagnostics[0][1]
745-
recovery_descriptions = 4 if {method!r} == "many" else 2
739+
assert diagnostics == []
746740
values = ",".join(f"({{i}})" for i in range(1, 16))
747741
cursor.execute(
748742
"SELECT CASE WHEN id%7=0 THEN NULL ELSE id END AS c0,"
@@ -778,9 +772,7 @@ def test_result_metadata_all_null_rows(tmp_path, method):
778772
]
779773
if profiling:
780774
stats = native.profiling.get_stats()
781-
# Only drivers/builds reporting a real scalar NULL error
782-
# require the additional post-error cache repopulations.
783-
expected_describes = (16 if {method!r} == "one" else 17) + recovery_descriptions
775+
expected_describes = 16 if {method!r} == "one" else 17
784776
assert stats["ddbc::SQLDescribeCol::driver_call"]["calls"] == expected_describes, stats
785777
assert stats["ddbc::sql_variant::null_probe"]["calls"] == 15
786778
assert stats["ddbc::sql_variant::subtype"]["calls"] == 13

0 commit comments

Comments
 (0)