diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..24eb325f --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,98 @@ +# Firebird ODBC Driver — Test Suite +# +# Standalone CMake project for the Google Test-based test suite. +# Tests the driver through the standard ODBC API (via the Driver Manager). +# +# Prerequisites: +# - The Firebird ODBC driver must be pre-built and registered as an ODBC +# data source (or referenced via a Driver= connection string). +# - A Firebird database must be running and accessible. +# - The FIREBIRD_ODBC_CONNECTION environment variable must be set. +# - Google Test must be installed (e.g., via vcpkg: vcpkg install gtest) +# +# Build and run: +# cmake -B build-tests -S tests +# cmake --build build-tests --config Release +# set FIREBIRD_ODBC_CONNECTION=Driver={Firebird ODBC Driver};Dbname=localhost:C:\path\to\test.fdb;Uid=SYSDBA;Pwd=masterkey; +# ctest --test-dir build-tests --output-on-failure + +cmake_minimum_required(VERSION 3.14) +project(FirebirdOdbcTests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# --------------------------------------------------------------------------- +# Find Google Test +# --------------------------------------------------------------------------- +find_package(GTest CONFIG REQUIRED) + +# --------------------------------------------------------------------------- +# Find ODBC +# --------------------------------------------------------------------------- +find_package(ODBC REQUIRED) + +# --------------------------------------------------------------------------- +# Enable CTest +# --------------------------------------------------------------------------- +include(GoogleTest) +enable_testing() + +# --------------------------------------------------------------------------- +# Test executable +# --------------------------------------------------------------------------- +add_executable(firebird_odbc_tests + test_main.cpp + + # Category A — tests expected to pass on vanilla master + test_data_types.cpp + test_result_conversions.cpp + test_param_conversions.cpp + test_prepare.cpp + test_cursors.cpp + test_cursor_commit.cpp + test_cursor_name.cpp + test_data_at_execution.cpp + test_array_binding.cpp + test_bindcol.cpp + test_descrec.cpp + test_blob.cpp + test_multi_statement.cpp + test_stmthandles.cpp + test_wchar.cpp + test_escape_sequences.cpp + + # Category B — mixed pass/skip + test_descriptor.cpp + test_connect_options.cpp + test_errors.cpp + test_catalogfunctions.cpp + test_server_version.cpp + test_scrollable_cursor.cpp + + # Category C — all tests SKIP'd (features not yet on upstream master) + test_null_handles.cpp + test_savepoint.cpp + test_conn_settings.cpp + test_odbc38_compliance.cpp + test_guid_and_binary.cpp + test_odbc_string.cpp + # NOTE: test_phase7_crusher_fixes.cpp and test_phase11_typeinfo_timeout_pool.cpp + # are NOT included here — their tests are already present (with GTEST_SKIP markers) + # in the Category B files (test_descriptor.cpp, test_connect_options.cpp, + # test_errors.cpp, test_catalogfunctions.cpp). The phase-specific files exist as + # documentation of which tests belong to which improvement phase. +) + +# --------------------------------------------------------------------------- +# Link against Google Test and the ODBC Driver Manager +# --------------------------------------------------------------------------- +target_link_libraries(firebird_odbc_tests PRIVATE GTest::gtest ODBC::ODBC) + +# --------------------------------------------------------------------------- +# Discover tests for CTest +# --------------------------------------------------------------------------- +gtest_discover_tests(firebird_odbc_tests + DISCOVERY_TIMEOUT 30 + DISCOVERY_MODE PRE_TEST +) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..cf882eb3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,157 @@ +# Firebird ODBC Driver — Test Suite + +Google Test-based test suite for the Firebird ODBC driver. Tests the driver through the **standard ODBC API** via the Driver Manager, connecting to a real Firebird database. + +## Prerequisites + +1. **Firebird Server** — a running Firebird 3.0+ instance with a test database +2. **Firebird ODBC Driver** — pre-built and registered as an ODBC data source (or referenced via a `Driver=` connection string) +3. **CMake** ≥ 3.14 +4. **C++17 compiler** (MSVC 2022, GCC 9+, Clang 10+) +5. **Google Test** — installed and discoverable via `find_package(GTest CONFIG REQUIRED)` + +### Windows-specific + +- The driver DLL (built from `Builds/MsVc2022.win/OdbcFb.sln`) must be registered via `odbcconf` or the ODBC Data Source Administrator. +- Google Test can be installed via [vcpkg](https://vcpkg.io/): `vcpkg install gtest:x64-windows`, then pass `-DCMAKE_TOOLCHAIN_FILE=.../vcpkg/scripts/buildsystems/vcpkg.cmake` to CMake. Or build from source and pass `-DCMAKE_PREFIX_PATH=`. + +### Linux-specific + +- `unixODBC` development headers (`unixodbc-dev` / `unixODBC-devel`) +- The driver `.so` must be registered in `/etc/odbcinst.ini` +- Google Test can be installed via your package manager: `apt install libgtest-dev` / `dnf install gtest-devel` + +## Building the tests + +The test suite is a standalone CMake project. Google Test and ODBC are found via `find_package()`. + +```bash +# From the repository root: +cmake -B build-tests -S tests +cmake --build build-tests --config Release +``` + +## Running the tests + +Set the `FIREBIRD_ODBC_CONNECTION` environment variable with a valid ODBC connection string, then run with CTest: + +### Windows (PowerShell) + +```powershell +$env:FIREBIRD_ODBC_CONNECTION = "Driver={Firebird ODBC Driver};Dbname=localhost:C:\path\to\test.fdb;Uid=SYSDBA;Pwd=masterkey;" +ctest --test-dir build-tests --output-on-failure -C Release +``` + +### Windows (cmd.exe) + +```cmd +set FIREBIRD_ODBC_CONNECTION=Driver={Firebird ODBC Driver};Dbname=localhost:C:\path\to\test.fdb;Uid=SYSDBA;Pwd=masterkey; +ctest --test-dir build-tests --output-on-failure -C Release +``` + +### Linux + +```bash +export FIREBIRD_ODBC_CONNECTION="Driver=Firebird;Dbname=localhost:/path/to/test.fdb;Uid=SYSDBA;Pwd=masterkey;" +ctest --test-dir build-tests --output-on-failure +``` + +### Running the test executable directly + +```bash +./build-tests/firebird_odbc_tests --gtest_output=xml:test-results.xml +``` + +## Test categories + +Tests are organized into three categories based on their expected behavior against the **current upstream driver** (vanilla master): + +### Category A — Pass (~166 tests) + +Standard ODBC functionality that the current driver supports. These tests are expected to **pass** on vanilla master. + +| File | Tests | Description | +|------|-------|-------------| +| `test_data_types.cpp` | ~18 | SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE, NUMERIC, VARCHAR, DATE, TIME, TIMESTAMP | +| `test_result_conversions.cpp` | ~35 | SQLGetData type conversions | +| `test_param_conversions.cpp` | ~18 | SQLBindParameter type conversions | +| `test_prepare.cpp` | ~10 | SQLPrepare / SQLExecute lifecycle | +| `test_cursors.cpp` | ~7 | Cursor behavior, commit/rollback, close/re-execute | +| `test_cursor_commit.cpp` | ~6 | Cursor behavior across transactions | +| `test_cursor_name.cpp` | ~9 | SQLSetCursorName / SQLGetCursorName | +| `test_data_at_execution.cpp` | ~6 | SQL_DATA_AT_EXEC / SQLPutData | +| `test_array_binding.cpp` | ~17 | Column-wise + row-wise parameter arrays | +| `test_bindcol.cpp` | ~5 | Dynamic unbind/rebind mid-fetch | +| `test_descrec.cpp` | ~10 | SQLGetDescRec for all column types | +| `test_blob.cpp` | ~3 | Small/large/null BLOB read/write | +| `test_multi_statement.cpp` | ~4 | Multiple statement handles on one connection | +| `test_stmthandles.cpp` | ~4 | 100+ simultaneous statement handles | +| `test_wchar.cpp` | ~8 | SQL_C_WCHAR bind/fetch, truncation | +| `test_escape_sequences.cpp` | ~6 | Escape sequence passthrough | + +### Category B — Mixed pass/skip (~109 tests) + +Files containing a mix of passing tests and tests that require driver improvements. Individual tests that depend on future fixes are marked with `GTEST_SKIP()`. + +| File | Total | Pass | Skip | Reason for skip | +|------|-------|------|------|-----------------| +| `test_descriptor.cpp` | ~13 | ~6 | ~7 | Phase 7 (OC-1): SQLCopyDesc crash, SetDescCount allocation | +| `test_connect_options.cpp` | ~36 | ~6 | ~30 | Phase 7/11: CONNECTION_TIMEOUT, ASYNC_ENABLE, QUERY_TIMEOUT, RESET_CONNECTION | +| `test_errors.cpp` | ~18 | ~11 | ~7 | Phase 7 (OC-2/OC-5): DiagRowCount, TruncationIndicator | +| `test_catalogfunctions.cpp` | ~29 | ~26 | ~3 | Phase 11: TypeInfo ordering, GUID searchability, BINARY dedup | +| `test_server_version.cpp` | ~6 | ~4 | ~2 | Phase 4: FB4+ type count in SQLGetTypeInfo | +| `test_scrollable_cursor.cpp` | ~9 | ~5 | ~4 | Phase 4: Scrollable cursor edge cases | + +### Category C — All skipped (~167 tests) + +These files test features that don't exist on vanilla master. Every test is marked with `GTEST_SKIP()`. + +| File | Tests | Reason | +|------|-------|--------| +| `test_null_handles.cpp` | ~65 | Phase 0: NULL handle crash prevention | +| `test_savepoint.cpp` | ~4 | Phase 4: Savepoint isolation | +| `test_conn_settings.cpp` | ~3 | Phase 4: ConnSettings DSN attribute | +| `test_odbc38_compliance.cpp` | ~12 | Phase 8: ODBC 3.8 features | +| `test_guid_and_binary.cpp` | ~14 | Phase 8: SQL_GUID and FB4+ types | +| `test_odbc_string.cpp` | ~26 | Phase 12: OdbcString class (guarded with `__has_include`) | +| `test_phase7_crusher_fixes.cpp` | ~22 | Phase 7: ODBC Crusher bug fixes | +| `test_phase11_typeinfo_timeout_pool.cpp` | ~21 | Phase 11: TypeInfo, timeout, connection pool | + +### Excluded + +| File | Reason | +|------|--------| +| `bench_fetch.cpp` | Benchmark, not a test — deferred to performance work | + +## Expected output + +When running against vanilla master, you should see output like: + +``` +[==========] N tests from M test suites ran. +[ PASSED ] ~ tests. +[ SKIPPED ] ~ tests. +[ FAILED ] 0 tests. +``` + +**Zero failures** are expected. Tests that would fail on vanilla master are pre-emptively SKIP'd. As driver improvements are merged in future PRs, the corresponding `GTEST_SKIP()` markers will be removed, turning those tests into actual pass/fail tests. + +## Connection string format + +The `FIREBIRD_ODBC_CONNECTION` environment variable should contain a standard ODBC connection string. Common parameters: + +| Parameter | Example | Description | +|-----------|---------|-------------| +| `Driver` | `{Firebird ODBC Driver}` | Registered driver name | +| `Dbname` | `localhost:C:\data\test.fdb` | Server:path to database | +| `Uid` | `SYSDBA` | Username | +| `Pwd` | `masterkey` | Password | +| `Role` | `RDB$ADMIN` | Role (optional) | +| `CharacterSet` | `UTF8` | Character set (optional) | + +## Architecture + +- Tests link against the **ODBC Driver Manager** (`odbc32`/`odbccp32` on Windows, `libodbc` on Linux) via `find_package(ODBC REQUIRED)` +- They do **not** link against the driver DLL directly (except `test_null_handles.cpp` which uses `LoadLibrary`) +- The driver must be registered as an ODBC driver for the connection string to work +- Google Test is found via `find_package(GTest CONFIG REQUIRED)` — must be pre-installed diff --git a/tests/test_array_binding.cpp b/tests/test_array_binding.cpp new file mode 100644 index 00000000..2f7808fa --- /dev/null +++ b/tests/test_array_binding.cpp @@ -0,0 +1,986 @@ +// test_array_binding.cpp — Tests for ODBC "Array of Parameter Values" +// Ported from psqlodbc arraybinding-test.c and params-batch-exec-test.c +// Covers: SQL_ATTR_PARAMSET_SIZE, SQL_ATTR_PARAM_BIND_TYPE, +// SQL_ATTR_PARAM_STATUS_PTR, SQL_ATTR_PARAMS_PROCESSED_PTR, +// SQL_ATTR_PARAM_OPERATION_PTR, column-wise binding, row-wise binding +#include "test_helpers.h" + +#include +#include + +// ============================================================================ +// ArrayBindingTest: ODBC Array of Parameter Values +// ============================================================================ +class ArrayBindingTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (hDbc == SQL_NULL_HDBC) return; + + ExecIgnoreError("DROP TABLE ARRAY_BIND_TEST"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE ARRAY_BIND_TEST (I INTEGER NOT NULL, T VARCHAR(100))"); + Commit(); + ReallocStmt(); + } + + void TearDown() override { + if (hDbc != SQL_NULL_HDBC) { + ExecIgnoreError("DROP TABLE ARRAY_BIND_TEST"); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + } + OdbcConnectedTest::TearDown(); + } + + // Helper: count rows in the table + int CountRows() { + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLExecDirect(hStmt2, (SQLCHAR*)"SELECT COUNT(*) FROM ARRAY_BIND_TEST", SQL_NTS); + SQLINTEGER count = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt2, 1, SQL_C_SLONG, &count, sizeof(count), &ind); + SQLFetch(hStmt2); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + return count; + } + + // Helper: get a value for a specific row + std::string GetValue(int id) { + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLCHAR sql[256]; + sprintf((char*)sql, "SELECT T FROM ARRAY_BIND_TEST WHERE I = %d", id); + SQLExecDirect(hStmt2, sql, SQL_NTS); + SQLCHAR buf[101] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt2, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + SQLRETURN r = SQLFetch(hStmt2); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + if (r == SQL_NO_DATA) return ""; + return std::string((char*)buf); + } +}; + +// ============================================================================ +// 1. Column-wise binding — basic INSERT +// (ported from psqlodbc arraybinding-test.c test 1) +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWiseInsert) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 100; + SQLRETURN ret; + + SQLUINTEGER int_array[ARRAY_SIZE]; + SQLCHAR str_array[ARRAY_SIZE][30]; + SQLLEN int_ind_array[ARRAY_SIZE]; + SQLLEN str_ind_array[ARRAY_SIZE]; + SQLUSMALLINT status_array[ARRAY_SIZE]; + SQLULEN nprocessed = 0; + + for (int i = 0; i < ARRAY_SIZE; i++) { + int_array[i] = i; + int_ind_array[i] = 0; + sprintf((char*)str_array[i], "columnwise %d", i); + str_ind_array[i] = SQL_NTS; + } + + // Column-wise binding (the default) + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_ULONG, SQL_INTEGER, 5, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_CHAR, 29, 0, + str_array, 30, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Verify all rows were processed + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + + // Verify no errors in status array + int errorCount = 0; + for (int i = 0; i < (int)nprocessed; i++) { + if (status_array[i] != SQL_PARAM_SUCCESS && status_array[i] != SQL_PARAM_SUCCESS_WITH_INFO) { + errorCount++; + } + } + EXPECT_EQ(errorCount, 0); + + Commit(); + + // Verify row count + EXPECT_EQ(CountRows(), ARRAY_SIZE); + + // Verify some specific values + EXPECT_EQ(GetValue(0), "columnwise 0"); + EXPECT_EQ(GetValue(1), "columnwise 1"); + EXPECT_EQ(GetValue(50), "columnwise 50"); + EXPECT_EQ(GetValue(99), "columnwise 99"); +} + +// ============================================================================ +// 2. Column-wise binding — using SQLPrepare + SQLExecute +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWisePrepareExecute) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 10; + SQLRETURN ret; + + SQLINTEGER int_array[ARRAY_SIZE]; + SQLCHAR str_array[ARRAY_SIZE][20]; + SQLLEN int_ind_array[ARRAY_SIZE]; + SQLLEN str_ind_array[ARRAY_SIZE]; + SQLUSMALLINT status_array[ARRAY_SIZE]; + SQLULEN nprocessed = 0; + + for (int i = 0; i < ARRAY_SIZE; i++) { + int_array[i] = (i + 1) * 10; + int_ind_array[i] = 0; + sprintf((char*)str_array[i], "prep %d", i); + str_ind_array[i] = SQL_NTS; + } + + // Set PARAMSET_SIZE AFTER SQLPrepare to test execute-time routing + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Prepare first — PARAMSET_SIZE is still 1 at this point + ret = SQLPrepare(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // NOW set PARAMSET_SIZE > 1 AFTER prepare + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + + for (int i = 0; i < (int)nprocessed; i++) { + EXPECT_TRUE(status_array[i] == SQL_PARAM_SUCCESS || + status_array[i] == SQL_PARAM_SUCCESS_WITH_INFO) + << "Row " << i << " status: " << status_array[i]; + } + + Commit(); + EXPECT_EQ(CountRows(), ARRAY_SIZE); + EXPECT_EQ(GetValue(10), "prep 0"); + EXPECT_EQ(GetValue(100), "prep 9"); +} + +// ============================================================================ +// 3. Row-wise binding — basic INSERT +// ============================================================================ +TEST_F(ArrayBindingTest, RowWiseInsert) { + const int ARRAY_SIZE = 5; + SQLRETURN ret; + + struct ParamRow { + SQLINTEGER i; + SQLLEN iInd; + SQLCHAR t[51]; + SQLLEN tInd; + }; + + ParamRow rows[ARRAY_SIZE] = {}; + rows[0] = {1, 0, "Alpha", SQL_NTS}; + rows[1] = {2, 0, "Bravo", SQL_NTS}; + rows[2] = {3, 0, "Charlie", SQL_NTS}; + rows[3] = {4, 0, "Delta", SQL_NTS}; + rows[4] = {5, 0, "Echo", SQL_NTS}; + + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, (SQLPOINTER)(intptr_t)sizeof(ParamRow), 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLPrepare(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &rows[0].i, sizeof(rows[0].i), &rows[0].iInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 50, 0, rows[0].t, 51, &rows[0].tInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + for (int i = 0; i < ARRAY_SIZE; i++) { + EXPECT_TRUE(status_array[i] == SQL_PARAM_SUCCESS || + status_array[i] == SQL_PARAM_SUCCESS_WITH_INFO) + << "Row " << i << " status: " << status_array[i]; + } + + Commit(); + EXPECT_EQ(CountRows(), ARRAY_SIZE); + EXPECT_EQ(GetValue(1), "Alpha"); + EXPECT_EQ(GetValue(3), "Charlie"); + EXPECT_EQ(GetValue(5), "Echo"); +} + +// ============================================================================ +// 4. Column-wise binding with NULL values +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWiseWithNulls) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 5; + SQLRETURN ret; + + SQLINTEGER int_array[ARRAY_SIZE] = {1, 2, 3, 4, 5}; + SQLCHAR str_array[ARRAY_SIZE][20] = {"one", "", "three", "", "five"}; + SQLLEN int_ind_array[ARRAY_SIZE] = {0, 0, 0, 0, 0}; + SQLLEN str_ind_array[ARRAY_SIZE] = {SQL_NTS, SQL_NULL_DATA, SQL_NTS, SQL_NULL_DATA, SQL_NTS}; + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + Commit(); + EXPECT_EQ(CountRows(), ARRAY_SIZE); + + // Non-null rows + EXPECT_EQ(GetValue(1), "one"); + EXPECT_EQ(GetValue(3), "three"); + EXPECT_EQ(GetValue(5), "five"); + + // Null rows — GetValue returns "" for both NULL and not-found + // Verify with explicit NULL check + { + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLExecDirect(hStmt2, (SQLCHAR*)"SELECT T FROM ARRAY_BIND_TEST WHERE I = 2", SQL_NTS); + SQLCHAR buf[20] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt2, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + SQLRETURN r = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(r)); + EXPECT_EQ(ind, SQL_NULL_DATA); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + } +} + +// ============================================================================ +// 5. SQL_ATTR_PARAM_OPERATION_PTR — skip individual rows +// ============================================================================ +TEST_F(ArrayBindingTest, ParamOperationPtrSkipRows) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 5; + SQLRETURN ret; + + SQLINTEGER int_array[ARRAY_SIZE] = {10, 20, 30, 40, 50}; + SQLCHAR str_array[ARRAY_SIZE][20] = {"A", "B", "C", "D", "E"}; + SQLLEN int_ind_array[ARRAY_SIZE] = {0, 0, 0, 0, 0}; + SQLLEN str_ind_array[ARRAY_SIZE] = {SQL_NTS, SQL_NTS, SQL_NTS, SQL_NTS, SQL_NTS}; + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + // Skip rows 1 (i=20) and 3 (i=40) + SQLUSMALLINT operation_array[ARRAY_SIZE] = { + SQL_PARAM_PROCEED, // row 0: i=10 — process + SQL_PARAM_IGNORE, // row 1: i=20 — skip + SQL_PARAM_PROCEED, // row 2: i=30 — process + SQL_PARAM_IGNORE, // row 3: i=40 — skip + SQL_PARAM_PROCEED // row 4: i=50 — process + }; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_OPERATION_PTR, operation_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Only 3 rows should have been processed + EXPECT_EQ(nprocessed, (SQLULEN)3); + + // Skipped rows should remain SQL_PARAM_UNUSED + EXPECT_TRUE(status_array[0] == SQL_PARAM_SUCCESS || status_array[0] == SQL_PARAM_SUCCESS_WITH_INFO); + EXPECT_EQ(status_array[1], SQL_PARAM_UNUSED); + EXPECT_TRUE(status_array[2] == SQL_PARAM_SUCCESS || status_array[2] == SQL_PARAM_SUCCESS_WITH_INFO); + EXPECT_EQ(status_array[3], SQL_PARAM_UNUSED); + EXPECT_TRUE(status_array[4] == SQL_PARAM_SUCCESS || status_array[4] == SQL_PARAM_SUCCESS_WITH_INFO); + + Commit(); + + // Only 3 rows inserted (10, 30, 50) + EXPECT_EQ(CountRows(), 3); + EXPECT_EQ(GetValue(10), "A"); + EXPECT_EQ(GetValue(30), "C"); + EXPECT_EQ(GetValue(50), "E"); + + // Skipped rows should not exist + EXPECT_EQ(GetValue(20), ""); + EXPECT_EQ(GetValue(40), ""); +} + +// ============================================================================ +// 6. Large array — column-wise (like psqlodbc's 10000-row test) +// ============================================================================ +TEST_F(ArrayBindingTest, LargeColumnWiseArray) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 1000; + SQLRETURN ret; + + std::vector int_array(ARRAY_SIZE); + std::vector> str_array(ARRAY_SIZE); + std::vector int_ind_array(ARRAY_SIZE, 0); + std::vector str_ind_array(ARRAY_SIZE, SQL_NTS); + std::vector status_array(ARRAY_SIZE, 0); + SQLULEN nprocessed = 0; + + for (int i = 0; i < ARRAY_SIZE; i++) { + int_array[i] = i; + sprintf((char*)str_array[i].data(), "row %d", i); + } + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array.data(), 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_ULONG, SQL_INTEGER, 5, 0, + int_array.data(), sizeof(SQLUINTEGER), int_ind_array.data()); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_CHAR, 39, 0, + str_array.data(), 40, str_ind_array.data()); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + + int errorCount = 0; + for (int i = 0; i < (int)nprocessed; i++) { + if (status_array[i] != SQL_PARAM_SUCCESS && status_array[i] != SQL_PARAM_SUCCESS_WITH_INFO) + errorCount++; + } + EXPECT_EQ(errorCount, 0); + + Commit(); + EXPECT_EQ(CountRows(), ARRAY_SIZE); + + // Spot-check values + EXPECT_EQ(GetValue(0), "row 0"); + EXPECT_EQ(GetValue(500), "row 500"); + EXPECT_EQ(GetValue(999), "row 999"); +} + +// ============================================================================ +// 7. Re-execute array batch with different data +// (from psqlodbc params-batch-exec-test.c) +// ============================================================================ +TEST_F(ArrayBindingTest, ReExecuteWithDifferentData) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int BATCH_SIZE = 5; + SQLRETURN ret; + + SQLINTEGER int_array[BATCH_SIZE] = {1, 2, 3, 4, 5}; + SQLCHAR str_array[BATCH_SIZE][20] = {"A1", "B1", "C1", "D1", "E1"}; + SQLLEN int_ind_array[BATCH_SIZE] = {0, 0, 0, 0, 0}; + SQLLEN str_ind_array[BATCH_SIZE] = {SQL_NTS, SQL_NTS, SQL_NTS, SQL_NTS, SQL_NTS}; + SQLUSMALLINT status_array[BATCH_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)BATCH_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // First execution + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(nprocessed, (SQLULEN)BATCH_SIZE); + + Commit(); + EXPECT_EQ(CountRows(), BATCH_SIZE); + + // Change data and re-execute + for (int i = 0; i < BATCH_SIZE; i++) { + int_array[i] = (i + 1) * 100; + sprintf((char*)str_array[i], "re-exec %d", i); + } + + ReallocStmt(); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)BATCH_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind_array); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(nprocessed, (SQLULEN)BATCH_SIZE); + + Commit(); + EXPECT_EQ(CountRows(), 2 * BATCH_SIZE); + EXPECT_EQ(GetValue(100), "re-exec 0"); + EXPECT_EQ(GetValue(500), "re-exec 4"); +} + +// ============================================================================ +// 8. New handle required after array execution for non-array queries +// (from psqlodbc arraybinding-test.c: "parameters set with +// SQLSetStmtAttr survive SQLFreeStmt") +// ============================================================================ +TEST_F(ArrayBindingTest, NewHandleAfterArrayExec) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 3; + SQLRETURN ret; + + SQLINTEGER int_array[ARRAY_SIZE] = {1, 2, 3}; + SQLCHAR str_array[ARRAY_SIZE][10] = {"a", "b", "c"}; + SQLLEN int_ind[ARRAY_SIZE] = {0, 0, 0}; + SQLLEN str_ind[ARRAY_SIZE] = {SQL_NTS, SQL_NTS, SQL_NTS}; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 9, 0, + str_array, 10, str_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + Commit(); + + // Free and allocate a new handle for non-array query + // (psqlodbc: "parameters set with SQLSetStmtAttr survive SQLFreeStmt") + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Non-array SELECT + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT COUNT(*) FROM ARRAY_BIND_TEST", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = 0; + SQLLEN countInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, sizeof(count), &countInd); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, ARRAY_SIZE); +} + +// ============================================================================ +// 9. Row-wise binding with multiple data types +// ============================================================================ +TEST_F(ArrayBindingTest, RowWiseMultipleTypes) { + // Drop and recreate with additional columns + ExecIgnoreError("DROP TABLE ARRAY_BIND_TEST"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE ARRAY_BIND_TEST (I INTEGER NOT NULL, F DOUBLE PRECISION, T VARCHAR(50))"); + Commit(); + ReallocStmt(); + + const int ARRAY_SIZE = 3; + SQLRETURN ret; + + struct ParamRow { + SQLINTEGER i; + SQLLEN iInd; + SQLDOUBLE f; + SQLLEN fInd; + SQLCHAR t[51]; + SQLLEN tInd; + }; + + ParamRow rows[ARRAY_SIZE] = {}; + rows[0] = {1, 0, 3.14, 0, "pi", SQL_NTS}; + rows[1] = {2, 0, 2.718, 0, "euler", SQL_NTS}; + rows[2] = {3, 0, 1.414, 0, "sqrt2", SQL_NTS}; + + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, (SQLPOINTER)(intptr_t)sizeof(ParamRow), 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLPrepare(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, F, T) VALUES (?, ?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &rows[0].i, sizeof(rows[0].i), &rows[0].iInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_DOUBLE, SQL_DOUBLE, + 15, 0, &rows[0].f, sizeof(rows[0].f), &rows[0].fInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 3, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 50, 0, rows[0].t, 51, &rows[0].tInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + Commit(); + + // Verify + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLExecDirect(hStmt2, (SQLCHAR*)"SELECT I, F, T FROM ARRAY_BIND_TEST ORDER BY I", SQL_NTS); + SQLINTEGER ival; + SQLDOUBLE fval; + SQLCHAR tval[51]; + SQLLEN iInd, fInd, tInd; + SQLBindCol(hStmt2, 1, SQL_C_SLONG, &ival, sizeof(ival), &iInd); + SQLBindCol(hStmt2, 2, SQL_C_DOUBLE, &fval, sizeof(fval), &fInd); + SQLBindCol(hStmt2, 3, SQL_C_CHAR, tval, sizeof(tval), &tInd); + + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ival, 1); + EXPECT_NEAR(fval, 3.14, 0.001); + EXPECT_STREQ((char*)tval, "pi"); + + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ival, 2); + EXPECT_NEAR(fval, 2.718, 0.001); + EXPECT_STREQ((char*)tval, "euler"); + + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ival, 3); + EXPECT_NEAR(fval, 1.414, 0.001); + EXPECT_STREQ((char*)tval, "sqrt2"); + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); +} + +// ============================================================================ +// 10. Column-wise binding with UPDATE statement +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWiseUpdate) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + // Insert some initial data + { + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLExecDirect(hStmt2, (SQLCHAR*) + "INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (1, 'old1')", SQL_NTS); + SQLExecDirect(hStmt2, (SQLCHAR*) + "INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (2, 'old2')", SQL_NTS); + SQLExecDirect(hStmt2, (SQLCHAR*) + "INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (3, 'old3')", SQL_NTS); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + } + Commit(); + ReallocStmt(); + + const int ARRAY_SIZE = 3; + SQLRETURN ret; + + // UPDATE: SET T = ? WHERE I = ? + SQLCHAR new_vals[ARRAY_SIZE][20] = {"new1", "new2", "new3"}; + SQLINTEGER ids[ARRAY_SIZE] = {1, 2, 3}; + SQLLEN val_ind[ARRAY_SIZE] = {SQL_NTS, SQL_NTS, SQL_NTS}; + SQLLEN id_ind[ARRAY_SIZE] = {0, 0, 0}; + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + new_vals, 20, val_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + ids, sizeof(*ids), id_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*) + "UPDATE ARRAY_BIND_TEST SET T = ? WHERE I = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + Commit(); + + EXPECT_EQ(GetValue(1), "new1"); + EXPECT_EQ(GetValue(2), "new2"); + EXPECT_EQ(GetValue(3), "new3"); +} + +// ============================================================================ +// 11. Column-wise binding with DELETE statement +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWiseDelete) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + // Insert initial data + { + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + for (int i = 1; i <= 5; i++) { + SQLCHAR sql[128]; + sprintf((char*)sql, "INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (%d, 'val%d')", i, i); + SQLExecDirect(hStmt2, sql, SQL_NTS); + } + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + } + Commit(); + ReallocStmt(); + + // Delete rows 2 and 4 using array binding + const int ARRAY_SIZE = 2; + SQLINTEGER ids[ARRAY_SIZE] = {2, 4}; + SQLLEN id_ind[ARRAY_SIZE] = {0, 0}; + SQLUSMALLINT status_array[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + SQLRETURN ret; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status_array, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + ids, sizeof(*ids), id_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"DELETE FROM ARRAY_BIND_TEST WHERE I = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + Commit(); + + // Only rows 1, 3, 5 should remain + EXPECT_EQ(CountRows(), 3); + EXPECT_EQ(GetValue(1), "val1"); + EXPECT_EQ(GetValue(2), ""); // deleted + EXPECT_EQ(GetValue(3), "val3"); + EXPECT_EQ(GetValue(4), ""); // deleted + EXPECT_EQ(GetValue(5), "val5"); +} + +// ============================================================================ +// 12. PARAMSET_SIZE = 1 should behave like normal single execution +// ============================================================================ +TEST_F(ArrayBindingTest, ParamsetSizeOneIsNormal) { + SQLRETURN ret; + + // Use column-wise with size=1 — should still work + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)1, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 42; + SQLLEN idInd = 0; + SQLCHAR val[] = "single-row"; + SQLLEN valInd = SQL_NTS; + + ret = SQLPrepare(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + &id, 0, &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 50, 0, + val, sizeof(val), &valInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + Commit(); + EXPECT_EQ(CountRows(), 1); + EXPECT_EQ(GetValue(42), "single-row"); +} + +// ============================================================================ +// 13. SQLGetInfo reports SQL_PARC_BATCH for SQL_PARAM_ARRAY_ROW_COUNTS +// ============================================================================ +TEST_F(ArrayBindingTest, GetInfoParamArrayRowCounts) { + SQLUINTEGER value = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_PARAM_ARRAY_ROW_COUNTS, &value, sizeof(value), NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLUINTEGER)SQL_PARC_BATCH); +} + +// ============================================================================ +// 14. SQLGetInfo reports SQL_PAS_BATCH for SQL_PARAM_ARRAY_SELECTS +// ============================================================================ +TEST_F(ArrayBindingTest, GetInfoParamArraySelects) { + SQLUINTEGER value = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_PARAM_ARRAY_SELECTS, &value, sizeof(value), NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLUINTEGER)SQL_PAS_BATCH); +} + +// ============================================================================ +// 15. Column-wise binding with integer-only (no strings) +// ============================================================================ +TEST_F(ArrayBindingTest, ColumnWiseIntegerOnly) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + // Recreate table with integer-only columns + ExecIgnoreError("DROP TABLE ARRAY_BIND_TEST"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE ARRAY_BIND_TEST (I INTEGER NOT NULL, T INTEGER)"); + Commit(); + ReallocStmt(); + + const int ARRAY_SIZE = 10; + SQLINTEGER i_array[ARRAY_SIZE]; + SQLINTEGER t_array[ARRAY_SIZE]; + SQLLEN i_ind[ARRAY_SIZE]; + SQLLEN t_ind[ARRAY_SIZE]; + SQLULEN nprocessed = 0; + SQLRETURN ret; + + for (int i = 0; i < ARRAY_SIZE; i++) { + i_array[i] = i + 1; + t_array[i] = (i + 1) * 100; + i_ind[i] = 0; + t_ind[i] = 0; + } + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + i_array, sizeof(*i_array), i_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + t_array, sizeof(*t_array), t_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)ARRAY_SIZE); + Commit(); + + // Verify + SQLHSTMT hStmt2 = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt2); + SQLExecDirect(hStmt2, (SQLCHAR*)"SELECT I, T FROM ARRAY_BIND_TEST ORDER BY I", SQL_NTS); + SQLINTEGER ival, tval; + SQLLEN iInd2, tInd2; + SQLBindCol(hStmt2, 1, SQL_C_SLONG, &ival, sizeof(ival), &iInd2); + SQLBindCol(hStmt2, 2, SQL_C_SLONG, &tval, sizeof(tval), &tInd2); + + for (int i = 0; i < ARRAY_SIZE; i++) { + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Row " << i; + EXPECT_EQ(ival, i + 1); + EXPECT_EQ(tval, (i + 1) * 100); + } + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); +} + +// ============================================================================ +// 16. Row-wise with SQL_ATTR_PARAM_OPERATION_PTR +// ============================================================================ +TEST_F(ArrayBindingTest, RowWiseWithOperationPtr) { + GTEST_SKIP() << "Vanilla driver does not properly handle SQL_ATTR_PARAM_OPERATION_PTR"; + const int ARRAY_SIZE = 4; + SQLRETURN ret; + + struct ParamRow { + SQLINTEGER i; + SQLLEN iInd; + SQLCHAR t[21]; + SQLLEN tInd; + }; + + ParamRow rows[ARRAY_SIZE] = {}; + rows[0] = {10, 0, "row10", SQL_NTS}; + rows[1] = {20, 0, "row20", SQL_NTS}; + rows[2] = {30, 0, "row30", SQL_NTS}; + rows[3] = {40, 0, "row40", SQL_NTS}; + + SQLUSMALLINT operation[ARRAY_SIZE] = { + SQL_PARAM_PROCEED, // process + SQL_PARAM_IGNORE, // skip + SQL_PARAM_PROCEED, // process + SQL_PARAM_PROCEED // process + }; + SQLUSMALLINT status[ARRAY_SIZE] = {}; + SQLULEN nprocessed = 0; + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, (SQLPOINTER)(intptr_t)sizeof(ParamRow), 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_STATUS_PTR, status, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMS_PROCESSED_PTR, &nprocessed, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_OPERATION_PTR, operation, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLPrepare(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &rows[0].i, sizeof(rows[0].i), &rows[0].iInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 20, 0, rows[0].t, 21, &rows[0].tInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_EQ(nprocessed, (SQLULEN)3); // 3 processed, 1 skipped + EXPECT_EQ(status[1], SQL_PARAM_UNUSED); // skipped + + Commit(); + EXPECT_EQ(CountRows(), 3); + EXPECT_EQ(GetValue(10), "row10"); + EXPECT_EQ(GetValue(20), ""); // was skipped + EXPECT_EQ(GetValue(30), "row30"); + EXPECT_EQ(GetValue(40), "row40"); +} + +// ============================================================================ +// 17. Without status/processed pointers (optional per spec) +// ============================================================================ +TEST_F(ArrayBindingTest, WithoutStatusPointers) { + GTEST_SKIP() << "Crashes on vanilla master: sizeof(SQLINTEGER) vs sizeof(SQLLEN) bug in OdbcStatement.cpp line 2891"; + const int ARRAY_SIZE = 3; + SQLRETURN ret; + + SQLINTEGER int_array[ARRAY_SIZE] = {100, 200, 300}; + SQLCHAR str_array[ARRAY_SIZE][20] = {"x1", "x2", "x3"}; + SQLLEN int_ind[ARRAY_SIZE] = {0, 0, 0}; + SQLLEN str_ind[ARRAY_SIZE] = {SQL_NTS, SQL_NTS, SQL_NTS}; + + // Set PARAMSET_SIZE but NOT status/processed ptrs + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAM_BIND_TYPE, SQL_PARAM_BIND_BY_COLUMN, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(intptr_t)ARRAY_SIZE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + int_array, sizeof(*int_array), int_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 19, 0, + str_array, 20, str_ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ARRAY_BIND_TEST (I, T) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + Commit(); + EXPECT_EQ(CountRows(), ARRAY_SIZE); +} diff --git a/tests/test_bindcol.cpp b/tests/test_bindcol.cpp new file mode 100644 index 00000000..1d23310c --- /dev/null +++ b/tests/test_bindcol.cpp @@ -0,0 +1,251 @@ +// tests/test_bindcol.cpp — Dynamic bind/unbind mid-fetch tests +// (Phase 6, ported from psqlodbc bindcol-test) +// +// Tests dynamic unbinding and rebinding of columns while fetching rows. +// Verifies that SQLBindCol(col, NULL) unbinds, SQLFreeStmt(SQL_UNBIND) +// unbinds all, and rebinding mid-fetch works correctly. + +#include "test_helpers.h" +#include + +class BindColTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_BINDCOL", + "ID INTEGER NOT NULL PRIMARY KEY, LABEL VARCHAR(30)"); + + for (int i = 1; i <= 10; i++) { + ReallocStmt(); + char sql[128]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_BINDCOL (ID, LABEL) VALUES (%d, 'foo%d')", + i, i); + ExecDirect(sql); + } + Commit(); + ReallocStmt(); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// --- Basic bind and fetch --- + +TEST_F(BindColTest, BasicBindAndFetch) { + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLCHAR label[64] = {}; + SQLLEN labelInd = 0; + + SQLRETURN rc = SQLBindCol(hStmt, 1, SQL_C_LONG, &id, 0, &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLBindCol(hStmt, 2, SQL_C_CHAR, label, sizeof(label), &labelInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, LABEL FROM ODBC_TEST_BINDCOL ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + int rowno = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + rowno++; + EXPECT_EQ(id, rowno); + char expected[32]; + snprintf(expected, sizeof(expected), "foo%d", rowno); + EXPECT_STREQ((char*)label, expected); + } + EXPECT_EQ(rowno, 10); +} + +// --- Unbind column 2 mid-fetch, then rebind --- +// Mirrors the psqlodbc bindcol-test pattern: +// rows 1-3: both columns bound +// rows 4-5: column 2 unbound (NULL pointer) +// rows 6-7: column 2 rebound +// row 8: SQL_UNBIND all columns +// rows 9-10: column 2 rebound + +TEST_F(BindColTest, UnbindAndRebindMidFetch) { + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLCHAR label[64] = {}; + SQLLEN labelInd = 0; + + SQLRETURN rc = SQLBindCol(hStmt, 1, SQL_C_LONG, &id, 0, &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLBindCol(hStmt, 2, SQL_C_CHAR, label, sizeof(label), &labelInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, LABEL FROM ODBC_TEST_BINDCOL ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + int rowno = 0; + while (true) { + rc = SQLFetch(hStmt); + if (rc == SQL_NO_DATA) break; + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Fetch failed at row " << (rowno + 1) << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rowno++; + + if (rowno <= 3) { + // Both bound — verify both + EXPECT_EQ(id, rowno); + char expected[32]; + snprintf(expected, sizeof(expected), "foo%d", rowno); + EXPECT_STREQ((char*)label, expected) << "Row " << rowno; + } else if (rowno == 4 || rowno == 5) { + // Column 2 was unbound — id should still be correct, + // but label should NOT have been updated + EXPECT_EQ(id, rowno); + // label still has the value from row 3 (not updated) + EXPECT_STREQ((char*)label, "foo3"); + } else if (rowno == 6 || rowno == 7) { + // Column 2 rebound — both should be correct + EXPECT_EQ(id, rowno); + char expected[32]; + snprintf(expected, sizeof(expected), "foo%d", rowno); + EXPECT_STREQ((char*)label, expected); + } else if (rowno == 8) { + // All unbound — neither should be updated. + // id still has value from row 7 + EXPECT_EQ(id, 7); + EXPECT_STREQ((char*)label, "foo7"); + } else { + // Column 2 rebound — label should be correct, + // id still has value from row 7 (unbound) + EXPECT_EQ(id, 7); // still unbound + char expected[32]; + snprintf(expected, sizeof(expected), "foo%d", rowno); + EXPECT_STREQ((char*)label, expected); + } + + // Unbind/rebind at the appropriate rows + if (rowno == 3) { + // Unbind column 2 + rc = SQLBindCol(hStmt, 2, SQL_C_CHAR, NULL, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "Unbind col 2 failed"; + } + if (rowno == 5) { + // Rebind column 2 + rc = SQLBindCol(hStmt, 2, SQL_C_CHAR, label, sizeof(label), &labelInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "Rebind col 2 failed"; + } + if (rowno == 7) { + // Unbind all + rc = SQLFreeStmt(hStmt, SQL_UNBIND); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "SQL_UNBIND failed"; + } + if (rowno == 8) { + // Rebind column 2 only (id stays unbound) + rc = SQLBindCol(hStmt, 2, SQL_C_CHAR, label, sizeof(label), &labelInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "Rebind col 2 after UNBIND failed"; + } + } + EXPECT_EQ(rowno, 10); +} + +// --- SQLFreeStmt(SQL_UNBIND) then SQLGetData still works --- + +TEST_F(BindColTest, UnbindAllThenGetData) { + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLCHAR label[64] = {}; + SQLLEN labelInd = 0; + + SQLBindCol(hStmt, 1, SQL_C_LONG, &id, 0, &idInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, label, sizeof(label), &labelInd); + + // Unbind all + SQLRETURN rc = SQLFreeStmt(hStmt, SQL_UNBIND); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, LABEL FROM ODBC_TEST_BINDCOL WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // id and label should not have been written to + EXPECT_EQ(id, 0); + EXPECT_STREQ((char*)label, ""); + + // But SQLGetData should still work + SQLINTEGER fetchedId = 0; + SQLLEN fetchedInd = 0; + rc = SQLGetData(hStmt, 1, SQL_C_SLONG, &fetchedId, 0, &fetchedInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(fetchedId, 1); + + SQLCHAR fetchedLabel[64] = {}; + rc = SQLGetData(hStmt, 2, SQL_C_CHAR, fetchedLabel, sizeof(fetchedLabel), &fetchedInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)fetchedLabel, "foo1"); +} + +// --- Bind before exec, then rebind to different type --- + +TEST_F(BindColTest, RebindToDifferentType) { + // Bind column 1 as integer + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLRETURN rc = SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_BINDCOL WHERE ID = 5", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(id, 5); + + // Close cursor, rebind same column as string + SQLCloseCursor(hStmt); + SQLCHAR strId[32] = {}; + SQLLEN strInd = 0; + rc = SQLBindCol(hStmt, 1, SQL_C_CHAR, strId, sizeof(strId), &strInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_BINDCOL WHERE ID = 7", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)strId, "7"); +} + +// --- Bind extra column beyond result set width --- + +TEST_F(BindColTest, BindBeyondResultSetWidth) { + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLCHAR extra[32] = "untouched"; + SQLLEN extraInd = 0; + + // Bind columns 1 and 3 (result set only has 2 columns) + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt, 3, SQL_C_CHAR, extra, sizeof(extra), &extraInd); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, LABEL FROM ODBC_TEST_BINDCOL WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(id, 1); + // Extra column should not have been modified + EXPECT_STREQ((char*)extra, "untouched"); +} diff --git a/tests/test_blob.cpp b/tests/test_blob.cpp new file mode 100644 index 00000000..32b012e3 --- /dev/null +++ b/tests/test_blob.cpp @@ -0,0 +1,114 @@ +// tests/test_blob.cpp — BLOB read/write tests (Phase 3.9) + +#include "test_helpers.h" + +class BlobTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_BLOB", + "ID INTEGER NOT NULL PRIMARY KEY, " + "TEXT_BLOB BLOB SUB_TYPE TEXT, " + "BIN_BLOB BLOB SUB_TYPE BINARY" + ); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +TEST_F(BlobTest, SmallTextBlob) { + ExecDirect("INSERT INTO ODBC_TEST_BLOB (ID, TEXT_BLOB) VALUES (1, 'Hello BLOB World')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT TEXT_BLOB FROM ODBC_TEST_BLOB WHERE ID = 1"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR val[256] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "Hello BLOB World"); +} + +TEST_F(BlobTest, LargeTextBlob) { + // Create a large string (64KB) + const int SIZE = 64 * 1024; + std::string largeStr; + largeStr.reserve(SIZE); + for (int i = 0; i < SIZE; i++) { + largeStr += ('A' + (i % 26)); + } + + // Insert using parameter binding + ReallocStmt(); + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_BLOB (ID, TEXT_BLOB) VALUES (2, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLLEN strInd = (SQLLEN)largeStr.size(); + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_LONGVARCHAR, + largeStr.size(), 0, (SQLPOINTER)largeStr.c_str(), 0, &strInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Large BLOB insert failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + ReallocStmt(); + + // Read back via GetData in chunks + ExecDirect("SELECT TEXT_BLOB FROM ODBC_TEST_BLOB WHERE ID = 2"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + std::string result; + SQLCHAR buffer[4096]; + SQLLEN ind = 0; + + while (true) { + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buffer, sizeof(buffer), &ind); + if (ret == SQL_NO_DATA) + break; + ASSERT_TRUE(ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO) + << "GetData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + if (ind == SQL_NULL_DATA) + break; + + SQLLEN bytesToAppend; + if (ret == SQL_SUCCESS_WITH_INFO) { + // Buffer was filled completely (minus null terminator) + bytesToAppend = sizeof(buffer) - 1; + } else { + bytesToAppend = ind; + } + result.append((char*)buffer, bytesToAppend); + + if (ret == SQL_SUCCESS) + break; + } + + EXPECT_EQ(result.size(), largeStr.size()); + EXPECT_EQ(result, largeStr); +} + +TEST_F(BlobTest, NullBlob) { + ExecDirect("INSERT INTO ODBC_TEST_BLOB (ID, TEXT_BLOB) VALUES (3, NULL)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT TEXT_BLOB FROM ODBC_TEST_BLOB WHERE ID = 3"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR val[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, SQL_NULL_DATA); +} diff --git a/tests/test_catalogfunctions.cpp b/tests/test_catalogfunctions.cpp new file mode 100644 index 00000000..60adb2ea --- /dev/null +++ b/tests/test_catalogfunctions.cpp @@ -0,0 +1,648 @@ +// tests/test_catalogfunctions.cpp — Comprehensive catalog function tests +// (Phase 6, ported from psqlodbc catalogfunctions-test) +// +// Tests all major catalog functions: SQLGetTypeInfo, SQLTables, SQLColumns, +// SQLPrimaryKeys, SQLForeignKeys, SQLSpecialColumns, SQLStatistics, +// SQLProcedures, SQLProcedureColumns, SQLTablePrivileges, +// SQLColumnPrivileges, SQLGetInfo. + +#include "test_helpers.h" +#include +#include +#include +#include +#include + +class CatalogFunctionsTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + // Create the primary table + ExecIgnoreError("DROP TABLE ODBC_CAT_FK"); + ExecIgnoreError("DROP TABLE ODBC_CAT_PK"); + ExecIgnoreError("DROP TABLE ODBC_CAT_SPECIAL"); + ExecIgnoreError("EXECUTE BLOCK AS BEGIN " + "IF (EXISTS(SELECT 1 FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_NAME = 'ODBC_CAT_ADD')) THEN " + "EXECUTE STATEMENT 'DROP PROCEDURE ODBC_CAT_ADD'; END"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_CAT_PK (" + "ID INTEGER NOT NULL PRIMARY KEY, " + "NAME VARCHAR(50) NOT NULL, " + "AMOUNT NUMERIC(10,2))"); + Commit(); + ReallocStmt(); + + // Create a foreign-key table referencing the PK table + ExecDirect("CREATE TABLE ODBC_CAT_FK (" + "FK_ID INTEGER NOT NULL PRIMARY KEY, " + "PK_ID INTEGER NOT NULL REFERENCES ODBC_CAT_PK(ID))"); + Commit(); + ReallocStmt(); + + // Create a table with a unique index (no PK) for SQLSpecialColumns + ExecDirect("CREATE TABLE ODBC_CAT_SPECIAL (" + "COL1 INTEGER NOT NULL, " + "COL2 VARCHAR(20) NOT NULL, " + "CONSTRAINT UQ_CAT_SPECIAL UNIQUE (COL1))"); + Commit(); + ReallocStmt(); + + // Create a simple stored procedure + ExecDirect("CREATE PROCEDURE ODBC_CAT_ADD (A INTEGER, B INTEGER) " + "RETURNS (RESULT INTEGER) AS " + "BEGIN RESULT = A + B; SUSPEND; END"); + Commit(); + ReallocStmt(); + } + + void TearDown() override { + if (hDbc != SQL_NULL_HDBC) { + ExecIgnoreError("DROP TABLE ODBC_CAT_FK"); + ExecIgnoreError("DROP TABLE ODBC_CAT_PK"); + ExecIgnoreError("DROP TABLE ODBC_CAT_SPECIAL"); + ExecIgnoreError("DROP PROCEDURE ODBC_CAT_ADD"); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + } + OdbcConnectedTest::TearDown(); + } +}; + +// --- SQLGetTypeInfo --- + +TEST_F(CatalogFunctionsTest, GetTypeInfoAllTypes) { + SQLRETURN rc = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetTypeInfo(SQL_ALL_TYPES) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int count = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) count++; + EXPECT_GT(count, 5) << "Expected at least 5 type entries"; +} + +TEST_F(CatalogFunctionsTest, GetTypeInfoVarchar) { + SQLRETURN rc = SQLGetTypeInfo(hStmt, SQL_VARCHAR); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetTypeInfo(SQL_VARCHAR) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Should have at least one row for VARCHAR + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "No VARCHAR type info returned"; + + // Verify TYPE_NAME column is not empty + SQLCHAR typeName[128] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_GT(strlen((char*)typeName), 0u); +} + +TEST_F(CatalogFunctionsTest, GetTypeInfoInteger) { + SQLRETURN rc = SQLGetTypeInfo(hStmt, SQL_INTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetTypeInfo(SQL_INTEGER) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "No INTEGER type info returned"; + + // Verify DATA_TYPE column matches + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(dataType, SQL_INTEGER); +} + +// --- SQLTables --- + +TEST_F(CatalogFunctionsTest, TablesFindsTestTable) { + SQLRETURN rc = SQLTables(hStmt, + NULL, 0, // catalog + NULL, 0, // schema + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + (SQLCHAR*)"TABLE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLTables failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "Table ODBC_CAT_PK not found"; + + // TABLE_NAME is column 3 + SQLCHAR tblName[128] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 3, SQL_C_CHAR, tblName, sizeof(tblName), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)tblName, "ODBC_CAT_PK"); +} + +TEST_F(CatalogFunctionsTest, TablesWithWildcard) { + SQLRETURN rc = SQLTables(hStmt, + NULL, 0, + NULL, 0, + (SQLCHAR*)"ODBC_CAT_%", SQL_NTS, + (SQLCHAR*)"TABLE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLTables wildcard failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int count = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) count++; + EXPECT_GE(count, 3) << "Expected at least 3 ODBC_CAT_* tables"; +} + +TEST_F(CatalogFunctionsTest, TablesResultMetadata) { + SQLRETURN rc = SQLTables(hStmt, NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, (SQLCHAR*)"TABLE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Verify the result set has the standard 5 columns + SQLSMALLINT numCols = 0; + rc = SQLNumResultCols(hStmt, &numCols); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(numCols, 5); // TABLE_CAT, TABLE_SCHEM, TABLE_NAME, TABLE_TYPE, REMARKS +} + +// --- SQLColumns --- + +TEST_F(CatalogFunctionsTest, ColumnsReturnsAllColumns) { + SQLRETURN rc = SQLColumns(hStmt, + NULL, 0, + NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + (SQLCHAR*)"%", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLColumns failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int count = 0; + bool foundId = false, foundName = false, foundAmount = false; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + count++; + SQLCHAR colName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 4, SQL_C_CHAR, colName, sizeof(colName), &ind); + + if (strcmp((char*)colName, "ID") == 0) foundId = true; + else if (strcmp((char*)colName, "NAME") == 0) foundName = true; + else if (strcmp((char*)colName, "AMOUNT") == 0) foundAmount = true; + } + EXPECT_EQ(count, 3); + EXPECT_TRUE(foundId); + EXPECT_TRUE(foundName); + EXPECT_TRUE(foundAmount); +} + +TEST_F(CatalogFunctionsTest, ColumnsDataTypes) { + SQLRETURN rc = SQLColumns(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + NULL, 0); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLCHAR colName[128] = {}; + SQLSMALLINT dataType = 0; + SQLLEN nameInd = 0, typeInd = 0; + SQLGetData(hStmt, 4, SQL_C_CHAR, colName, sizeof(colName), &nameInd); + SQLGetData(hStmt, 5, SQL_C_SSHORT, &dataType, 0, &typeInd); + + if (strcmp((char*)colName, "ID") == 0) { + EXPECT_EQ(dataType, SQL_INTEGER); + } else if (strcmp((char*)colName, "NAME") == 0) { + EXPECT_TRUE(dataType == SQL_VARCHAR || dataType == SQL_WVARCHAR); + } else if (strcmp((char*)colName, "AMOUNT") == 0) { + EXPECT_TRUE(dataType == SQL_NUMERIC || dataType == SQL_DECIMAL); + } + } +} + +TEST_F(CatalogFunctionsTest, ColumnsNullability) { + SQLRETURN rc = SQLColumns(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + NULL, 0); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLCHAR colName[128] = {}; + SQLSMALLINT nullable = 0; + SQLLEN nameInd = 0, nullInd = 0; + SQLGetData(hStmt, 4, SQL_C_CHAR, colName, sizeof(colName), &nameInd); + SQLGetData(hStmt, 11, SQL_C_SSHORT, &nullable, 0, &nullInd); + + if (strcmp((char*)colName, "ID") == 0 || strcmp((char*)colName, "NAME") == 0) { + EXPECT_EQ(nullable, SQL_NO_NULLS); + } else if (strcmp((char*)colName, "AMOUNT") == 0) { + EXPECT_EQ(nullable, SQL_NULLABLE); + } + } +} + +// --- SQLPrimaryKeys --- + +TEST_F(CatalogFunctionsTest, PrimaryKeys) { + SQLRETURN rc = SQLPrimaryKeys(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLPrimaryKeys failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "No primary key found"; + + // COLUMN_NAME is column 4 + SQLCHAR colName[128] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 4, SQL_C_CHAR, colName, sizeof(colName), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "ID"); + + // KEY_SEQ is column 5 + SQLSMALLINT keySeq = 0; + rc = SQLGetData(hStmt, 5, SQL_C_SSHORT, &keySeq, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(keySeq, 1); +} + +// --- SQLForeignKeys --- + +TEST_F(CatalogFunctionsTest, ForeignKeys) { + SQLRETURN rc = SQLForeignKeys(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, // PK table + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_FK", SQL_NTS); // FK table + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLForeignKeys failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "No foreign key relationship found"; + + // PKTABLE_NAME col 3, PKCOLUMN_NAME col 4 + SQLCHAR pkTable[128] = {}, pkCol[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 3, SQL_C_CHAR, pkTable, sizeof(pkTable), &ind); + SQLGetData(hStmt, 4, SQL_C_CHAR, pkCol, sizeof(pkCol), &ind); + EXPECT_STREQ((char*)pkTable, "ODBC_CAT_PK"); + EXPECT_STREQ((char*)pkCol, "ID"); + + // FKTABLE_NAME col 7, FKCOLUMN_NAME col 8 + SQLCHAR fkTable[128] = {}, fkCol[128] = {}; + SQLGetData(hStmt, 7, SQL_C_CHAR, fkTable, sizeof(fkTable), &ind); + SQLGetData(hStmt, 8, SQL_C_CHAR, fkCol, sizeof(fkCol), &ind); + EXPECT_STREQ((char*)fkTable, "ODBC_CAT_FK"); + EXPECT_STREQ((char*)fkCol, "PK_ID"); +} + +// --- SQLSpecialColumns --- + +TEST_F(CatalogFunctionsTest, SpecialColumnsBestRowId) { + SQLRETURN rc = SQLSpecialColumns(hStmt, SQL_BEST_ROWID, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + SQL_SCOPE_SESSION, SQL_NULLABLE); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLSpecialColumns failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int found = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) found++; + EXPECT_GE(found, 1) << "Expected at least one BEST_ROWID column (PK)"; +} + +TEST_F(CatalogFunctionsTest, SpecialColumnsUniqueIndex) { + SQLRETURN rc = SQLSpecialColumns(hStmt, SQL_BEST_ROWID, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_SPECIAL", SQL_NTS, + SQL_SCOPE_SESSION, SQL_NO_NULLS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLSpecialColumns (unique idx) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int found = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) found++; + EXPECT_GE(found, 1) << "Expected unique index column as BEST_ROWID"; +} + +TEST_F(CatalogFunctionsTest, SpecialColumnsRowVer) { + SQLRETURN rc = SQLSpecialColumns(hStmt, SQL_ROWVER, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + SQL_SCOPE_SESSION, SQL_NO_NULLS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLSpecialColumns(SQL_ROWVER) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Firebird may or may not report row version columns — just verify no crash + while (SQL_SUCCEEDED(SQLFetch(hStmt))) {} + SUCCEED(); +} + +// --- SQLStatistics --- + +TEST_F(CatalogFunctionsTest, Statistics) { + SQLRETURN rc = SQLStatistics(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + SQL_INDEX_ALL, SQL_QUICK); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLStatistics failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Result should have 13 columns per ODBC spec + SQLSMALLINT numCols = 0; + rc = SQLNumResultCols(hStmt, &numCols); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(numCols, 13); + + int indexCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) indexCount++; + EXPECT_GE(indexCount, 1) << "Expected at least one index (PK)"; +} + +TEST_F(CatalogFunctionsTest, StatisticsUniqueOnly) { + SQLRETURN rc = SQLStatistics(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_SPECIAL", SQL_NTS, + SQL_INDEX_UNIQUE, SQL_QUICK); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + int count = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) count++; + EXPECT_GE(count, 1) << "Expected at least one unique index"; +} + +// --- SQLProcedures --- + +TEST_F(CatalogFunctionsTest, Procedures) { + SQLRETURN rc = SQLProcedures(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_ADD", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLProcedures failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) << "Procedure ODBC_CAT_ADD not found"; + + // PROCEDURE_NAME is column 3 + SQLCHAR procName[128] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 3, SQL_C_CHAR, procName, sizeof(procName), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)procName, "ODBC_CAT_ADD"); +} + +// --- SQLProcedureColumns --- + +TEST_F(CatalogFunctionsTest, ProcedureColumns) { + SQLRETURN rc = SQLProcedureColumns(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_ADD", SQL_NTS, + NULL, 0); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLProcedureColumns failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int paramCount = 0; + bool foundA = false, foundB = false, foundResult = false; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + paramCount++; + SQLCHAR colName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 4, SQL_C_CHAR, colName, sizeof(colName), &ind); + + if (strcmp((char*)colName, "A") == 0) foundA = true; + else if (strcmp((char*)colName, "B") == 0) foundB = true; + else if (strcmp((char*)colName, "RESULT") == 0) foundResult = true; + } + EXPECT_GE(paramCount, 2) << "Expected at least 2 parameter entries"; + EXPECT_TRUE(foundA) << "Parameter A not found"; + EXPECT_TRUE(foundB) << "Parameter B not found"; +} + +// --- SQLTablePrivileges --- + +TEST_F(CatalogFunctionsTest, TablePrivileges) { + SQLRETURN rc = SQLTablePrivileges(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLTablePrivileges failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Just verify the call succeeds and produces a result set (may be empty + // depending on Firebird configuration) + SQLSMALLINT numCols = 0; + rc = SQLNumResultCols(hStmt, &numCols); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(numCols, 7); // Per ODBC spec +} + +// --- SQLColumnPrivileges --- + +TEST_F(CatalogFunctionsTest, ColumnPrivileges) { + SQLRETURN rc = SQLColumnPrivileges(hStmt, + NULL, 0, NULL, 0, + (SQLCHAR*)"ODBC_CAT_PK", SQL_NTS, + (SQLCHAR*)"ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLColumnPrivileges failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Per ODBC spec, the result set has 8 columns + SQLSMALLINT numCols = 0; + rc = SQLNumResultCols(hStmt, &numCols); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(numCols, 8); +} + +// --- SQLGetInfo --- + +TEST_F(CatalogFunctionsTest, GetInfoTableTerm) { + SQLCHAR buf[128] = {}; + SQLSMALLINT len = 0; + SQLRETURN rc = SQLGetInfo(hDbc, SQL_TABLE_TERM, buf, sizeof(buf), &len); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetInfo(SQL_TABLE_TERM) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_GT(len, 0) << "SQL_TABLE_TERM should not be empty"; +} + +TEST_F(CatalogFunctionsTest, GetInfoProcedureTerm) { + SQLCHAR buf[128] = {}; + SQLSMALLINT len = 0; + SQLRETURN rc = SQLGetInfo(hDbc, SQL_PROCEDURE_TERM, buf, sizeof(buf), &len); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetInfo(SQL_PROCEDURE_TERM) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + // May be empty if not supported, but should not fail +} + +TEST_F(CatalogFunctionsTest, GetInfoMaxTableNameLen) { + SQLUSMALLINT maxLen = 0; + SQLRETURN rc = SQLGetInfo(hDbc, SQL_MAX_TABLE_NAME_LEN, &maxLen, sizeof(maxLen), NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_GT(maxLen, 0u) << "Max table name length should be > 0"; +} + +// ===== SQLGetTypeInfo tests (from Phase 11) ===== + +class TypeInfoTest : public OdbcConnectedTest {}; + +// Result set must be sorted by DATA_TYPE ascending (ODBC spec requirement) +TEST_F(TypeInfoTest, ResultSetSortedByDataType) { + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering fix"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetTypeInfo failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLSMALLINT prevDataType = -32768; + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + EXPECT_GE(dataType, prevDataType) + << "Row " << (rowCount + 1) << ": DATA_TYPE " << dataType + << " is less than previous " << prevDataType + << " — result set is not sorted by DATA_TYPE ascending"; + prevDataType = dataType; + rowCount++; + } + EXPECT_GT(rowCount, 0) << "No rows returned by SQLGetTypeInfo(SQL_ALL_TYPES)"; +} + +// When a specific DATA_TYPE is requested, all matching rows must be returned +TEST_F(TypeInfoTest, MultipleRowsForSameDataType) { + // SQL_INTEGER is a good test — should return exactly 1 row + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_INTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + EXPECT_EQ(dataType, SQL_INTEGER) << "Unexpected DATA_TYPE in filtered result"; + rowCount++; + } + EXPECT_GE(rowCount, 1) << "Should return at least 1 row for SQL_INTEGER"; +} + +// SQL_NUMERIC should return at least NUMERIC, and on FB4+ also INT128 +TEST_F(TypeInfoTest, NumericReturnsMultipleOnFB4Plus) { + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_NUMERIC); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + std::vector typeNames; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLCHAR typeName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind); + typeNames.push_back(std::string((char*)typeName)); + } + ASSERT_GE(typeNames.size(), 1u) << "At least NUMERIC should be returned"; + + // Verify NUMERIC is in the list + bool hasNumeric = false; + for (auto& name : typeNames) { + if (name == "NUMERIC") hasNumeric = true; + } + EXPECT_TRUE(hasNumeric) << "NUMERIC type not found in SQL_NUMERIC results"; +} + +// No non-existent type should return any rows +TEST_F(TypeInfoTest, NonexistentTypeReturnsNoRows) { + SQLRETURN ret = SQLGetTypeInfo(hStmt, 9999); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + rowCount++; + EXPECT_EQ(rowCount, 0) << "Invalid type 9999 should return 0 rows"; +} + +// SQL_GUID type should have SEARCHABLE = SQL_ALL_EXCEPT_LIKE (2) +TEST_F(TypeInfoTest, GuidSearchabilityIsAllExceptLike) { + GTEST_SKIP() << "Requires Phase 11: SQL_GUID searchability fix"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool foundGuid = false; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + if (dataType == SQL_GUID) { + foundGuid = true; + SQLSMALLINT searchable = 0; + SQLGetData(hStmt, 9, SQL_C_SSHORT, &searchable, 0, &ind); + EXPECT_EQ(searchable, SQL_ALL_EXCEPT_LIKE) + << "SQL_GUID SEARCHABLE should be SQL_ALL_EXCEPT_LIKE (2), not " << searchable; + + // LITERAL_PREFIX/SUFFIX should be NULL for GUID + SQLCHAR prefix[32] = {}; + ret = SQLGetData(hStmt, 4, SQL_C_CHAR, prefix, sizeof(prefix), &ind); + EXPECT_TRUE(ind == SQL_NULL_DATA || strlen((char*)prefix) == 0) + << "SQL_GUID LITERAL_PREFIX should be NULL or empty"; + break; + } + } + EXPECT_TRUE(foundGuid) << "SQL_GUID type not found in type info result set"; +} + +// On FB4+ servers, BINARY/VARBINARY should not have duplicate entries +TEST_F(TypeInfoTest, NoDuplicateBinaryTypesOnFB4Plus) { + GTEST_SKIP() << "Requires Phase 11: no-duplicate BINARY type entries"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + std::map> typeMap; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLSMALLINT dataType = 0; + SQLCHAR typeName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind); + typeMap[dataType].push_back(std::string((char*)typeName)); + } + + if (typeMap.count(SQL_BINARY)) { + auto& names = typeMap[SQL_BINARY]; + bool hasBlobAlias = false; + bool hasNative = false; + for (auto& n : names) { + if (n == "BLOB SUB_TYPE 0") hasBlobAlias = true; + if (n == "BINARY") hasNative = true; + } + if (hasBlobAlias && hasNative) { + FAIL() << "SQL_BINARY has both 'BLOB SUB_TYPE 0' and 'BINARY' entries — " + "version-gating failed"; + } + } +} + +// Verify every row in SQL_ALL_TYPES has valid data +TEST_F(TypeInfoTest, AllTypesReturnValidData) { + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLCHAR typeName[128] = {}; + SQLSMALLINT dataType = 0; + SQLINTEGER columnSize = 0; + SQLLEN ind1 = 0, ind2 = 0, ind3 = 0; + + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind1); + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind2); + SQLGetData(hStmt, 3, SQL_C_SLONG, &columnSize, 0, &ind3); + + EXPECT_NE(ind1, SQL_NULL_DATA) << "TYPE_NAME should not be NULL"; + EXPECT_NE(ind2, SQL_NULL_DATA) << "DATA_TYPE should not be NULL"; + EXPECT_GT(strlen((char*)typeName), 0u) << "TYPE_NAME should not be empty"; + rowCount++; + } + EXPECT_GT(rowCount, 10) << "Expected at least 10 type info rows"; +} diff --git a/tests/test_conn_settings.cpp b/tests/test_conn_settings.cpp new file mode 100644 index 00000000..0a59b21a --- /dev/null +++ b/tests/test_conn_settings.cpp @@ -0,0 +1,98 @@ +// test_conn_settings.cpp — Tests for ConnSettings (SQL on connect) feature (Task 4.6) +#include "test_helpers.h" + +// ============================================================================ +// ConnSettingsTest: Verify SQL is executed during connection +// ============================================================================ +class ConnSettingsTest : public ::testing::Test { +protected: + SQLHENV hEnv = SQL_NULL_HENV; + SQLHDBC hDbc = SQL_NULL_HDBC; + SQLHSTMT hStmt = SQL_NULL_HSTMT; + std::string baseConnStr; + + void SetUp() override { + baseConnStr = GetConnectionString(); + if (baseConnStr.empty()) { + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; + } + + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + } + + void TearDown() override { + if (hStmt != SQL_NULL_HSTMT) { + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + } + if (hDbc != SQL_NULL_HDBC) { + SQLDisconnect(hDbc); + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); + } + if (hEnv != SQL_NULL_HENV) { + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + } + } + + bool Connect(const std::string& connStr) { + SQLCHAR outBuf[1024]; + SQLSMALLINT outLen; + SQLRETURN ret = SQLDriverConnect(hDbc, NULL, + (SQLCHAR*)connStr.c_str(), SQL_NTS, + outBuf, sizeof(outBuf), &outLen, + SQL_DRIVER_NOPROMPT); + return SQL_SUCCEEDED(ret); + } +}; + +TEST_F(ConnSettingsTest, ConnSettingsExecutesSQL) { + GTEST_SKIP() << "Requires Phase 4 (Task 4.6): ConnSettings DSN attribute (not yet merged)"; + // Connect with ConnSettings that creates a GTT (Global Temporary Table) + std::string connStr = baseConnStr + + ";ConnSettings=RECREATE GLOBAL TEMPORARY TABLE CS_TEST (X INTEGER) ON COMMIT DELETE ROWS"; + + ASSERT_TRUE(Connect(connStr)) + << "Connection with ConnSettings failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + // Verify the table was created by inserting into it + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO CS_TEST (X) VALUES (42)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT into ConnSettings-created table failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Cleanup: drop the GTT + SQLHSTMT dropStmt; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &dropStmt); + SQLExecDirect(dropStmt, (SQLCHAR*)"DROP TABLE CS_TEST", SQL_NTS); + SQLFreeHandle(SQL_HANDLE_STMT, dropStmt); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); +} + +TEST_F(ConnSettingsTest, EmptyConnSettingsIsIgnored) { + GTEST_SKIP() << "Requires Phase 4 (Task 4.6): ConnSettings DSN attribute (not yet merged)"; + // An empty ConnSettings should not cause any issues + std::string connStr = baseConnStr + ";ConnSettings="; + + ASSERT_TRUE(Connect(connStr)) + << "Connection with empty ConnSettings failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); +} + +TEST_F(ConnSettingsTest, InvalidConnSettingsFailsConnection) { + GTEST_SKIP() << "Requires Phase 4 (Task 4.6): ConnSettings DSN attribute (not yet merged)"; + // Invalid SQL in ConnSettings should fail the connection + std::string connStr = baseConnStr + ";ConnSettings=THIS IS NOT VALID SQL AT ALL"; + + bool connected = Connect(connStr); + // The connection should fail due to invalid SQL + EXPECT_FALSE(connected) + << "Expected connection to fail with invalid ConnSettings SQL"; +} diff --git a/tests/test_connect_options.cpp b/tests/test_connect_options.cpp new file mode 100644 index 00000000..b5162e93 --- /dev/null +++ b/tests/test_connect_options.cpp @@ -0,0 +1,711 @@ +// tests/test_connect_options.cpp — Connection option tests (Phase 6, ported from psqlodbc connect-test) +// +// Tests SQLConnect, SQLDriverConnect, attribute persistence, and transaction behavior. + +#include "test_helpers.h" +#include +#include + +// ===== Raw connection tests (not using the fixture) ===== + +class ConnectOptionsTest : public ::testing::Test { +protected: + SQLHENV hEnv = SQL_NULL_HENV; + SQLHDBC hDbc = SQL_NULL_HDBC; + SQLHSTMT hStmt = SQL_NULL_HSTMT; + + void SetUp() override { + if (GetConnectionString().empty()) { + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; + } + } + + void TearDown() override { + if (hStmt != SQL_NULL_HSTMT) { + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + } + if (hDbc != SQL_NULL_HDBC) { + SQLDisconnect(hDbc); + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); + } + if (hEnv != SQL_NULL_HENV) { + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + } + } + + void AllocEnvAndDbc() { + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + } + + void Connect() { + std::string connStr = GetConnectionString(); + SQLCHAR outStr[1024]; + SQLSMALLINT outLen; + SQLRETURN ret = SQLDriverConnect(hDbc, NULL, + (SQLCHAR*)connStr.c_str(), SQL_NTS, + outStr, sizeof(outStr), &outLen, + SQL_DRIVER_NOPROMPT); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Connect failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + } +}; + +// Test basic SQLDriverConnect +TEST_F(ConnectOptionsTest, BasicDriverConnect) { + AllocEnvAndDbc(); + Connect(); + // If we got here, connection succeeded +} + +// Test that autocommit attribute persists across SQLDriverConnect +// (ported from psqlodbc test_setting_attribute_before_connect) +TEST_F(ConnectOptionsTest, AutocommitPersistsAcrossConnect) { + AllocEnvAndDbc(); + + // Disable autocommit BEFORE connecting + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetConnectAttr(AUTOCOMMIT_OFF) failed before connect"; + + Connect(); + + // Verify autocommit is still off after connect + SQLULEN value = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetConnectAttr failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(value, (SQLULEN)SQL_AUTOCOMMIT_OFF) + << "Autocommit should still be OFF after connect"; +} + +// Test that transactions work correctly with autocommit off +TEST_F(ConnectOptionsTest, RollbackUndoesInsert) { + AllocEnvAndDbc(); + + // Set autocommit OFF before connecting + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + Connect(); + + // Create temp table + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Drop if exists + SQLExecDirect(hStmt, (SQLCHAR*)"DROP TABLE ODBC_TEST_ROLLBACK", SQL_NTS); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"CREATE TABLE ODBC_TEST_ROLLBACK (ID INTEGER, VAL VARCHAR(50))", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "CREATE TABLE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + + // Insert a row + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_ROLLBACK VALUES (10000, 'should not be here')", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Rollback + ret = SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_ROLLBACK); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Rollback failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + // Verify row is NOT there + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COUNT(*) FROM ODBC_TEST_ROLLBACK WHERE ID = 10000", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = -1; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 0) << "Row should not exist after rollback"; + + // Cleanup + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + SQLExecDirect(hStmt, (SQLCHAR*)"DROP TABLE ODBC_TEST_ROLLBACK", SQL_NTS); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); +} + +// Test that autocommit ON commits every statement automatically +TEST_F(ConnectOptionsTest, AutocommitOnCommitsEveryStatement) { + AllocEnvAndDbc(); + Connect(); // Default: autocommit ON + + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Drop if exists + SQLExecDirect(hStmt, (SQLCHAR*)"DROP TABLE ODBC_TEST_AUTOCOMMIT", SQL_NTS); + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"CREATE TABLE ODBC_TEST_AUTOCOMMIT (ID INTEGER)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_AUTOCOMMIT VALUES (42)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Don't call SQLEndTran — autocommit should have committed already + + // Verify by reading back + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT ID FROM ODBC_TEST_AUTOCOMMIT", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 42); + + // Cleanup + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + SQLExecDirect(hStmt, (SQLCHAR*)"DROP TABLE ODBC_TEST_AUTOCOMMIT", SQL_NTS); +} + +// Test toggling autocommit on and off +TEST_F(ConnectOptionsTest, ToggleAutocommit) { + AllocEnvAndDbc(); + Connect(); + + SQLULEN value = 0; + SQLRETURN ret; + + // Default should be ON + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_AUTOCOMMIT_ON); + + // Turn OFF + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_AUTOCOMMIT_OFF); + + // Turn back ON + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_AUTOCOMMIT_ON); +} + +// Test connection timeout attribute +TEST_F(ConnectOptionsTest, ConnectionTimeoutAttribute) { + GTEST_SKIP() << "Requires Phase 7 (OC-3): SQL_ATTR_CONNECTION_TIMEOUT support"; + AllocEnvAndDbc(); + + // Set connection timeout before connect + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, + (SQLPOINTER)30, SQL_IS_UINTEGER); + // May or may not succeed depending on driver support, just shouldn't crash + (void)ret; + + Connect(); + + // Read it back + SQLULEN timeout = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, &timeout, 0, NULL); + if (SQL_SUCCEEDED(ret)) { + // If supported, should return the set value or 0 + SUCCEED(); + } +} + +// Test SQL_ATTR_ACCESS_MODE attribute +TEST_F(ConnectOptionsTest, AccessModeAttribute) { + AllocEnvAndDbc(); + Connect(); + + SQLULEN mode = 0; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_ACCESS_MODE, &mode, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // Default should be read-write + EXPECT_EQ(mode, (SQLULEN)SQL_MODE_READ_WRITE); +} + +// ===== OC-3: SQL_ATTR_CONNECTION_TIMEOUT ===== + +class ConnectionTimeoutTest : public ::testing::Test { +protected: + SQLHENV hEnv = SQL_NULL_HENV; + SQLHDBC hDbc = SQL_NULL_HDBC; + + void SetUp() override { + if (GetConnectionString().empty()) + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; + } + + void TearDown() override { + if (hDbc != SQL_NULL_HDBC) { + SQLDisconnect(hDbc); + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); + } + if (hEnv != SQL_NULL_HENV) + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + } + + void AllocHandles() { + SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); + SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + } + + void Connect() { + std::string connStr = GetConnectionString(); + SQLCHAR outStr[1024]; + SQLSMALLINT outLen; + SQLRETURN ret = SQLDriverConnect(hDbc, NULL, + (SQLCHAR*)connStr.c_str(), SQL_NTS, + outStr, sizeof(outStr), &outLen, + SQL_DRIVER_NOPROMPT); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Connect failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + } +}; + +TEST_F(ConnectionTimeoutTest, SetAndGetConnectionTimeout) { + GTEST_SKIP() << "Requires Phase 7 (OC-3): SQL_ATTR_CONNECTION_TIMEOUT support"; + AllocHandles(); + + // Set connection timeout before connecting + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, + (SQLPOINTER)30, SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetConnectAttr(SQL_ATTR_CONNECTION_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + Connect(); + + // Read it back + SQLULEN timeout = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetConnectAttr(SQL_ATTR_CONNECTION_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(timeout, 30u); +} + +TEST_F(ConnectionTimeoutTest, LoginTimeoutGetterWorks) { + GTEST_SKIP() << "Requires Phase 7 (OC-3): SQL_ATTR_LOGIN_TIMEOUT getter fix"; + AllocHandles(); + + // Set login timeout + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_LOGIN_TIMEOUT, + (SQLPOINTER)15, SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetConnectAttr(SQL_ATTR_LOGIN_TIMEOUT) failed"; + + // Read it back — this previously fell through to HYC00 error + SQLULEN timeout = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_LOGIN_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetConnectAttr(SQL_ATTR_LOGIN_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(timeout, 15u); +} + +TEST_F(ConnectionTimeoutTest, ConnectionTimeoutDefaultIsZero) { + GTEST_SKIP() << "Requires Phase 7 (OC-3): SQL_ATTR_CONNECTION_TIMEOUT support"; + AllocHandles(); + Connect(); + + SQLULEN timeout = 999; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Default connection timeout should be 0 (no timeout)"; +} + +// ===== OC-4: SQL_ATTR_ASYNC_ENABLE ===== + +class AsyncEnableTest : public OdbcConnectedTest {}; + +TEST_F(AsyncEnableTest, ConnectionLevelRejectsAsyncOn) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ATTR_ASYNC_ENABLE support"; + // Setting SQL_ASYNC_ENABLE_ON should fail with HYC00 + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_ON, SQL_IS_UINTEGER); + EXPECT_EQ(ret, SQL_ERROR); + + std::string state = GetSqlState(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(state, "HYC00") << "Expected HYC00 for unsupported async enable"; +} + +TEST_F(AsyncEnableTest, ConnectionLevelAcceptsAsyncOff) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ATTR_ASYNC_ENABLE support"; + // Setting SQL_ASYNC_ENABLE_OFF should succeed (it's the default) + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_OFF, SQL_IS_UINTEGER); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); +} + +TEST_F(AsyncEnableTest, ConnectionLevelGetReturnsOff) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ATTR_ASYNC_ENABLE support"; + SQLULEN value = 999; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_ASYNC_ENABLE_OFF); +} + +TEST_F(AsyncEnableTest, StatementLevelRejectsAsyncOn) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ATTR_ASYNC_ENABLE support"; + // Setting SQL_ASYNC_ENABLE_ON on statement should fail with HYC00 + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_ON, SQL_IS_UINTEGER); + EXPECT_EQ(ret, SQL_ERROR); + + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(state, "HYC00") << "Expected HYC00 for unsupported async enable"; +} + +TEST_F(AsyncEnableTest, StatementLevelGetReturnsOff) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ATTR_ASYNC_ENABLE support"; + SQLULEN value = 999; + SQLRETURN ret = SQLGetStmtAttr(hStmt, SQL_ATTR_ASYNC_ENABLE, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_ASYNC_ENABLE_OFF); +} + +// ===== SQL_ASYNC_MODE info ===== + +class AsyncModeTest : public OdbcConnectedTest {}; + +TEST_F(AsyncModeTest, ReportsAsyncModeNone) { + GTEST_SKIP() << "Requires Phase 7 (OC-4): SQL_ASYNC_MODE info support"; + SQLUINTEGER asyncMode = 0; + SQLSMALLINT actualLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_ASYNC_MODE, &asyncMode, sizeof(asyncMode), &actualLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetInfo(SQL_ASYNC_MODE) failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(asyncMode, (SQLUINTEGER)SQL_AM_NONE) + << "SQL_ASYNC_MODE should be SQL_AM_NONE (0), got " << asyncMode; +} + +// ===== SQL_ATTR_QUERY_TIMEOUT and SQLCancel tests ===== + +class QueryTimeoutTest : public OdbcConnectedTest {}; + +// Default query timeout should be 0 +TEST_F(QueryTimeoutTest, DefaultTimeoutIsZero) { + GTEST_SKIP() << "Requires Phase 11: QUERY_TIMEOUT support"; + SQLULEN timeout = 999; + SQLRETURN ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Default query timeout should be 0"; +} + +// Setting and getting timeout +TEST_F(QueryTimeoutTest, SetAndGetTimeout) { + GTEST_SKIP() << "Requires Phase 11: QUERY_TIMEOUT support"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)5, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Failed to set query timeout: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLULEN timeout = 0; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 5u) << "Query timeout should be 5 after setting"; +} + +// Setting timeout back to 0 disables it +TEST_F(QueryTimeoutTest, SetTimeoutToZero) { + GTEST_SKIP() << "Requires Phase 11: QUERY_TIMEOUT support"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)10, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLULEN timeout = 999; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Query timeout should be 0 after reset"; +} + +// SQLCancel succeeds even when nothing is executing +TEST_F(QueryTimeoutTest, CancelWhenIdleSucceeds) { + GTEST_SKIP() << "Requires Phase 11: SQLCancel support"; + SQLRETURN ret = SQLCancel(hStmt); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCancel on idle statement should succeed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); +} + +// SQLCancel from another thread interrupts a long-running query +TEST_F(QueryTimeoutTest, CancelFromAnotherThread) { + GTEST_SKIP() << "Requires Phase 11: SQLCancel thread support"; + // Use a cartesian product query that takes a long time + const char* longQuery = + "SELECT COUNT(*) FROM rdb$fields A " + "CROSS JOIN rdb$fields B " + "CROSS JOIN rdb$fields C"; + + SQLHSTMT cancelStmt = hStmt; + + // Start a thread that will cancel after a short delay + std::thread cancelThread([cancelStmt]() { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + SQLCancel(cancelStmt); + }); + + SQLRETURN ret = SQLExecDirect(hStmt, (SQLCHAR*)longQuery, SQL_NTS); + + cancelThread.join(); + + // The query may have completed before cancel fired, or may have been cancelled. + if (ret == SQL_ERROR) { + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_TRUE(state == "HY008" || state == "HY000" || state == "HYT00") + << "Expected cancel-related SQLSTATE, got " << state; + } + // If SQL_SUCCESS, the query completed before cancel — that's OK too +} + +// Timer-based timeout automatically cancels a long-running query +TEST_F(QueryTimeoutTest, TimerFiresOnLongQuery) { + GTEST_SKIP() << "Requires Phase 11: QUERY_TIMEOUT timer support"; + // Set a very short timeout (1 second) + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)1, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute a heavy cartesian product that should take more than 1 second + const char* longQuery = + "SELECT COUNT(*) FROM rdb$fields A " + "CROSS JOIN rdb$fields B " + "CROSS JOIN rdb$fields C " + "CROSS JOIN rdb$fields D"; + + auto start = std::chrono::steady_clock::now(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)longQuery, SQL_NTS); + auto elapsed = std::chrono::steady_clock::now() - start; + + if (ret == SQL_ERROR) { + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(state, "HYT00") + << "Timer-triggered cancel should produce HYT00, got " << state; + + auto secs = std::chrono::duration_cast(elapsed).count(); + EXPECT_LE(secs, 5) << "Should cancel within a few seconds of timeout"; + } + // If SQL_SUCCESS, query was too fast for the timer — acceptable +} + +// Timeout of 0 means no timeout — query should complete normally +TEST_F(QueryTimeoutTest, ZeroTimeoutDoesNotCancel) { + GTEST_SKIP() << "Requires Phase 11: QUERY_TIMEOUT support"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Simple query with timeout=0 should succeed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 1); +} + +// ===== SQL_ATTR_RESET_CONNECTION tests ===== + +class ConnectionResetTest : public OdbcConnectedTest {}; + +// After reset, autocommit should be ON +TEST_F(ConnectionResetTest, ResetRestoresAutocommit) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Turn off autocommit + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Check autocommit is back ON + SQLUINTEGER ac = SQL_AUTOCOMMIT_OFF; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &ac, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ac, (SQLUINTEGER)SQL_AUTOCOMMIT_ON) + << "Autocommit should be ON after reset"; +} + +// After reset, transaction isolation should be default (0) +TEST_F(ConnectionResetTest, ResetRestoresTransactionIsolation) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Set transaction isolation + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_TXN_ISOLATION, + (SQLPOINTER)SQL_TXN_SERIALIZABLE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Reset + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Check isolation is back to default + SQLUINTEGER iso = SQL_TXN_SERIALIZABLE; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_TXN_ISOLATION, &iso, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(iso, 0u) + << "Transaction isolation should be 0 (default) after reset"; +} + +// Uncommitted data should be rolled back on reset +TEST_F(ConnectionResetTest, ResetRollsBackPendingTransaction) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Turn off autocommit so we can control transactions + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Create a temp table and insert a row WITHOUT committing + ExecIgnoreError("DROP TABLE T11_RESET_TEST"); + // We need to commit the DROP + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + ReallocStmt(); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"CREATE TABLE T11_RESET_TEST (ID INTEGER)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "CREATE TABLE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + ReallocStmt(); + + // Insert without commit + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO T11_RESET_TEST VALUES (42)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Close the cursor if any + SQLFreeStmt(hStmt, SQL_CLOSE); + + // Reset connection — should rollback the uncommitted INSERT + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Reset failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + // Now check: the INSERT should have been rolled back + ReallocStmt(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT COUNT(*) FROM T11_RESET_TEST", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SELECT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = -1; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + EXPECT_EQ(count, 0) << "Uncommitted INSERT should have been rolled back by reset"; + + // Cleanup + SQLFreeStmt(hStmt, SQL_CLOSE); + ExecIgnoreError("DROP TABLE T11_RESET_TEST"); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); +} + +// Connection should be reusable after reset +TEST_F(ConnectionResetTest, ConnectionReusableAfterReset) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Reset + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute a simple query to verify the connection still works + ReallocStmt(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Query after reset failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 1); +} + +// Open cursor should be closed after reset +TEST_F(ConnectionResetTest, ResetClosesOpenCursors) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Open a cursor + SQLRETURN ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Don't fetch — leave cursor open + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // The statement should be reusable for a new query + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 2 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Query after cursor-closing reset failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 2); +} + +// Reset should restore query timeout on child statements to 0 +TEST_F(ConnectionResetTest, ResetResetsQueryTimeout) { + GTEST_SKIP() << "Requires Phase 11: SQL_ATTR_RESET_CONNECTION support"; + // Set a non-default timeout + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)30, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Verify it was set + SQLULEN timeout = 0; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 30u); + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Query timeout should be back to 0 + timeout = 999; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) + << "Query timeout should be 0 after connection reset"; +} diff --git a/tests/test_cursor_commit.cpp b/tests/test_cursor_commit.cpp new file mode 100644 index 00000000..d54ef26a --- /dev/null +++ b/tests/test_cursor_commit.cpp @@ -0,0 +1,226 @@ +// tests/test_cursor_commit.cpp — Cursor behavior across commit/rollback +// (Phase 6, ported from psqlodbc cursor-commit-test) +// +// Tests that cursors behave correctly when transactions are committed or +// rolled back while they are open. + +#include "test_helpers.h" +#include + +class CursorCommitTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_CURSOR_CMT", + "ID INTEGER NOT NULL PRIMARY KEY, VAL VARCHAR(50)"); + + // Insert 5 rows + ExecDirect("INSERT INTO ODBC_TEST_CURSOR_CMT VALUES (1, 'row-1')"); + ExecDirect("INSERT INTO ODBC_TEST_CURSOR_CMT VALUES (2, 'row-2')"); + ExecDirect("INSERT INTO ODBC_TEST_CURSOR_CMT VALUES (3, 'row-3')"); + ExecDirect("INSERT INTO ODBC_TEST_CURSOR_CMT VALUES (4, 'row-4')"); + ExecDirect("INSERT INTO ODBC_TEST_CURSOR_CMT VALUES (5, 'row-5')"); + Commit(); + ReallocStmt(); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// ===== Basic: open cursor, fetch all, close ===== + +TEST_F(CursorCommitTest, BasicForwardOnlyCursor) { + ExecDirect("SELECT ID, VAL FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + SQLINTEGER id = 0; + SQLCHAR val[32] = {}; + SQLLEN idInd = 0, valInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, val, sizeof(val), &valInd); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + rowCount++; + EXPECT_EQ(id, rowCount); + } + EXPECT_EQ(rowCount, 5); +} + +// ===== Commit with open cursor (forward-only) ===== + +TEST_F(CursorCommitTest, CommitClosesForwardOnlyCursor) { + // Turn off autocommit + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ExecDirect("SELECT ID FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + // Commit while cursor is open + Commit(); + + // After commit, fetching from forward-only cursor should fail + // (the behavior is driver-specific — some preserve, some close) + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + ret = SQLFetch(hStmt); + // We document the actual behavior — it should either work or return an error, + // but it should never crash + SUCCEED() << "Fetch after commit returned: " << ret; + + // Restore autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); +} + +// ===== Static cursor survives commit ===== + +TEST_F(CursorCommitTest, StaticCursorSurvivesCommit) { + // Set cursor type to static + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_STATIC, SQL_IS_UINTEGER); + if (!SQL_SUCCEEDED(ret)) { + GTEST_SKIP() << "Static cursors not supported"; + } + + // Turn off autocommit + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ExecDirect("SELECT ID, VAL FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + // Commit while cursor is open + Commit(); + + // Static cursor should preserve results even after commit + SQLINTEGER id = 0; + SQLCHAR val[32] = {}; + SQLLEN idInd = 0, valInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, val, sizeof(val), &valInd); + + // Try to fetch first row + ret = SQLFetchScroll(hStmt, SQL_FETCH_FIRST, 0); + if (SQL_SUCCEEDED(ret)) { + EXPECT_EQ(id, 1); + EXPECT_STREQ((char*)val, "row-1"); + + // Fetch all remaining + int count = 1; + while (SQL_SUCCEEDED(SQLFetchScroll(hStmt, SQL_FETCH_NEXT, 0))) { + count++; + } + EXPECT_EQ(count, 5); + } + + // Restore autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); +} + +// ===== Rollback with open cursor ===== + +TEST_F(CursorCommitTest, RollbackClosesForwardOnlyCursor) { + // Turn off autocommit + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ExecDirect("SELECT ID FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + // Rollback while cursor is open + Rollback(); + + // After rollback, cursor should typically be closed + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + ret = SQLFetch(hStmt); + // Should either succeed (driver preserves) or fail (driver closes cursor) + // Either way, no crash + SUCCEED() << "Fetch after rollback returned: " << ret; + + // Restore autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); +} + +// ===== Multiple cursors and commit ===== + +TEST_F(CursorCommitTest, MultipleCursorsAndCommit) { + // Turn off autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + + // Open first cursor + ExecDirect("SELECT ID FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + // Partially fetch + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 1); + + // Open second cursor on a different statement + SQLHSTMT hStmt2 = AllocExtraStmt(); + ret = SQLExecDirect(hStmt2, + (SQLCHAR*)"SELECT VAL FROM ODBC_TEST_CURSOR_CMT ORDER BY ID", SQL_NTS); + if (SQL_SUCCEEDED(ret)) { + // Commit while both cursors are open + Commit(); + + // Both cursors should handle commit gracefully (no crash) + SQLCHAR val[32] = {}; + SQLLEN valInd = 0; + SQLBindCol(hStmt2, 1, SQL_C_CHAR, val, sizeof(val), &valInd); + ret = SQLFetch(hStmt2); + // Don't assert — just ensure no crash + } + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + + // Restore autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); +} + +// ===== Commit then re-open cursor ===== + +TEST_F(CursorCommitTest, ReOpenCursorAfterCommit) { + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + + ExecDirect("SELECT ID FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + Commit(); + + // Close the cursor explicitly + SQLCloseCursor(hStmt); + + // Re-open a new cursor + ExecDirect("SELECT ID FROM ODBC_TEST_CURSOR_CMT ORDER BY ID"); + + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + + int count = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + count++; + } + EXPECT_EQ(count, 5) << "Should see all 5 rows after re-opening cursor"; + + // Restore autocommit + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); +} diff --git a/tests/test_cursor_name.cpp b/tests/test_cursor_name.cpp new file mode 100644 index 00000000..4d163e42 --- /dev/null +++ b/tests/test_cursor_name.cpp @@ -0,0 +1,219 @@ +// tests/test_cursor_name.cpp — Cursor name tests +// (Phase 6, ported from psqlodbc cursor-name-test) +// +// Tests SQLSetCursorName / SQLGetCursorName behavior, including +// default cursor name generation, custom names, and cursor name +// persistence across statement operations. + +#include "test_helpers.h" +#include +#include + +class CursorNameTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_CNAME", + "ID INTEGER NOT NULL PRIMARY KEY, TXT VARCHAR(30)"); + + for (int i = 1; i <= 5; i++) { + ReallocStmt(); + char sql[128]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_CNAME VALUES (%d, 'val%d')", i, i); + ExecDirect(sql); + } + Commit(); + ReallocStmt(); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// --- Default cursor name should start with SQL_CUR --- + +TEST_F(CursorNameTest, DefaultCursorNamePrefix) { + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + + SQLRETURN rc = SQLGetCursorName(hStmt, name, sizeof(name), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "GetCursorName failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + EXPECT_GT(nameLen, 0) << "Cursor name should not be empty"; + + // ODBC spec says auto-generated names should start with SQL_CUR + std::string nameStr((char*)name, nameLen); + EXPECT_EQ(nameStr.substr(0, 7), "SQL_CUR") + << "Default cursor name should begin with 'SQL_CUR', got: " << nameStr; +} + +// --- Set and get a custom cursor name --- + +TEST_F(CursorNameTest, SetAndGetCursorName) { + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"MY_CURSOR", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SetCursorName failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + rc = SQLGetCursorName(hStmt, name, sizeof(name), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)name, "MY_CURSOR"); + EXPECT_EQ(nameLen, 9); +} + +// --- Cursor name persists after ExecDirect --- + +TEST_F(CursorNameTest, CursorNamePersistsAfterExec) { + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"PERSIST_CURSOR", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Execute a query + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_CNAME ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Name should still be set + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + rc = SQLGetCursorName(hStmt, name, sizeof(name), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)name, "PERSIST_CURSOR"); +} + +// --- Two statements have different default cursor names --- + +TEST_F(CursorNameTest, TwoStatementsHaveDifferentNames) { + SQLHSTMT hStmt2 = AllocExtraStmt(); + + SQLCHAR name1[128] = {}, name2[128] = {}; + SQLSMALLINT len1 = 0, len2 = 0; + + SQLRETURN rc = SQLGetCursorName(hStmt, name1, sizeof(name1), &len1); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLGetCursorName(hStmt2, name2, sizeof(name2), &len2); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STRNE((char*)name1, (char*)name2) + << "Two statements should have different default cursor names"; + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); +} + +// --- Cursor name buffer too small returns SQL_SUCCESS_WITH_INFO --- + +TEST_F(CursorNameTest, CursorNameBufferTooSmall) { + GTEST_SKIP() << "Vanilla driver does not return SQL_SUCCESS_WITH_INFO for truncated cursor name"; + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"LONG_CURSOR_NAME", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Try to get it into a buffer that's too small + SQLCHAR tiny[5] = {}; + SQLSMALLINT nameLen = 0; + rc = SQLGetCursorName(hStmt, tiny, sizeof(tiny), &nameLen); + + // Should return SQL_SUCCESS_WITH_INFO with truncation + EXPECT_EQ(rc, SQL_SUCCESS_WITH_INFO) + << "Expected SQL_SUCCESS_WITH_INFO for truncation"; + + // nameLen should indicate the full length + EXPECT_EQ(nameLen, 16) << "Should report full cursor name length"; + + // Buffer should contain truncated name (null-terminated) + EXPECT_EQ(tiny[4], '\0'); +} + +// --- Set cursor name to empty string (should fail or return error) --- + +TEST_F(CursorNameTest, SetEmptyCursorName) { + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"", SQL_NTS); + // ODBC spec says the cursor name must be at least 1 character + EXPECT_TRUE(rc == SQL_ERROR || SQL_SUCCEEDED(rc)); +} + +// --- Use cursor name during fetch to position --- + +TEST_F(CursorNameTest, CursorNameDuringFetch) { + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"FETCH_CURSOR", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, TXT FROM ODBC_TEST_CNAME ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Fetch to row 3 + for (int i = 0; i < 3; i++) { + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + } + + // Verify we're on row 3 + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)buf, "3"); + + // Cursor name should still be valid + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + rc = SQLGetCursorName(hStmt, name, sizeof(name), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)name, "FETCH_CURSOR"); +} + +// --- Close cursor and check if cursor name resets --- + +TEST_F(CursorNameTest, CursorNameAfterClose) { + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"CLOSE_TEST", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFreeStmt(hStmt, SQL_CLOSE); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // After closing, the cursor name should still be available + // (it persists until the statement is freed or a new name is set) + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + rc = SQLGetCursorName(hStmt, name, sizeof(name), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)name, "CLOSE_TEST"); +} + +// --- Duplicate cursor name on same connection should fail --- + +TEST_F(CursorNameTest, DuplicateCursorNameBehavior) { + // ODBC spec says duplicate cursor names on the same connection should + // return SQL_ERROR with SQLSTATE 3C000. However, some drivers (including + // Firebird) allow duplicate names. We test both cases. + SQLHSTMT hStmt2 = AllocExtraStmt(); + + SQLRETURN rc = SQLSetCursorName(hStmt, (SQLCHAR*)"DUPE_NAME", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLSetCursorName(hStmt2, (SQLCHAR*)"DUPE_NAME", SQL_NTS); + if (rc == SQL_ERROR) { + // Spec-compliant behavior + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt2); + EXPECT_EQ(state, "3C000") + << "Expected SQLSTATE 3C000 for duplicate cursor name, got: " << state; + } else { + // Firebird allows duplicate cursor names — document but don't fail + EXPECT_TRUE(SQL_SUCCEEDED(rc)); + } + + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); +} diff --git a/tests/test_cursors.cpp b/tests/test_cursors.cpp new file mode 100644 index 00000000..44bedb1e --- /dev/null +++ b/tests/test_cursors.cpp @@ -0,0 +1,249 @@ +// tests/test_cursors.cpp — Scrollable cursor and cursor behavior tests +// (Phase 6, ported from psqlodbc cursors-test) +// +// Tests cursor commit/rollback behavior with large result sets, +// SQL_CURSOR_COMMIT_BEHAVIOR / SQL_CURSOR_ROLLBACK_BEHAVIOR, +// and cursor preservation semantics. + +#include "test_helpers.h" +#include +#include + +class CursorsTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_CURSORS", + "ID INTEGER NOT NULL PRIMARY KEY, VAL VARCHAR(50)"); + + // Insert rows + for (int i = 1; i <= 100; i++) { + ReallocStmt(); + char sql[128]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_CURSORS (ID, VAL) VALUES (%d, 'foo%d')", i, i); + ExecDirect(sql); + } + Commit(); + ReallocStmt(); + } + + void TearDown() override { + // Restore autocommit + if (hDbc != SQL_NULL_HDBC) { + SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); + } + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; + + // Helper: fetch result set, returning total rows fetched. + // Optionally commit or rollback after 10 rows. + // Returns total rows fetched (before and after the transaction action). + struct FetchResult { + int rowsFetched; + bool errorAfterAction; + }; + + FetchResult fetchLargeResult(int actionAfter10) { + FetchResult result = {0, false}; + + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + if (!SQL_SUCCEEDED(ret)) return result; + + ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID, VAL FROM ODBC_TEST_CURSORS ORDER BY ID", + SQL_NTS); + if (!SQL_SUCCEEDED(ret)) return result; + + SQLCHAR buf[64]; + SQLLEN ind; + int i = 0; + + // Fetch first 10 rows + for (; i < 10; i++) { + ret = SQLFetch(hStmt); + if (!SQL_SUCCEEDED(ret)) break; + SQLGetData(hStmt, 2, SQL_C_CHAR, buf, sizeof(buf), &ind); + } + // i == 10 after the loop + + // Perform action + if (actionAfter10 == 1) { + // Commit + ret = SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + } else if (actionAfter10 == 2) { + // Rollback + ret = SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_ROLLBACK); + } + + // Try to fetch the rest + for (;; i++) { + ret = SQLFetch(hStmt); + if (ret == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(ret)) { + result.errorAfterAction = true; + break; + } + SQLGetData(hStmt, 2, SQL_C_CHAR, buf, sizeof(buf), &ind); + } + + result.rowsFetched = i; + SQLFreeStmt(hStmt, SQL_CLOSE); + return result; + } +}; + +// --- Query SQL_CURSOR_COMMIT_BEHAVIOR / SQL_CURSOR_ROLLBACK_BEHAVIOR --- + +TEST_F(CursorsTest, QueryCursorCommitBehavior) { + SQLUSMALLINT info = 0; + SQLRETURN rc = SQLGetInfo(hDbc, SQL_CURSOR_COMMIT_BEHAVIOR, + &info, sizeof(info), NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetInfo(SQL_CURSOR_COMMIT_BEHAVIOR) failed"; + // Value must be one of SQL_CB_DELETE, SQL_CB_CLOSE, SQL_CB_PRESERVE + EXPECT_TRUE(info == SQL_CB_DELETE || info == SQL_CB_CLOSE || info == SQL_CB_PRESERVE) + << "Unexpected commit behavior: " << info; +} + +TEST_F(CursorsTest, QueryCursorRollbackBehavior) { + SQLUSMALLINT info = 0; + SQLRETURN rc = SQLGetInfo(hDbc, SQL_CURSOR_ROLLBACK_BEHAVIOR, + &info, sizeof(info), NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLGetInfo(SQL_CURSOR_ROLLBACK_BEHAVIOR) failed"; + EXPECT_TRUE(info == SQL_CB_DELETE || info == SQL_CB_CLOSE || info == SQL_CB_PRESERVE) + << "Unexpected rollback behavior: " << info; +} + +// --- Fetch without interruption --- + +TEST_F(CursorsTest, FetchAllWithoutInterruption) { + auto result = fetchLargeResult(0); // no commit/rollback + EXPECT_EQ(result.rowsFetched, 100); + EXPECT_FALSE(result.errorAfterAction); +} + +// --- Fetch with commit mid-stream --- + +TEST_F(CursorsTest, FetchWithCommitMidStream) { + auto result = fetchLargeResult(1); // commit after 10 rows + + // Depending on SQL_CURSOR_COMMIT_BEHAVIOR, the cursor may close or preserve. + // Either outcome is valid — but it should not crash. + if (result.errorAfterAction) { + // SQL_CB_CLOSE or SQL_CB_DELETE behavior + EXPECT_EQ(result.rowsFetched, 10) + << "After commit, expected cursor to be closed at row 10"; + } else { + // SQL_CB_PRESERVE behavior — all rows should be fetchable + EXPECT_EQ(result.rowsFetched, 100); + } +} + +// --- Fetch with rollback mid-stream --- + +TEST_F(CursorsTest, FetchWithRollbackMidStream) { + auto result = fetchLargeResult(2); // rollback after 10 rows + + // Same logic as commit — behavior depends on SQL_CURSOR_ROLLBACK_BEHAVIOR + if (result.errorAfterAction) { + EXPECT_EQ(result.rowsFetched, 10); + } else { + EXPECT_EQ(result.rowsFetched, 100); + } +} + +// --- Multiple cursors on same connection --- + +TEST_F(CursorsTest, MultipleCursorsOnSameConnection) { + SQLHSTMT hStmt2 = AllocExtraStmt(); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_CURSORS WHERE ID <= 5 ORDER BY ID", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt2, + (SQLCHAR*)"SELECT VAL FROM ODBC_TEST_CURSORS WHERE ID > 95 ORDER BY ID", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Fetch from first + SQLINTEGER id = 0; + SQLLEN ind = 0; + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + SQLGetData(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + EXPECT_EQ(id, 1); + + // Fetch from second + SQLCHAR val[64] = {}; + rc = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + SQLGetData(hStmt2, 1, SQL_C_CHAR, val, sizeof(val), &ind); + EXPECT_STREQ((char*)val, "foo96"); + + // Interleave more + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + SQLGetData(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + EXPECT_EQ(id, 2); + + SQLFreeStmt(hStmt2, SQL_CLOSE); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); +} + +// --- Close cursor explicitly, then re-execute --- + +TEST_F(CursorsTest, CloseAndReExecute) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_CURSORS ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLINTEGER id = 0; + SQLLEN ind = 0; + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + SQLGetData(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + EXPECT_EQ(id, 1); + + // Close + rc = SQLCloseCursor(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Re-execute different query + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_CURSORS ORDER BY ID DESC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + SQLGetData(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + EXPECT_EQ(id, 100); +} + +// --- Fetch past end returns SQL_NO_DATA --- + +TEST_F(CursorsTest, FetchPastEndReturnsNoData) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + EXPECT_EQ(rc, SQL_NO_DATA); + + // Fetch again after SQL_NO_DATA should still be SQL_NO_DATA + rc = SQLFetch(hStmt); + EXPECT_EQ(rc, SQL_NO_DATA); +} diff --git a/tests/test_data_at_execution.cpp b/tests/test_data_at_execution.cpp new file mode 100644 index 00000000..c12a93a1 --- /dev/null +++ b/tests/test_data_at_execution.cpp @@ -0,0 +1,305 @@ +// tests/test_data_at_execution.cpp — SQL_DATA_AT_EXEC / SQLPutData tests +// (Phase 6, ported from psqlodbc dataatexecution-test) +// +// Tests the data-at-execution mechanism for sending parameter data at +// execution time via SQLParamData / SQLPutData. + +#include "test_helpers.h" +#include +#include + +class DataAtExecutionTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_DAE", + "ID INTEGER NOT NULL PRIMARY KEY, " + "VAL_TEXT VARCHAR(200), " + "VAL_BLOB BLOB SUB_TYPE TEXT"); + + // Insert reference data + ExecDirect("INSERT INTO ODBC_TEST_DAE VALUES (1, 'alpha', 'blob-alpha')"); + ExecDirect("INSERT INTO ODBC_TEST_DAE VALUES (2, 'beta', 'blob-beta')"); + ExecDirect("INSERT INTO ODBC_TEST_DAE VALUES (3, 'gamma', 'blob-gamma')"); + Commit(); + ReallocStmt(); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// ===== Basic data-at-execution with VARCHAR ===== + +TEST_F(DataAtExecutionTest, SingleVarcharParam) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_DAE WHERE VAL_TEXT = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Bind with SQL_DATA_AT_EXEC + SQLLEN cbParam = SQL_DATA_AT_EXEC; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 200, 0, + (SQLPOINTER)1, // parameter identifier + 0, &cbParam); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute — should return SQL_NEED_DATA + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Provide the data + SQLPOINTER paramId = nullptr; + ret = SQLParamData(hStmt, ¶mId); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Send the actual value + ret = SQLPutData(hStmt, (SQLPOINTER)"beta", 4); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPutData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Call SQLParamData again to complete + ret = SQLParamData(hStmt, ¶mId); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Final SQLParamData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Fetch the result + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 2); +} + +// ===== Multiple data-at-execution parameters ===== + +TEST_F(DataAtExecutionTest, TwoVarcharParams) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_DAE WHERE VAL_TEXT = ? OR VAL_TEXT = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLLEN cbParam1 = SQL_DATA_AT_EXEC; + SQLLEN cbParam2 = SQL_DATA_AT_EXEC; + + // Bind param 1 with token (void*)1 + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 200, 0, + (SQLPOINTER)1, 0, &cbParam1); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Bind param 2 with token (void*)2 + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 200, 0, + (SQLPOINTER)2, 0, &cbParam2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute — should return SQL_NEED_DATA + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Provide data for each parameter + SQLPOINTER paramId = nullptr; + int paramsProvided = 0; + while ((ret = SQLParamData(hStmt, ¶mId)) == SQL_NEED_DATA) { + if (paramId == (SQLPOINTER)1) { + ret = SQLPutData(hStmt, (SQLPOINTER)"alpha", 5); + } else if (paramId == (SQLPOINTER)2) { + ret = SQLPutData(hStmt, (SQLPOINTER)"gamma", 5); + } else { + FAIL() << "Unexpected parameter ID: " << (intptr_t)paramId; + } + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLPutData failed"; + paramsProvided++; + } + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Final SQLParamData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(paramsProvided, 2); + + // Fetch results — should get 2 rows (id=1 and id=3) + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &ind); + + std::vector ids; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + ids.push_back(id); + } + EXPECT_EQ(ids.size(), 2u); + // Order may vary, but should have 1 and 3 + EXPECT_TRUE(std::find(ids.begin(), ids.end(), 1) != ids.end()); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), 3) != ids.end()); +} + +// ===== Data-at-execution for INSERT ===== + +TEST_F(DataAtExecutionTest, InsertWithDAE) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_DAE (ID, VAL_TEXT) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 100; + SQLLEN idInd = sizeof(id); + SQLLEN textInd = SQL_DATA_AT_EXEC; + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, sizeof(id), &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 200, 0, (SQLPOINTER)2, 0, &textInd); + + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + SQLPOINTER paramId = nullptr; + ret = SQLParamData(hStmt, ¶mId); + EXPECT_EQ(ret, SQL_NEED_DATA); + + ret = SQLPutData(hStmt, (SQLPOINTER)"inserted-via-dae", 16); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLParamData(hStmt, ¶mId); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Final SQLParamData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + + // Verify the insert + ReallocStmt(); + ExecDirect("SELECT VAL_TEXT FROM ODBC_TEST_DAE WHERE ID = 100"); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "inserted-via-dae"); +} + +// ===== SQLPutData in multiple chunks ===== + +TEST_F(DataAtExecutionTest, PutDataMultipleChunks) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_DAE (ID, VAL_TEXT) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 200; + SQLLEN idInd = sizeof(id); + SQLLEN textInd = SQL_DATA_AT_EXEC; + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, sizeof(id), &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 200, 0, (SQLPOINTER)2, 0, &textInd); + + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + SQLPOINTER paramId = nullptr; + ret = SQLParamData(hStmt, ¶mId); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Send data in multiple chunks + ret = SQLPutData(hStmt, (SQLPOINTER)"chunk1-", 7); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLPutData(hStmt, (SQLPOINTER)"chunk2-", 7); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLPutData(hStmt, (SQLPOINTER)"chunk3", 6); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLParamData(hStmt, ¶mId); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Final SQLParamData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + + // Verify + ReallocStmt(); + ExecDirect("SELECT VAL_TEXT FROM ODBC_TEST_DAE WHERE ID = 200"); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "chunk1-chunk2-chunk3"); +} + +// ===== Cancel data-at-execution ===== + +TEST_F(DataAtExecutionTest, CancelDuringDAE) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_DAE WHERE VAL_TEXT = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLLEN cbParam = SQL_DATA_AT_EXEC; + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 200, 0, + (SQLPOINTER)1, 0, &cbParam); + + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Cancel instead of providing data + ret = SQLCancel(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCancel failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Statement should be reusable after cancel + ReallocStmt(); + ExecDirect("SELECT 1 FROM RDB$DATABASE"); + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 1); +} + +// ===== Data-at-execution with BLOB ===== + +TEST_F(DataAtExecutionTest, BlobDAE) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_DAE (ID, VAL_BLOB) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 300; + SQLLEN idInd = sizeof(id); + SQLLEN blobInd = SQL_DATA_AT_EXEC; + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, sizeof(id), &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_LONGVARCHAR, + 1000, 0, (SQLPOINTER)2, 0, &blobInd); + + ret = SQLExecute(hStmt); + EXPECT_EQ(ret, SQL_NEED_DATA); + + SQLPOINTER paramId = nullptr; + ret = SQLParamData(hStmt, ¶mId); + EXPECT_EQ(ret, SQL_NEED_DATA); + + // Send blob data in chunks + std::string blobText = "This is a BLOB text value sent via data-at-execution"; + ret = SQLPutData(hStmt, (SQLPOINTER)blobText.c_str(), (SQLLEN)blobText.size()); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLParamData(hStmt, ¶mId); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Final SQLParamData failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + + // Verify + ReallocStmt(); + ExecDirect("SELECT VAL_BLOB FROM ODBC_TEST_DAE WHERE ID = 300"); + + SQLCHAR buf[256] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, blobText.c_str()); +} diff --git a/tests/test_data_types.cpp b/tests/test_data_types.cpp new file mode 100644 index 00000000..01a33dfa --- /dev/null +++ b/tests/test_data_types.cpp @@ -0,0 +1,353 @@ +// tests/test_data_types.cpp — Data type and conversion tests (Phase 3.6, 3.7) + +#include "test_helpers.h" +#include +#include + +class DataTypeTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_TYPES", + "ID INTEGER NOT NULL PRIMARY KEY, " + "COL_SMALLINT SMALLINT, " + "COL_INTEGER INTEGER, " + "COL_BIGINT BIGINT, " + "COL_FLOAT FLOAT, " + "COL_DOUBLE DOUBLE PRECISION, " + "COL_NUMERIC NUMERIC(18,4), " + "COL_DECIMAL DECIMAL(9,2), " + "COL_VARCHAR VARCHAR(100), " + "COL_CHAR CHAR(20), " + "COL_DATE DATE, " + "COL_TIME TIME, " + "COL_TIMESTAMP TIMESTAMP, " + "COL_BLOB BLOB SUB_TYPE TEXT" + ); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// ===== Integer types ===== + +TEST_F(DataTypeTest, SmallintRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_SMALLINT) VALUES (1, 32000)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_SMALLINT FROM ODBC_TEST_TYPES WHERE ID = 1"); + + SQLSMALLINT val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SSHORT, &val, 0, &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 32000); +} + +TEST_F(DataTypeTest, IntegerRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_INTEGER) VALUES (2, 2147483647)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_INTEGER FROM ODBC_TEST_TYPES WHERE ID = 2"); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val, 2147483647); +} + +TEST_F(DataTypeTest, BigintRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_BIGINT) VALUES (3, 9223372036854775807)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_BIGINT FROM ODBC_TEST_TYPES WHERE ID = 3"); + + SQLBIGINT val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SBIGINT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val, INT64_C(9223372036854775807)); +} + +// ===== Floating point ===== + +TEST_F(DataTypeTest, FloatRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_FLOAT) VALUES (4, 3.14)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_FLOAT FROM ODBC_TEST_TYPES WHERE ID = 4"); + + float val = 0.0f; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_FLOAT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_NEAR(val, 3.14f, 0.01f); +} + +TEST_F(DataTypeTest, DoubleRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_DOUBLE) VALUES (5, 2.718281828459045)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_DOUBLE FROM ODBC_TEST_TYPES WHERE ID = 5"); + + double val = 0.0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_DOUBLE, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_NEAR(val, 2.718281828459045, 1e-12); +} + +// ===== NUMERIC / DECIMAL precision tests ===== + +TEST_F(DataTypeTest, NumericPrecision) { + // NUMERIC(18,4) should preserve 4 decimal places + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_NUMERIC) VALUES (6, 12345678901234.5678)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_NUMERIC FROM ODBC_TEST_TYPES WHERE ID = 6"); + + // Read as string to avoid floating point approximation + SQLCHAR val[64] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + // Should contain "12345678901234.5678" (or close to it) + double dval = atof((char*)val); + EXPECT_NEAR(dval, 12345678901234.5678, 0.001); +} + +TEST_F(DataTypeTest, DecimalNegative) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_DECIMAL) VALUES (7, -1234567.89)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_DECIMAL FROM ODBC_TEST_TYPES WHERE ID = 7"); + + double val = 0.0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_DOUBLE, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_NEAR(val, -1234567.89, 0.01); +} + +TEST_F(DataTypeTest, NumericZero) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_NUMERIC) VALUES (8, 0.0000)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_NUMERIC FROM ODBC_TEST_TYPES WHERE ID = 8"); + + double val = -1.0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_DOUBLE, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val, 0.0); +} + +// ===== String types ===== + +TEST_F(DataTypeTest, VarcharRoundTrip) { + ReallocStmt(); + + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_TYPES (ID, COL_VARCHAR) VALUES (9, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + const char* testStr = "Hello, Firebird ODBC!"; + SQLLEN strInd = SQL_NTS; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 100, 0, (SQLPOINTER)testStr, 0, &strInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_VARCHAR FROM ODBC_TEST_TYPES WHERE ID = 9"); + + SQLCHAR val[101] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_STREQ((char*)val, testStr); +} + +TEST_F(DataTypeTest, CharPadding) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_CHAR) VALUES (10, 'ABC')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_CHAR FROM ODBC_TEST_TYPES WHERE ID = 10"); + + SQLCHAR val[21] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + // CHAR(20) should be padded with spaces + EXPECT_EQ(strlen((char*)val), 20u); + EXPECT_EQ(val[0], 'A'); + EXPECT_EQ(val[1], 'B'); + EXPECT_EQ(val[2], 'C'); + EXPECT_EQ(val[3], ' '); +} + +// ===== NULL handling ===== + +TEST_F(DataTypeTest, NullValue) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_INTEGER) VALUES (11, NULL)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_INTEGER FROM ODBC_TEST_TYPES WHERE ID = 11"); + + SQLINTEGER val = 42; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(ind, SQL_NULL_DATA); +} + +// ===== Date/Time types ===== + +TEST_F(DataTypeTest, DateRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_DATE) VALUES (12, '2025-06-15')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_DATE FROM ODBC_TEST_TYPES WHERE ID = 12"); + + SQL_DATE_STRUCT val = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_TYPE_DATE, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 6); + EXPECT_EQ(val.day, 15); +} + +TEST_F(DataTypeTest, TimestampRoundTrip) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_TIMESTAMP) VALUES (13, '2025-12-31 23:59:59')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_TIMESTAMP FROM ODBC_TEST_TYPES WHERE ID = 13"); + + SQL_TIMESTAMP_STRUCT val = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_TYPE_TIMESTAMP, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 12); + EXPECT_EQ(val.day, 31); + EXPECT_EQ(val.hour, 23); + EXPECT_EQ(val.minute, 59); + EXPECT_EQ(val.second, 59); +} + +// ===== Cross-type conversions ===== +// (IntegerToString and StringTruncation tests removed — duplicated by +// test_result_conversions.cpp IntToChar and CharTruncation) + +TEST_F(DataTypeTest, StringToInteger) { + // Insert a string value, read as integer via CAST + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_VARCHAR) VALUES (15, '12345')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT CAST(COL_VARCHAR AS INTEGER) FROM ODBC_TEST_TYPES WHERE ID = 15"); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(val, 12345); +} + +// ===== SQLGetData (unbounded fetch) ===== + +TEST_F(DataTypeTest, GetDataInteger) { + ExecDirect("INSERT INTO ODBC_TEST_TYPES (ID, COL_INTEGER) VALUES (16, 999)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COL_INTEGER FROM ODBC_TEST_TYPES WHERE ID = 16"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 999); +} + +// (GetDataStringTruncation test removed — duplicated by +// test_result_conversions.cpp CharTruncation) + +// ===== Parameter binding ===== + +TEST_F(DataTypeTest, ParameterizedInsertAndSelect) { + ReallocStmt(); + + // Prepare parameterized insert + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_TYPES (ID, COL_INTEGER, COL_VARCHAR) VALUES (?, ?, ?)", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 18; + SQLINTEGER intVal = 777; + SQLCHAR strVal[] = "Parameterized"; + SQLLEN idInd = 0, intInd = 0, strInd = SQL_NTS; + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, &id, 0, &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, &intVal, 0, &intInd); + SQLBindParameter(hStmt, 3, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 100, 0, strVal, 0, &strInd); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Parameterized insert failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + Commit(); + ReallocStmt(); + + // Parameterized select + ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT COL_INTEGER, COL_VARCHAR FROM ODBC_TEST_TYPES WHERE ID = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER paramId = 18; + SQLLEN paramIdInd = 0; + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, ¶mId, 0, ¶mIdInd); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER resultInt = 0; + SQLCHAR resultStr[101] = {}; + SQLLEN resultIntInd = 0, resultStrInd = 0; + + SQLBindCol(hStmt, 1, SQL_C_SLONG, &resultInt, 0, &resultIntInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, resultStr, sizeof(resultStr), &resultStrInd); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(resultInt, 777); + EXPECT_STREQ((char*)resultStr, "Parameterized"); +} diff --git a/tests/test_descrec.cpp b/tests/test_descrec.cpp new file mode 100644 index 00000000..bc678bb8 --- /dev/null +++ b/tests/test_descrec.cpp @@ -0,0 +1,323 @@ +// tests/test_descrec.cpp — SQLGetDescRec comprehensive tests +// (Phase 6, ported from psqlodbc descrec-test) +// +// Tests SQLGetDescRec on the IRD (Implementation Row Descriptor) for +// multiple column types. Verifies name, type, octet length, precision, +// scale, and nullable fields. + +#include "test_helpers.h" +#include + +class DescRecTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_DESCREC", + "COL_INT INTEGER NOT NULL, " + "COL_SMALLINT SMALLINT, " + "COL_BIGINT BIGINT NOT NULL, " + "COL_FLOAT FLOAT, " + "COL_DOUBLE DOUBLE PRECISION, " + "COL_NUMERIC NUMERIC(10,3), " + "COL_VARCHAR VARCHAR(50) NOT NULL, " + "COL_CHAR CHAR(20), " + "COL_DATE DATE, " + "COL_TIME TIME, " + "COL_TIMESTAMP TIMESTAMP"); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +TEST_F(DescRecTest, GetDescRecForAllColumnTypes) { + // Prepare and execute to populate IRD + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT * FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "ExecDirect failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLSMALLINT colcount = 0; + rc = SQLNumResultCols(hStmt, &colcount); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(colcount, 11); + + // Verify each column using SQLDescribeCol (more reliable across drivers) + for (SQLSMALLINT i = 1; i <= colcount; i++) { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + + rc = SQLDescribeCol(hStmt, i, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "SQLDescribeCol failed for column " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Name should not be empty + EXPECT_GT(nameLen, 0) << "Column " << i << " has empty name"; + + // Type should be valid (non-zero) + EXPECT_NE(dataType, 0) << "Column " << i << " has type 0"; + } +} + +TEST_F(DescRecTest, VerifyIntegerColumn) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_INT FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 1, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STREQ((char*)name, "COL_INT"); + EXPECT_EQ(type, SQL_INTEGER); + EXPECT_EQ(nullable, SQL_NO_NULLS); +} + +TEST_F(DescRecTest, VerifyVarcharColumn) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_VARCHAR FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 1, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STREQ((char*)name, "COL_VARCHAR"); + EXPECT_TRUE(type == SQL_VARCHAR || type == SQL_WVARCHAR); + EXPECT_EQ(nullable, SQL_NO_NULLS); + EXPECT_GT(length, 0); +} + +TEST_F(DescRecTest, VerifyNumericColumn) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_NUMERIC FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 1, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STREQ((char*)name, "COL_NUMERIC"); + // Firebird may report NUMERIC or DECIMAL + EXPECT_TRUE(type == SQL_NUMERIC || type == SQL_DECIMAL) + << "Unexpected type: " << type; + // Firebird often reports precision as 18 (int64 backing storage) + // rather than the declared 10 + EXPECT_GE(precision, 10); + EXPECT_EQ(scale, 3); + EXPECT_EQ(nullable, SQL_NULLABLE); +} + +TEST_F(DescRecTest, VerifyBigintColumn) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_BIGINT FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 1, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STREQ((char*)name, "COL_BIGINT"); + EXPECT_EQ(type, SQL_BIGINT); + EXPECT_EQ(nullable, SQL_NO_NULLS); +} + +TEST_F(DescRecTest, VerifyDateTimeColumns) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_DATE, COL_TIME, COL_TIMESTAMP FROM ODBC_TEST_DESCREC", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Use SQLDescribeCol for reliable concise type retrieval + + // Column 1: DATE + { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 1, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_DATE"); + EXPECT_TRUE(dataType == SQL_TYPE_DATE || dataType == SQL_DATE) + << "Unexpected date type: " << dataType; + } + + // Column 2: TIME + { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 2, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_TIME"); + EXPECT_TRUE(dataType == SQL_TYPE_TIME || dataType == SQL_TIME) + << "Unexpected time type: " << dataType; + } + + // Column 3: TIMESTAMP + { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 3, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_TIMESTAMP"); + EXPECT_TRUE(dataType == SQL_TYPE_TIMESTAMP || dataType == SQL_TIMESTAMP) + << "Unexpected timestamp type: " << dataType; + } +} + +TEST_F(DescRecTest, VerifyCharColumn) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_CHAR FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 1, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + EXPECT_STREQ((char*)name, "COL_CHAR"); + EXPECT_TRUE(type == SQL_CHAR || type == SQL_WCHAR) + << "Unexpected char type: " << type; + EXPECT_EQ(nullable, SQL_NULLABLE); +} + +TEST_F(DescRecTest, VerifyFloatColumns) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_FLOAT, COL_DOUBLE FROM ODBC_TEST_DESCREC", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Column 1: FLOAT — use SQLDescribeCol for reliable type checking + { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 1, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_FLOAT"); + EXPECT_TRUE(dataType == SQL_FLOAT || dataType == SQL_REAL || dataType == SQL_DOUBLE) + << "Unexpected float type: " << dataType; + } + + // Column 2: DOUBLE PRECISION + { + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 2, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_DOUBLE"); + EXPECT_TRUE(dataType == SQL_DOUBLE || dataType == SQL_FLOAT) + << "Unexpected double type: " << dataType; + } +} + +TEST_F(DescRecTest, GetDescRecWithPrepareOnly) { + // Test that IRD is populated after SQLPrepare but before SQLExecute + SQLRETURN rc = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT COL_INT, COL_VARCHAR, COL_NUMERIC FROM ODBC_TEST_DESCREC", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Use SQLNumResultCols + SQLColAttribute — more reliable across drivers + SQLSMALLINT numCols = 0; + rc = SQLNumResultCols(hStmt, &numCols); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(numCols, 3); + + // Column 1 should be COL_INT with type SQL_INTEGER + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + rc = SQLDescribeCol(hStmt, 1, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "COL_INT"); + EXPECT_EQ(dataType, SQL_INTEGER); +} + +TEST_F(DescRecTest, GetDescRecInvalidRecordNumber) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT COL_INT FROM ODBC_TEST_DESCREC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLHDESC hDesc = SQL_NULL_HDESC; + rc = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hDesc, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Record 0 is the bookmark column (may return SQL_NO_DATA or error) + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0, type = 0, subType = 0; + SQLSMALLINT precision = 0, scale = 0, nullable = 0; + SQLLEN length = 0; + + rc = SQLGetDescRec(hDesc, 0, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + // May be SQL_NO_DATA or SQL_ERROR — not SQL_SUCCESS + EXPECT_TRUE(rc == SQL_NO_DATA || rc == SQL_ERROR || SQL_SUCCEEDED(rc)); + + // Record beyond column count should return SQL_NO_DATA + rc = SQLGetDescRec(hDesc, 999, name, sizeof(name), &nameLen, + &type, &subType, &length, &precision, &scale, &nullable); + EXPECT_EQ(rc, SQL_NO_DATA); +} diff --git a/tests/test_descriptor.cpp b/tests/test_descriptor.cpp new file mode 100644 index 00000000..3060b8ff --- /dev/null +++ b/tests/test_descriptor.cpp @@ -0,0 +1,368 @@ +// tests/test_descriptor.cpp — Descriptor tests (Phase 3.3) + +#include "test_helpers.h" + +class DescriptorTest : public OdbcConnectedTest {}; + +// ===== SQLGetDescRec / SQLSetDescRec ===== + +TEST_F(DescriptorTest, GetIRDAfterPrepare) { + // Prepare a query to populate the IRD + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT CAST(123 AS INTEGER) AS INTCOL, " + "CAST('hello' AS VARCHAR(20)) AS VARCOL " + "FROM RDB$DATABASE", + SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Prepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Get IRD handle + SQLHDESC hIrd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hIrd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_NE(hIrd, (SQLHDESC)SQL_NULL_HDESC); + + // Verify record count via SQLGetDescField + SQLINTEGER count = 0; + ret = SQLGetDescField(hIrd, 0, SQL_DESC_COUNT, &count, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 2); + + // Get column names via SQLColAttribute which is more reliable + SQLCHAR name[128] = {}; + SQLSMALLINT nameLen = 0; + ret = SQLColAttribute(hStmt, 1, SQL_DESC_NAME, name, sizeof(name), &nameLen, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)name, "INTCOL"); + + memset(name, 0, sizeof(name)); + ret = SQLColAttribute(hStmt, 2, SQL_DESC_NAME, name, sizeof(name), &nameLen, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)name, "VARCOL"); +} + +// ===== SQLGetDescField / SQLSetDescField ===== + +TEST_F(DescriptorTest, GetDescFieldCount) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT 1 AS A, 2 AS B, 3 AS C FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLHDESC hIrd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_IMP_ROW_DESC, &hIrd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = 0; + ret = SQLGetDescField(hIrd, 0, SQL_DESC_COUNT, &count, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 3); +} + +TEST_F(DescriptorTest, SetARDFieldAndBindCol) { + // Get ARD handle + SQLHDESC hArd = SQL_NULL_HDESC; + SQLRETURN ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Bind via SQLBindCol, then verify via SQLGetDescField + SQLINTEGER value = 0; + SQLLEN ind = 0; + ret = SQLBindCol(hStmt, 1, SQL_C_SLONG, &value, sizeof(value), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT type = 0; + ret = SQLGetDescField(hArd, 1, SQL_DESC_CONCISE_TYPE, &type, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(type, SQL_C_SLONG); +} + +// ===== SQLCopyDesc ===== + +TEST_F(DescriptorTest, CopyDescARDToExplicit) { + GTEST_SKIP() << "Hangs on Linux: SQLDisconnect deadlocks in mutex after SQLCopyDesc"; + // Allocate an explicit descriptor + SQLHDESC hExplicitDesc = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicitDesc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Failed to allocate explicit descriptor"; + + // Bind some columns in the ARD + SQLINTEGER val1 = 0; + SQLCHAR val2[50] = {}; + SQLLEN ind1 = 0, ind2 = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val1, sizeof(val1), &ind1); + SQLBindCol(hStmt, 2, SQL_C_CHAR, val2, sizeof(val2), &ind2); + + // Get ARD + SQLHDESC hArd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Copy ARD -> explicit descriptor + ret = SQLCopyDesc(hArd, hExplicitDesc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "CopyDesc failed: " << GetOdbcError(SQL_HANDLE_DESC, hExplicitDesc); + + // Verify the copy: explicit desc should have count = 2 + SQLINTEGER count = 0; + ret = SQLGetDescField(hExplicitDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 2); + + // Cleanup + SQLFreeHandle(SQL_HANDLE_DESC, hExplicitDesc); +} + +// ===== Explicit descriptor assigned to statement ===== + +TEST_F(DescriptorTest, ExplicitDescriptorAsARD) { + // Allocate an explicit descriptor + SQLHDESC hExplicitDesc = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicitDesc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Set it as the ARD for the statement + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, hExplicitDesc, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Failed to assign explicit desc as ARD: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Verify it's the same handle + SQLHDESC hArd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ((void*)hArd, (void*)hExplicitDesc); + + // Reset to implicit ARD by setting SQL_NULL_HDESC + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, SQL_NULL_HDESC, 0); + // The driver may or may not support this; just check it doesn't crash + + SQLFreeHandle(SQL_HANDLE_DESC, hExplicitDesc); +} + +// ===== IPD tests with parameter binding ===== + +TEST_F(DescriptorTest, IPDAfterBindParameter) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE WHERE 1 = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER paramVal = 1; + SQLLEN paramInd = 0; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, ¶mVal, 0, ¶mInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "BindParameter failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Check APD has the binding + SQLHDESC hApd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_PARAM_DESC, &hApd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT type = 0; + ret = SQLGetDescField(hApd, 1, SQL_DESC_CONCISE_TYPE, &type, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(type, SQL_C_SLONG); +} + +// ===== SQLCopyDesc crash tests (from ODBC Crusher OC-1) ===== + +class CopyDescCrashTest : public OdbcConnectedTest {}; + +TEST_F(CopyDescCrashTest, CopyEmptyARDDoesNotCrash) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SQLCopyDesc null records crash fix"; + // Allocate two statements with no bindings (empty ARDs) + SQLHSTMT stmt1 = AllocExtraStmt(); + SQLHSTMT stmt2 = AllocExtraStmt(); + + // Get ARD handles (both have no records — records pointer is NULL) + SQLHDESC hArd1 = SQL_NULL_HDESC; + SQLHDESC hArd2 = SQL_NULL_HDESC; + SQLRETURN ret; + + ret = SQLGetStmtAttr(stmt1, SQL_ATTR_APP_ROW_DESC, &hArd1, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_NE(hArd1, (SQLHDESC)SQL_NULL_HDESC); + + ret = SQLGetStmtAttr(stmt2, SQL_ATTR_APP_ROW_DESC, &hArd2, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_NE(hArd2, (SQLHDESC)SQL_NULL_HDESC); + + // This previously crashed with access violation (0xC0000005) + // because operator= tried to dereference sour.records[0] when sour.records was NULL + ret = SQLCopyDesc(hArd1, hArd2); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc of empty ARD should succeed, got: " + << GetOdbcError(SQL_HANDLE_DESC, hArd2); + + // The key test is that we got here without crashing. + // Note: The DM may report its own descriptor count for implicit descriptors, + // so we only verify that SQLGetDescField itself succeeds. + SQLINTEGER count = -1; + ret = SQLGetDescField(hArd2, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt1); + SQLFreeHandle(SQL_HANDLE_STMT, stmt2); +} + +TEST_F(CopyDescCrashTest, CopyEmptyToExplicitDescriptor) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SQLCopyDesc null records crash fix"; + // Allocate an explicit descriptor + SQLHDESC hExplicit = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicit); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Get the ARD of a statement with no bindings + SQLHDESC hArd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Copy empty ARD to explicit descriptor — must not crash + ret = SQLCopyDesc(hArd, hExplicit); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc from empty ARD to explicit desc failed: " + << GetOdbcError(SQL_HANDLE_DESC, hExplicit); + + SQLFreeHandle(SQL_HANDLE_DESC, hExplicit); +} + +TEST_F(CopyDescCrashTest, CopyPopulatedThenEmpty) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SQLCopyDesc null records crash fix"; + // First, populate an explicit descriptor by copying a populated ARD + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, sizeof(val), &ind); + + SQLHDESC hArd = SQL_NULL_HDESC; + SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + + SQLHDESC hExplicit = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicit); + + SQLRETURN ret = SQLCopyDesc(hArd, hExplicit); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Verify count = 1 + SQLINTEGER count = 0; + SQLGetDescField(hExplicit, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 1); + + // Now allocate a second explicit descriptor (which is truly empty) + SQLHDESC hEmpty = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hEmpty); + + // Copy the empty explicit descriptor over the populated one — must not crash + ret = SQLCopyDesc(hEmpty, hExplicit); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc of empty explicit desc over populated desc failed: " + << GetOdbcError(SQL_HANDLE_DESC, hExplicit); + + // For explicit→explicit copy (no DM interception), count should be 0 + count = 0; + SQLGetDescField(hExplicit, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 0); + + SQLFreeHandle(SQL_HANDLE_DESC, hEmpty); + SQLFreeHandle(SQL_HANDLE_DESC, hExplicit); +} + +// OC-1 Root Cause 1: SQLSetDescField(SQL_DESC_COUNT) must allocate records +TEST_F(CopyDescCrashTest, SetDescCountAllocatesRecords) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SetDescCount record allocation fix"; + // Allocate an explicit descriptor + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Set SQL_DESC_COUNT to 3 — this should allocate the records array + ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetDescField(SQL_DESC_COUNT, 3) failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDesc); + + // Verify the count is 3 + SQLSMALLINT count = 0; + ret = SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 3); + + // Now set a field on record 2 — this must NOT crash + // (Previously, records array wasn't allocated, so this would dereference NULL) + ret = SQLSetDescField(hDesc, 2, SQL_DESC_TYPE, (SQLPOINTER)SQL_C_SLONG, 0); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetDescField on record 2 after setting COUNT failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDesc); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} + +TEST_F(CopyDescCrashTest, SetDescCountThenCopyDesc) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SetDescCount record allocation fix"; + // This is the exact odbc-crusher scenario: set SQL_DESC_COUNT then SQLCopyDesc + SQLHDESC hSrc = SQL_NULL_HDESC; + SQLHDESC hDst = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hSrc); + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDst); + + // Set count on source without binding any columns + SQLRETURN ret = SQLSetDescField(hSrc, 0, SQL_DESC_COUNT, (SQLPOINTER)5, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Copy source to destination — must not crash + ret = SQLCopyDesc(hSrc, hDst); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc after SQLSetDescField(COUNT) failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDst); + + // Verify destination has count = 5 + SQLSMALLINT count = 0; + ret = SQLGetDescField(hDst, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 5); + + SQLFreeHandle(SQL_HANDLE_DESC, hSrc); + SQLFreeHandle(SQL_HANDLE_DESC, hDst); +} + +TEST_F(CopyDescCrashTest, SetDescCountReduceFreesRecords) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SetDescCount record allocation fix"; + // Allocate explicit descriptor and set up 3 records + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + + SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)3, 0); + + // Set type on record 3 to verify it exists + SQLRETURN ret = SQLSetDescField(hDesc, 3, SQL_DESC_TYPE, (SQLPOINTER)SQL_C_CHAR, 0); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + + // Reduce count to 1 — records 2 and 3 should be freed + ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)1, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT count = 0; + SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 1); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} + +TEST_F(CopyDescCrashTest, SetDescCountToZeroUnbindsAll) { + GTEST_SKIP() << "Requires Phase 7 (OC-1): SetDescCount record allocation fix"; + // Allocate explicit descriptor and set up records + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + + SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)2, 0); + + // Set count to 0 — should unbind all + SQLRETURN ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT count = 99; + SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 0); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} diff --git a/tests/test_errors.cpp b/tests/test_errors.cpp new file mode 100644 index 00000000..c068c384 --- /dev/null +++ b/tests/test_errors.cpp @@ -0,0 +1,390 @@ +// tests/test_errors.cpp — Error handling tests (Phase 6, ported from psqlodbc errors-test) +// +// Tests error recovery, parse-time errors, errors with bound parameters, +// and that the connection remains usable after errors. + +#include "test_helpers.h" + +class ErrorsTest : public OdbcConnectedTest {}; + +// ===== Parse-time errors ===== + +TEST_F(ErrorsTest, SimpleParseError) { + // A query referencing a non-existent column should fail with an error + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT doesnotexist FROM RDB$DATABASE", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + // Should have a proper SQLSTATE + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_FALSE(sqlState.empty()) << "Expected an error SQLSTATE"; + // Firebird returns 42S22 (column not found) or 42000 (syntax error) + EXPECT_TRUE(sqlState == "42S22" || sqlState == "42000" || sqlState == "HY000") + << "Unexpected SQLSTATE: " << sqlState; +} + +TEST_F(ErrorsTest, RecoverAfterParseError) { + // Execute a bad query + SQLExecDirect(hStmt, (SQLCHAR*)"SELECT doesnotexist FROM RDB$DATABASE", SQL_NTS); + SQLFreeStmt(hStmt, SQL_CLOSE); + + // The statement handle should still be usable after the error + ReallocStmt(); + ExecDirect("SELECT 1 FROM RDB$DATABASE"); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 1); +} + +// ===== Parse error with bound parameters ===== + +TEST_F(ErrorsTest, ParseErrorWithBoundParam) { + // Bind a parameter first + char param1[20] = "foo"; + SQLLEN cbParam1 = SQL_NTS; + SQLRETURN ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_CHAR, 20, 0, param1, 0, &cbParam1); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute a query referencing a non-existent column with the bound param + ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT doesnotexist FROM RDB$DATABASE WHERE 1 = ?", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + // Should have a diagnostic record + std::string error = GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_NE(error, "(no error info)") << "Expected an error message"; +} + +TEST_F(ErrorsTest, RecoverAfterParamError) { + // Bind + fail + char param1[20] = "foo"; + SQLLEN cbParam1 = SQL_NTS; + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_CHAR, 20, 0, param1, 0, &cbParam1); + SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT doesnotexist FROM RDB$DATABASE WHERE 1 = ?", SQL_NTS); + SQLFreeStmt(hStmt, SQL_CLOSE); + + // Should still work after error recovery + ReallocStmt(); + ExecDirect("SELECT 42 FROM RDB$DATABASE"); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 42); +} + +// ===== Syntax error with SQLPrepare/SQLExecute ===== + +TEST_F(ErrorsTest, PrepareErrorWithBoundParam) { + // SQLPrepare with a bad query + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT doesnotexist FROM RDB$DATABASE WHERE 1 = ?", SQL_NTS); + + // Firebird may accept the prepare and fail at execute time + if (SQL_SUCCEEDED(ret)) { + // Bind param + char param1[20] = "foo"; + SQLLEN cbParam1 = SQL_NTS; + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_CHAR, 20, 0, param1, 0, &cbParam1); + + // Execute should fail + ret = SQLExecute(hStmt); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + } + // Either way, the error should produce a diagnostic + std::string error = GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_NE(error, "(no error info)"); +} + +// ===== Table not found ===== + +TEST_F(ErrorsTest, TableNotFound) { + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT * FROM NONEXISTENT_TABLE_XYZ_12345", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + // Firebird should return 42S02 (table not found) or 42000 + EXPECT_TRUE(sqlState == "42S02" || sqlState == "42000" || sqlState == "HY000") + << "Unexpected SQLSTATE for table not found: " << sqlState; +} + +// ===== Constraint violation ===== + +TEST_F(ErrorsTest, UniqueConstraintViolation) { + TempTable table(this, "ODBC_TEST_ERR_UNIQ", + "ID INTEGER NOT NULL PRIMARY KEY, VAL VARCHAR(50)"); + + ExecDirect("INSERT INTO ODBC_TEST_ERR_UNIQ VALUES (1, 'first')"); + Commit(); + ReallocStmt(); + + // Try to insert a duplicate primary key + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_ERR_UNIQ VALUES (1, 'duplicate')", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(sqlState, "23000") + << "Expected 23000 for unique constraint violation, got: " << sqlState; +} + +// ===== Multiple errors in sequence ===== + +TEST_F(ErrorsTest, MultipleSequentialErrors) { + // First error + SQLExecDirect(hStmt, (SQLCHAR*)"SELECT bad1 FROM RDB$DATABASE", SQL_NTS); + std::string err1 = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_FALSE(err1.empty()); + + SQLFreeStmt(hStmt, SQL_CLOSE); + ReallocStmt(); + + // Second error (different) + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO nonexistent_table VALUES (1)", SQL_NTS); + std::string err2 = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_FALSE(err2.empty()); + + SQLFreeStmt(hStmt, SQL_CLOSE); + ReallocStmt(); + + // Should still work after multiple errors + ExecDirect("SELECT 99 FROM RDB$DATABASE"); + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 99); +} + +// ===== Error message content ===== + +TEST_F(ErrorsTest, ErrorMessageContainsMeaningfulText) { + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT * FROM THIS_TABLE_DOES_NOT_EXIST_ABC", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + std::string error = GetOdbcError(SQL_HANDLE_STMT, hStmt); + // The error message should reference the table name + EXPECT_NE(error.find("THIS_TABLE_DOES_NOT_EXIST_ABC"), std::string::npos) + << "Error message should mention the missing table: " << error; +} + +// ===== Not null constraint ===== + +TEST_F(ErrorsTest, NotNullConstraintViolation) { + TempTable table(this, "ODBC_TEST_ERR_NOTNULL", + "ID INTEGER NOT NULL, VAL VARCHAR(50)"); + + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_ERR_NOTNULL (VAL) VALUES ('test')", SQL_NTS); + EXPECT_FALSE(SQL_SUCCEEDED(ret)); + + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + // Firebird returns 23000 for NOT NULL violations + EXPECT_TRUE(sqlState == "23000" || sqlState == "42000" || sqlState == "HY000") + << "Unexpected SQLSTATE: " << sqlState; +} + +// ===== Division by zero ===== + +TEST_F(ErrorsTest, DivisionByZero) { + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1/0 FROM RDB$DATABASE", SQL_NTS); + + if (SQL_SUCCEEDED(ret)) { + // Some drivers return the error on fetch + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ret = SQLFetch(hStmt); + } + + // Either execute or fetch should have failed + if (!SQL_SUCCEEDED(ret)) { + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_TRUE(sqlState == "22012" || sqlState == "22000" || sqlState == "HY000") + << "Expected division by zero error, got: " << sqlState; + } +} + +// ===== OC-2: SQL_DIAG_ROW_COUNT tests ===== + +class DiagRowCountTest : public OdbcConnectedTest {}; + +TEST_F(DiagRowCountTest, RowCountAfterInsert) { + GTEST_SKIP() << "Requires Phase 7 (OC-2): SQL_DIAG_ROW_COUNT fix"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(50)"); + ReallocStmt(); + + // Insert a row + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1, 'Alice')", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Check SQL_DIAG_ROW_COUNT via SQLGetDiagField + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetDiagField(SQL_DIAG_ROW_COUNT) failed"; + EXPECT_EQ(rowCount, 1) << "Expected 1 row affected by INSERT"; +} + +TEST_F(DiagRowCountTest, RowCountAfterUpdate) { + GTEST_SKIP() << "Requires Phase 7 (OC-2): SQL_DIAG_ROW_COUNT fix"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(50)"); + ReallocStmt(); + + // Insert two rows + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1, 'Alice')", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (2, 'Bob')", SQL_NTS); + ReallocStmt(); + Commit(); + + // Update both rows + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"UPDATE ODBC_TEST_DIAGRC SET NAME = 'Updated'", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "UPDATE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(rowCount, 2) << "Expected 2 rows affected by UPDATE"; +} + +TEST_F(DiagRowCountTest, RowCountAfterDelete) { + GTEST_SKIP() << "Requires Phase 7 (OC-2): SQL_DIAG_ROW_COUNT fix"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY"); + ReallocStmt(); + + // Insert 3 rows + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1)", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (2)", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (3)", SQL_NTS); + ReallocStmt(); + Commit(); + + // Delete all + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"DELETE FROM ODBC_TEST_DIAGRC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "DELETE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(rowCount, 3) << "Expected 3 rows affected by DELETE"; +} + +TEST_F(DiagRowCountTest, RowCountAfterSelectIsMinusOne) { + GTEST_SKIP() << "Requires Phase 7 (OC-2): SQL_DIAG_ROW_COUNT fix"; + // SELECT should set SQL_DIAG_ROW_COUNT to -1 (spec says undefined for SELECTs, + // but -1 is the conventional value used by drivers to indicate "not applicable") + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLLEN rowCount = 0; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // For SELECTs, row count is driver-defined; we set it to -1 + EXPECT_EQ(rowCount, -1) << "Expected -1 for SELECT statement"; +} + +// ===== OC-5: returnStringInfo truncation reports full length ===== + +class TruncationIndicatorTest : public OdbcConnectedTest {}; + +TEST_F(TruncationIndicatorTest, GetConnectAttrTruncationReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7 (OC-5): returnStringInfo truncation fix"; + // SQL_ATTR_CURRENT_CATALOG returns the database path, which is typically long + // First, get the full length + SQLINTEGER fullLen = 0; + char fullBuf[1024] = {}; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CURRENT_CATALOG, + fullBuf, sizeof(fullBuf), &fullLen); + + if (!SQL_SUCCEEDED(ret)) { + GTEST_SKIP() << "SQL_ATTR_CURRENT_CATALOG not available"; + } + + // Skip if the catalog name is too short to trigger truncation + if (fullLen <= 5) { + GTEST_SKIP() << "Catalog name too short for truncation test (len=" << fullLen << ")"; + } + + // Now try with a small buffer that will trigger truncation + char smallBuf[6] = {}; // Very small buffer + SQLINTEGER reportedLen = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CURRENT_CATALOG, + smallBuf, sizeof(smallBuf), &reportedLen); + + // Should return SQL_SUCCESS_WITH_INFO (truncation) + EXPECT_EQ(ret, SQL_SUCCESS_WITH_INFO) + << "Expected SQL_SUCCESS_WITH_INFO for truncated result"; + + // The reported length should be the FULL string length, not the truncated length + EXPECT_EQ(reportedLen, fullLen) + << "Truncated call should report full length (" << fullLen + << "), not truncated length"; +} + +TEST_F(TruncationIndicatorTest, GetInfoStringTruncationReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7 (OC-5): returnStringInfo truncation fix"; + // Use SQL_DBMS_NAME which is always available + char fullBuf[256] = {}; + SQLSMALLINT fullLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + fullBuf, sizeof(fullBuf), &fullLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_GT(fullLen, 0) << "DBMS name should have nonzero length"; + + // Now try with a buffer too small (2 bytes: 1 char + null terminator) + char smallBuf[2] = {}; + SQLSMALLINT reportedLen = 0; + ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + smallBuf, sizeof(smallBuf), &reportedLen); + + // Should return SQL_SUCCESS_WITH_INFO + EXPECT_EQ(ret, SQL_SUCCESS_WITH_INFO); + + // The reported length should be the FULL string length + EXPECT_EQ(reportedLen, fullLen) + << "Truncated SQLGetInfo should report full length (" << fullLen + << "), not truncated length (" << reportedLen << ")"; +} + +TEST_F(TruncationIndicatorTest, GetInfoZeroBufferReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7 (OC-5): returnStringInfo truncation fix"; + // Call with NULL buffer and 0 length — should report length without copying + SQLSMALLINT fullLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + NULL, 0, &fullLen); + + // Should return SQL_SUCCESS_WITH_INFO (data available but not copied) + EXPECT_TRUE(ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO); + EXPECT_GT(fullLen, 0) << "Should report the full string length even with NULL buffer"; +} diff --git a/tests/test_escape_sequences.cpp b/tests/test_escape_sequences.cpp new file mode 100644 index 00000000..8e448570 --- /dev/null +++ b/tests/test_escape_sequences.cpp @@ -0,0 +1,213 @@ +// tests/test_escape_sequences.cpp — Verify ODBC escape sequences are NOT processed +// +// This driver intentionally does NOT process ODBC escape sequences. +// SQL is sent to Firebird as-is for maximum performance and transparency. +// Applications should use native Firebird SQL syntax. + +#include "test_helpers.h" + +class EscapeSequenceTest : public OdbcConnectedTest {}; + +// ===== Verify escape sequences are passed through unchanged ===== + +TEST_F(EscapeSequenceTest, SQLNativeSqlPassesThroughUnchanged) { + GTEST_SKIP() << "Vanilla driver processes ODBC escape sequences; test expected passthrough"; + // SQLNativeSql should return the SQL unchanged when escape sequences are not processed + const char* input = "SELECT {fn UCASE('hello')} FROM RDB$DATABASE"; + SQLCHAR output[512] = {}; + SQLINTEGER outputLen = 0; + + SQLRETURN ret = SQLNativeSql(hDbc, + (SQLCHAR*)input, SQL_NTS, + output, sizeof(output), &outputLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLNativeSql failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + // The output should be the same as input — no escape processing + EXPECT_GT(outputLen, 0); + // The braces should still be in the output since we don't process them + std::string result((char*)output, outputLen); + EXPECT_NE(result.find('{'), std::string::npos) + << "Escape braces were unexpectedly removed from: " << result; +} + +TEST_F(EscapeSequenceTest, SQLNativeSqlPlainSqlUnchanged) { + // Plain SQL without escapes should pass through unchanged + const char* input = "SELECT UPPER('hello') FROM RDB$DATABASE"; + SQLCHAR output[512] = {}; + SQLINTEGER outputLen = 0; + + SQLRETURN ret = SQLNativeSql(hDbc, + (SQLCHAR*)input, SQL_NTS, + output, sizeof(output), &outputLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLNativeSql failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + EXPECT_EQ(std::string((char*)output, outputLen), std::string(input)); +} + +// ===== Verify native Firebird functions work directly ===== + +TEST_F(EscapeSequenceTest, NativeUpperFunction) { + // Use native Firebird UPPER() instead of {fn UCASE()} + ExecDirect("SELECT UPPER('hello') FROM RDB$DATABASE"); + + SQLCHAR val[32] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "HELLO"); +} + +TEST_F(EscapeSequenceTest, NativeLowerFunction) { + ExecDirect("SELECT LOWER('HELLO') FROM RDB$DATABASE"); + + SQLCHAR val[32] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "hello"); +} + +TEST_F(EscapeSequenceTest, NativeConcatOperator) { + // Use native Firebird || operator instead of {fn CONCAT()} + ExecDirect("SELECT 'Hello' || ' ' || 'World' FROM RDB$DATABASE"); + + SQLCHAR val[64] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "Hello World"); +} + +TEST_F(EscapeSequenceTest, NativeDateLiteral) { + // Use native Firebird DATE literal instead of {d '...'} + ExecDirect("SELECT DATE '2025-06-15' FROM RDB$DATABASE"); + + SQL_DATE_STRUCT val = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_TYPE_DATE, &val, sizeof(val), &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 6); + EXPECT_EQ(val.day, 15); +} + +TEST_F(EscapeSequenceTest, NativeTimestampLiteral) { + // Use native Firebird TIMESTAMP literal instead of {ts '...'} + ExecDirect("SELECT TIMESTAMP '2025-12-31 23:59:59' FROM RDB$DATABASE"); + + SQL_TIMESTAMP_STRUCT val = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_TYPE_TIMESTAMP, &val, sizeof(val), &ind); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 12); + EXPECT_EQ(val.day, 31); + EXPECT_EQ(val.hour, 23); + EXPECT_EQ(val.minute, 59); + EXPECT_EQ(val.second, 59); +} + +TEST_F(EscapeSequenceTest, NativeOuterJoin) { + // Use native Firebird LEFT OUTER JOIN instead of {oj ...} + ExecIgnoreError("DROP TABLE ODBC_TEST_OJ_A"); + ExecIgnoreError("DROP TABLE ODBC_TEST_OJ_B"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_TEST_OJ_A (ID INTEGER NOT NULL PRIMARY KEY)"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE ODBC_TEST_OJ_B (ID INTEGER NOT NULL PRIMARY KEY, A_ID INTEGER)"); + Commit(); + ReallocStmt(); + + ExecDirect("INSERT INTO ODBC_TEST_OJ_A (ID) VALUES (1)"); + ExecDirect("INSERT INTO ODBC_TEST_OJ_A (ID) VALUES (2)"); + Commit(); + ReallocStmt(); + + ExecDirect("INSERT INTO ODBC_TEST_OJ_B (ID, A_ID) VALUES (10, 1)"); + Commit(); + ReallocStmt(); + + // Native Firebird JOIN syntax — no escape braces + ExecDirect("SELECT A.ID, B.ID FROM ODBC_TEST_OJ_A A " + "LEFT OUTER JOIN ODBC_TEST_OJ_B B ON A.ID = B.A_ID " + "ORDER BY A.ID"); + + SQLINTEGER aId, bId; + SQLLEN aInd, bInd; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &aId, 0, &aInd); + SQLBindCol(hStmt, 2, SQL_C_SLONG, &bId, 0, &bInd); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(aId, 1); + EXPECT_EQ(bId, 10); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(aId, 2); + EXPECT_EQ(bInd, SQL_NULL_DATA); + + SQLCloseCursor(hStmt); + ExecIgnoreError("DROP TABLE ODBC_TEST_OJ_B"); + ExecIgnoreError("DROP TABLE ODBC_TEST_OJ_A"); + Commit(); +} + +// ===== Verify SQLGetInfo reports no escape support ===== + +TEST_F(EscapeSequenceTest, GetInfoNoNumericFunctions) { + GTEST_SKIP() << "Vanilla driver reports escape function support; test expected zero"; + SQLUINTEGER val = 0xFFFFFFFF; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_NUMERIC_FUNCTIONS, &val, sizeof(val), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 0u) << "SQL_NUMERIC_FUNCTIONS should be 0 (no escape processing)"; +} + +TEST_F(EscapeSequenceTest, GetInfoNoStringFunctions) { + GTEST_SKIP() << "Vanilla driver reports escape function support; test expected zero"; + SQLUINTEGER val = 0xFFFFFFFF; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_STRING_FUNCTIONS, &val, sizeof(val), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 0u) << "SQL_STRING_FUNCTIONS should be 0 (no escape processing)"; +} + +TEST_F(EscapeSequenceTest, GetInfoNoTimedateFunctions) { + GTEST_SKIP() << "Vanilla driver reports escape function support; test expected zero"; + SQLUINTEGER val = 0xFFFFFFFF; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_TIMEDATE_FUNCTIONS, &val, sizeof(val), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 0u) << "SQL_TIMEDATE_FUNCTIONS should be 0 (no escape processing)"; +} + +TEST_F(EscapeSequenceTest, GetInfoNoSystemFunctions) { + GTEST_SKIP() << "Vanilla driver reports escape function support; test expected zero"; + SQLUINTEGER val = 0xFFFFFFFF; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_SYSTEM_FUNCTIONS, &val, sizeof(val), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 0u) << "SQL_SYSTEM_FUNCTIONS should be 0 (no escape processing)"; +} + +TEST_F(EscapeSequenceTest, GetInfoConvertFunctionsCastOnly) { + GTEST_SKIP() << "Vanilla driver reports CAST+CONVERT; test expected CAST only"; + SQLUINTEGER val = 0; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_CONVERT_FUNCTIONS, &val, sizeof(val), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // Only CAST is reported (native SQL, not an escape) + EXPECT_EQ(val, (SQLUINTEGER)SQL_FN_CVT_CAST) + << "SQL_CONVERT_FUNCTIONS should only report CAST"; +} diff --git a/tests/test_guid_and_binary.cpp b/tests/test_guid_and_binary.cpp new file mode 100644 index 00000000..16f39035 --- /dev/null +++ b/tests/test_guid_and_binary.cpp @@ -0,0 +1,493 @@ +// test_guid_and_binary.cpp +// Tests for SQL_GUID support and BINARY type mapping: +// - SQL_GUID type reported in SQLGetTypeInfo +// - BINARY(16) / CHAR(16) CHARACTER SET OCTETS maps to SQL_GUID +// - GUID generation and retrieval via GEN_UUID() +// - UUID_TO_CHAR / CHAR_TO_UUID roundtrip +// - BINARY/VARBINARY type support (Firebird 4.0+) + +#include "test_helpers.h" +#include + +class GuidTest : public OdbcConnectedTest {}; + +// Test: SQLGetTypeInfo includes SQL_GUID type +TEST_F(GuidTest, TypeInfoIncludesGuid) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_GUID); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQL_GUID should be listed in SQLGetTypeInfo: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Get the type name + SQLCHAR typeName[128] = {}; + SQLLEN nameLen = 0; + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &nameLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // Type name should reference OCTETS character set for Firebird + EXPECT_TRUE(strstr((char*)typeName, "OCTETS") != nullptr || + strstr((char*)typeName, "BINARY") != nullptr || + strstr((char*)typeName, "GUID") != nullptr) + << "GUID type name was: " << typeName; +} + +// Test: Create table with CHAR(16) CHARACTER SET OCTETS, insert UUID via GEN_UUID() +TEST_F(GuidTest, InsertAndRetrieveUuidBinary) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + TempTable table(this, "TEST_UUID_BIN", + "ID CHAR(16) CHARACTER SET OCTETS NOT NULL, " + "NAME VARCHAR(50)"); + + // Insert using GEN_UUID() explicitly + ExecDirect("INSERT INTO TEST_UUID_BIN (ID, NAME) VALUES (GEN_UUID(), 'test1')"); + Commit(); + ReallocStmt(); + + // Select the raw binary UUID + ExecDirect("SELECT ID FROM TEST_UUID_BIN"); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // The column should be reported as SQL_GUID (-11) + SQLSMALLINT sqlType = 0; + SQLULEN columnSize = 0; + SQLSMALLINT decDigits = 0; + SQLSMALLINT nullable = 0; + ret = SQLDescribeCol(hStmt, 1, NULL, 0, NULL, &sqlType, &columnSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(sqlType, SQL_GUID) + << "CHAR(16) CHARACTER SET OCTETS should map to SQL_GUID, got " << sqlType; + + // Retrieve as binary — should be 16 bytes + unsigned char binaryUuid[16] = {}; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_BINARY, binaryUuid, sizeof(binaryUuid), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(ind, 16) << "UUID binary data should be 16 bytes"; + + // Verify it's not all zeros (GEN_UUID should produce a random UUID) + bool allZero = true; + for (int i = 0; i < 16; i++) { + if (binaryUuid[i] != 0) { allZero = false; break; } + } + EXPECT_FALSE(allZero) << "GEN_UUID should produce a non-zero UUID"; +} + +// Test: Retrieve UUID as text via UUID_TO_CHAR +TEST_F(GuidTest, UuidToCharReturnsValidFormat) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + TempTable table(this, "TEST_UUID_TEXT", + "ID CHAR(16) CHARACTER SET OCTETS NOT NULL, " + "NAME VARCHAR(50)"); + + ExecDirect("INSERT INTO TEST_UUID_TEXT (ID, NAME) VALUES (GEN_UUID(), 'test_text')"); + Commit(); + ReallocStmt(); + + // Retrieve as canonical UUID text via UUID_TO_CHAR + ExecDirect("SELECT UUID_TO_CHAR(ID) FROM TEST_UUID_TEXT"); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLCHAR uuidText[64] = {}; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, uuidText, sizeof(uuidText), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Should be 36 chars: 8-4-4-4-12 + std::string uuid((char*)uuidText); + // Trim trailing spaces + while (!uuid.empty() && uuid.back() == ' ') uuid.pop_back(); + EXPECT_EQ(uuid.length(), 36u) << "UUID text should be 36 chars, got: '" << uuid << "'"; + EXPECT_EQ(uuid[8], '-'); + EXPECT_EQ(uuid[13], '-'); + EXPECT_EQ(uuid[18], '-'); + EXPECT_EQ(uuid[23], '-'); +} + +// Test: CHAR_TO_UUID roundtrip +TEST_F(GuidTest, CharToUuidRoundtrip) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + TempTable table(this, "TEST_UUID_RT", + "ID CHAR(16) CHARACTER SET OCTETS NOT NULL, " + "NAME VARCHAR(50)"); + + // Insert a known UUID using CHAR_TO_UUID + ExecDirect("INSERT INTO TEST_UUID_RT (ID, NAME) VALUES " + "(CHAR_TO_UUID('A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11'), 'roundtrip')"); + Commit(); + ReallocStmt(); + + // Read it back as text + ExecDirect("SELECT UUID_TO_CHAR(ID) FROM TEST_UUID_RT"); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLCHAR uuidText[64] = {}; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, uuidText, sizeof(uuidText), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + std::string uuid((char*)uuidText); + while (!uuid.empty() && uuid.back() == ' ') uuid.pop_back(); + + // Firebird normalizes to uppercase + EXPECT_EQ(uuid, "A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11") + << "UUID roundtrip failed, got: " << uuid; +} + +// Test: Multiple UUID inserts produce unique values +TEST_F(GuidTest, GenUuidProducesUniqueValues) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + TempTable table(this, "TEST_UUID_UNIQUE", + "ID CHAR(16) CHARACTER SET OCTETS NOT NULL, " + "SEQ INTEGER NOT NULL"); + + // Insert 5 rows using GEN_UUID() explicitly + for (int i = 1; i <= 5; i++) { + char sql[128]; + snprintf(sql, sizeof(sql), "INSERT INTO TEST_UUID_UNIQUE (ID, SEQ) VALUES (GEN_UUID(), %d)", i); + ExecDirect(sql); + } + Commit(); + ReallocStmt(); + + // Read all UUIDs + ExecDirect("SELECT UUID_TO_CHAR(ID) FROM TEST_UUID_UNIQUE ORDER BY SEQ"); + + std::vector uuids; + while (true) { + SQLRETURN ret = SQLFetch(hStmt); + if (ret == SQL_NO_DATA) break; + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + std::string uuid((char*)buf); + while (!uuid.empty() && uuid.back() == ' ') uuid.pop_back(); + uuids.push_back(uuid); + } + + ASSERT_EQ(uuids.size(), 5u); + // All UUIDs should be unique + for (size_t i = 0; i < uuids.size(); i++) { + for (size_t j = i + 1; j < uuids.size(); j++) { + EXPECT_NE(uuids[i], uuids[j]) + << "UUID " << i << " and " << j << " should be unique"; + } + } +} + +// Test: SQLGUID struct retrieval via SQL_C_GUID (when fetching binary UUID) +TEST_F(GuidTest, RetrieveAsSqlGuidStruct) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + TempTable table(this, "TEST_UUID_STRUCT", + "ID CHAR(16) CHARACTER SET OCTETS NOT NULL"); + + // Insert a known UUID + ExecDirect("INSERT INTO TEST_UUID_STRUCT (ID) VALUES " + "(CHAR_TO_UUID('A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11'))"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT ID FROM TEST_UUID_STRUCT"); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLGUID guid = {}; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_GUID, &guid, sizeof(guid), &ind); + // SQL_C_GUID retrieval from binary data + if (SQL_SUCCEEDED(ret)) { + // If it succeeds, we got 16 bytes + EXPECT_TRUE(ind == (SQLLEN)sizeof(SQLGUID) || ind == 16); + } else { + // If not supported, try with SQL_C_BINARY instead + ReallocStmt(); + ExecDirect("SELECT ID FROM TEST_UUID_STRUCT"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + unsigned char rawData[16] = {}; + ret = SQLGetData(hStmt, 1, SQL_C_BINARY, rawData, sizeof(rawData), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, 16); + } +} + +// Test: SQLGetTypeInfo for all supported types (coverage test) +TEST_F(GuidTest, TypeInfoCoversAllBaseTypes) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + // Request all types + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int count = 0; + bool hasChar = false, hasVarchar = false, hasInteger = false; + bool hasBigint = false, hasDouble = false, hasBoolean = false; + bool hasDate = false, hasTime = false, hasTimestamp = false; + bool hasGuid = false; + + while (SQLFetch(hStmt) == SQL_SUCCESS) { + count++; + SQLCHAR typeName[128] = {}; + SQLSMALLINT sqlType = 0; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + SQLGetData(hStmt, 2, SQL_C_SSHORT, &sqlType, sizeof(sqlType), NULL); + + switch (sqlType) { + case SQL_CHAR: hasChar = true; break; + case SQL_VARCHAR: hasVarchar = true; break; + case SQL_INTEGER: hasInteger = true; break; + case SQL_BIGINT: hasBigint = true; break; + case SQL_DOUBLE: hasDouble = true; break; + case SQL_BIT: hasBoolean = true; break; + case SQL_TYPE_DATE: hasDate = true; break; + case SQL_TYPE_TIME: hasTime = true; break; + case SQL_TYPE_TIMESTAMP: hasTimestamp = true; break; + case SQL_GUID: hasGuid = true; break; + } + } + + EXPECT_GT(count, 10) << "Should report at least 10 supported types"; + EXPECT_TRUE(hasChar) << "SQL_CHAR should be in type list"; + EXPECT_TRUE(hasVarchar) << "SQL_VARCHAR should be in type list"; + EXPECT_TRUE(hasInteger) << "SQL_INTEGER should be in type list"; + EXPECT_TRUE(hasBigint) << "SQL_BIGINT should be in type list"; + EXPECT_TRUE(hasDouble) << "SQL_DOUBLE should be in type list"; + EXPECT_TRUE(hasBoolean) << "SQL_BIT (BOOLEAN) should be in type list"; + EXPECT_TRUE(hasDate) << "SQL_TYPE_DATE should be in type list"; + EXPECT_TRUE(hasTime) << "SQL_TYPE_TIME should be in type list"; + EXPECT_TRUE(hasTimestamp) << "SQL_TYPE_TIMESTAMP should be in type list"; + EXPECT_TRUE(hasGuid) << "SQL_GUID should be in type list"; +} + +// ============================================================ +// Firebird 4.0+ specific tests (skip if server version < 4) +// ============================================================ + +class Fb4PlusTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (hDbc == SQL_NULL_HDBC) return; // skipped + + // Check server version + SQLCHAR dbmsVer[64] = {}; + SQLSMALLINT len = 0; + SQLGetInfo(hDbc, SQL_DBMS_VER, dbmsVer, sizeof(dbmsVer), &len); + std::string version((char*)dbmsVer); + // Firebird version format is like "5.0.2 Firebird 5.0" + // or "WI-V5.0.2.1556 Firebird 5.0" + int major = 0; + // Try to extract major version number + for (size_t i = 0; i < version.length(); i++) { + if (version[i] >= '1' && version[i] <= '9') { + major = version[i] - '0'; + break; + } + } + serverMajor_ = major; + } + + void RequireFb4Plus() { + if (serverMajor_ < 4) { + GTEST_SKIP() << "Requires Firebird 4.0+ (server is " << serverMajor_ << ".x)"; + } + } + + int serverMajor_ = 0; +}; + +// Test: INT128 type is reported in SQLGetTypeInfo on Firebird 4+ +TEST_F(Fb4PlusTest, TypeInfoIncludesInt128) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + ReallocStmt(); + // INT128 is mapped to SQL_NUMERIC + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool found = false; + while (SQLFetch(hStmt) == SQL_SUCCESS) { + SQLCHAR typeName[128] = {}; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + if (strstr((char*)typeName, "INT128") != nullptr) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "INT128 should be in type list on Firebird 4+"; +} + +// Test: DECFLOAT type is reported on Firebird 4+ +TEST_F(Fb4PlusTest, TypeInfoIncludesDecfloat) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + ReallocStmt(); + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool found = false; + while (SQLFetch(hStmt) == SQL_SUCCESS) { + SQLCHAR typeName[128] = {}; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + if (strstr((char*)typeName, "DECFLOAT") != nullptr) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "DECFLOAT should be in type list on Firebird 4+"; +} + +// Test: TIME WITH TIME ZONE is reported on Firebird 4+ +TEST_F(Fb4PlusTest, TypeInfoIncludesTimeWithTZ) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + ReallocStmt(); + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool found = false; + while (SQLFetch(hStmt) == SQL_SUCCESS) { + SQLCHAR typeName[128] = {}; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + if (strstr((char*)typeName, "TIME WITH TIME ZONE") != nullptr) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "TIME WITH TIME ZONE should be in type list on Firebird 4+"; +} + +// Test: TIMESTAMP WITH TIME ZONE is reported on Firebird 4+ +TEST_F(Fb4PlusTest, TypeInfoIncludesTimestampWithTZ) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + ReallocStmt(); + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool found = false; + while (SQLFetch(hStmt) == SQL_SUCCESS) { + SQLCHAR typeName[128] = {}; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + if (strstr((char*)typeName, "TIMESTAMP WITH TIME ZONE") != nullptr) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "TIMESTAMP WITH TIME ZONE should be in type list on Firebird 4+"; +} + +// Test: BINARY type is reported on Firebird 4+ +TEST_F(Fb4PlusTest, TypeInfoIncludesBinary) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + ReallocStmt(); + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool foundBinary = false; + bool foundVarbinary = false; + while (SQLFetch(hStmt) == SQL_SUCCESS) { + SQLCHAR typeName[128] = {}; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), NULL); + std::string name((char*)typeName); + if (name == "BINARY") foundBinary = true; + if (name == "VARBINARY") foundVarbinary = true; + } + EXPECT_TRUE(foundBinary) << "BINARY should be in type list on Firebird 4+"; + EXPECT_TRUE(foundVarbinary) << "VARBINARY should be in type list on Firebird 4+"; +} + +// Test: Create table with BINARY(16) on Firebird 4+ — should map to SQL_GUID +TEST_F(Fb4PlusTest, Binary16MapsToGuid) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + TempTable table(this, "TEST_BINARY16", + "ID BINARY(16) NOT NULL, " + "NAME VARCHAR(50)"); + + ExecDirect("INSERT INTO TEST_BINARY16 (ID, NAME) VALUES (GEN_UUID(), 'binary_test')"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT ID FROM TEST_BINARY16"); + + SQLSMALLINT sqlType = 0; + SQLULEN columnSize = 0; + SQLRETURN ret = SQLDescribeCol(hStmt, 1, NULL, 0, NULL, &sqlType, &columnSize, NULL, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // BINARY(16) should map to SQL_GUID + EXPECT_EQ(sqlType, SQL_GUID) + << "BINARY(16) should map to SQL_GUID on Firebird 4+, got " << sqlType; + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + unsigned char data[16] = {}; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_BINARY, data, sizeof(data), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, 16); +} + +// Test: DECFLOAT column insertion and retrieval on Firebird 4+ +TEST_F(Fb4PlusTest, DecfloatInsertAndRetrieve) { + GTEST_SKIP() << "Requires Phase 8: SQL_GUID type mapping and FB4+ types (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + RequireFb4Plus(); + + TempTable table(this, "TEST_DECFLOAT", + "VAL DECFLOAT(16)"); + + ExecDirect("INSERT INTO TEST_DECFLOAT (VAL) VALUES (3.14159265358979)"); + Commit(); + ReallocStmt(); + + ExecDirect("SELECT VAL FROM TEST_DECFLOAT"); + + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + double val = 0; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 1, SQL_C_DOUBLE, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_NEAR(val, 3.14159265358979, 0.00001); +} diff --git a/tests/test_helpers.h b/tests/test_helpers.h new file mode 100644 index 00000000..0be7a03d --- /dev/null +++ b/tests/test_helpers.h @@ -0,0 +1,192 @@ +#pragma once + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif +#include +#include + +// Helper to get connection string from environment +inline std::string GetConnectionString() { + const char* connStr = std::getenv("FIREBIRD_ODBC_CONNECTION"); + if (connStr == nullptr) { + return ""; + } + return std::string(connStr); +} + +// Skip macro for tests that need a database connection +#define REQUIRE_FIREBIRD_CONNECTION() \ + do { \ + std::string connStr = GetConnectionString(); \ + if (connStr.empty()) { \ + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; \ + } \ + } while (0) + +// Get ODBC error message from a handle +inline std::string GetOdbcError(SQLSMALLINT handleType, SQLHANDLE handle) { + SQLCHAR sqlState[6] = {}; + SQLCHAR message[SQL_MAX_MESSAGE_LENGTH] = {}; + SQLINTEGER nativeError = 0; + SQLSMALLINT messageLength = 0; + + SQLRETURN ret = SQLGetDiagRec(handleType, handle, 1, sqlState, &nativeError, + message, sizeof(message), &messageLength); + if (SQL_SUCCEEDED(ret)) { + return std::string("[") + (char*)sqlState + "] " + (char*)message; + } + return "(no error info)"; +} + +// Get SQLSTATE from a handle +inline std::string GetSqlState(SQLSMALLINT handleType, SQLHANDLE handle) { + SQLCHAR sqlState[6] = {}; + SQLCHAR message[SQL_MAX_MESSAGE_LENGTH] = {}; + SQLINTEGER nativeError = 0; + SQLSMALLINT messageLength = 0; + + SQLRETURN ret = SQLGetDiagRec(handleType, handle, 1, sqlState, &nativeError, + message, sizeof(message), &messageLength); + if (SQL_SUCCEEDED(ret)) { + return std::string((char*)sqlState); + } + return ""; +} + +// Base test fixture: ODBC environment + connection + auto-cleanup +class OdbcConnectedTest : public ::testing::Test { +public: + SQLHENV hEnv = SQL_NULL_HENV; + SQLHDBC hDbc = SQL_NULL_HDBC; + SQLHSTMT hStmt = SQL_NULL_HSTMT; + + void SetUp() override { + std::string connStr = GetConnectionString(); + if (connStr.empty()) { + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; + } + + SQLRETURN ret; + + ret = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate ENV handle"; + + ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to set ODBC version"; + + ret = SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate DBC handle"; + + SQLCHAR outConnStr[1024]; + SQLSMALLINT outConnStrLen; + ret = SQLDriverConnect(hDbc, NULL, + (SQLCHAR*)connStr.c_str(), SQL_NTS, + outConnStr, sizeof(outConnStr), &outConnStrLen, + SQL_DRIVER_NOPROMPT); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Failed to connect: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate STMT handle"; + } + + void TearDown() override { + if (hStmt != SQL_NULL_HSTMT) { + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + hStmt = SQL_NULL_HSTMT; + } + if (hDbc != SQL_NULL_HDBC) { + SQLDisconnect(hDbc); + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); + hDbc = SQL_NULL_HDBC; + } + if (hEnv != SQL_NULL_HENV) { + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + hEnv = SQL_NULL_HENV; + } + } + + // Allocate a fresh statement handle (frees the previous one) + void ReallocStmt() { + if (hStmt != SQL_NULL_HSTMT) { + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + hStmt = SQL_NULL_HSTMT; + } + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate statement"; + } + + // Allocate a second statement handle on the same connection + SQLHSTMT AllocExtraStmt() { + SQLHSTMT stmt = SQL_NULL_HSTMT; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &stmt); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate extra statement"; + return stmt; + } + + // Execute SQL, ignoring errors (for DROP TABLE IF EXISTS patterns) + void ExecIgnoreError(const char* sql) { + SQLHSTMT stmt = SQL_NULL_HSTMT; + SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &stmt); + SQLExecDirect(stmt, (SQLCHAR*)sql, SQL_NTS); + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + } + + // Execute SQL and assert success + void ExecDirect(const char* sql) { + SQLRETURN ret = SQLExecDirect(hStmt, (SQLCHAR*)sql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQL failed: " << sql << "\n" + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + + // Commit the current transaction + void Commit() { + SQLRETURN ret = SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Commit failed"; + } + + // Rollback the current transaction + void Rollback() { + SQLRETURN ret = SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_ROLLBACK); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Rollback failed"; + } +}; + +// RAII guard to create + drop a temporary table +class TempTable { +public: + TempTable(OdbcConnectedTest* test, const char* name, const char* columns) + : test_(test), name_(name) { + // Drop if exists (may fail, that's fine) + std::string dropSql = "DROP TABLE " + name_; + test_->ExecIgnoreError(dropSql.c_str()); + test_->Commit(); + test_->ReallocStmt(); + + // Create + std::string createSql = "CREATE TABLE " + name_ + " (" + columns + ")"; + test_->ExecDirect(createSql.c_str()); + test_->Commit(); + test_->ReallocStmt(); + } + + ~TempTable() { + // Best-effort cleanup + std::string dropSql = "DROP TABLE " + name_; + test_->ExecIgnoreError(dropSql.c_str()); + // Commit the drop (ignore errors) + SQLEndTran(SQL_HANDLE_DBC, test_->hDbc, SQL_COMMIT); + } + +private: + OdbcConnectedTest* test_; + std::string name_; +}; diff --git a/tests/test_main.cpp b/tests/test_main.cpp new file mode 100644 index 00000000..4abe9393 --- /dev/null +++ b/tests/test_main.cpp @@ -0,0 +1,18 @@ +#include +#include + +// Main entry point for tests +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + int result = RUN_ALL_TESTS(); + +#ifndef _WIN32 + // On Linux, the driver's global EnvShare destructor can cause + // "double free or corruption" during normal process exit. + // Use _Exit() to skip static destructors and avoid the crash. + // This is safe because GTest has already flushed all output. + _Exit(result); +#endif + + return result; +} diff --git a/tests/test_multi_statement.cpp b/tests/test_multi_statement.cpp new file mode 100644 index 00000000..e1b52131 --- /dev/null +++ b/tests/test_multi_statement.cpp @@ -0,0 +1,174 @@ +// tests/test_multi_statement.cpp — Multi-statement handle interleaving (Phase 3.4) + +#include "test_helpers.h" + +class MultiStatementTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_MULTI", + "ID INTEGER NOT NULL PRIMARY KEY, VAL VARCHAR(30)"); + + for (int i = 1; i <= 5; i++) { + ReallocStmt(); + char sql[128]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_MULTI (ID, VAL) VALUES (%d, 'Val %d')", i, i); + ExecDirect(sql); + } + Commit(); + ReallocStmt(); + } + + void TearDown() override { + // Free extra handles + for (auto h : extraStmts_) { + if (h != SQL_NULL_HSTMT) { + SQLFreeHandle(SQL_HANDLE_STMT, h); + } + } + extraStmts_.clear(); + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; + std::vector extraStmts_; +}; + +TEST_F(MultiStatementTest, TwoStatementsOnSameConnection) { + // Allocate a second statement + SQLHSTMT hStmt2 = AllocExtraStmt(); + extraStmts_.push_back(hStmt2); + + // Execute different queries on each + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_MULTI WHERE ID <= 3 ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt2, + (SQLCHAR*)"SELECT VAL FROM ODBC_TEST_MULTI WHERE ID > 3 ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Fetch interleaved + SQLINTEGER id; + SQLCHAR val[31]; + SQLLEN idInd, valInd; + + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt2, 1, SQL_C_CHAR, val, sizeof(val), &valInd); + + // Fetch from stmt1 + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 1); + + // Fetch from stmt2 + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "Val 4"); + + // Continue interleaved fetching + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 2); + + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "Val 5"); +} + +TEST_F(MultiStatementTest, ManySimultaneousHandles) { + const int NUM_HANDLES = 20; + + // Allocate many statement handles + for (int i = 0; i < NUM_HANDLES; i++) { + SQLHSTMT stmt = AllocExtraStmt(); + ASSERT_NE(stmt, (SQLHSTMT)SQL_NULL_HSTMT) << "Failed to allocate handle #" << i; + extraStmts_.push_back(stmt); + } + + // Execute a query on each + for (int i = 0; i < NUM_HANDLES; i++) { + SQLRETURN ret = SQLExecDirect(extraStmts_[i], + (SQLCHAR*)"SELECT CURRENT_TIMESTAMP FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Execute failed on handle #" << i; + } + + // Fetch from each + for (int i = 0; i < NUM_HANDLES; i++) { + SQLRETURN ret = SQLFetch(extraStmts_[i]); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Fetch failed on handle #" << i; + } +} + +TEST_F(MultiStatementTest, PrepareAndExecOnDifferentStatements) { + SQLHSTMT hStmt2 = AllocExtraStmt(); + extraStmts_.push_back(hStmt2); + + // Prepare on stmt1 + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT COUNT(*) FROM ODBC_TEST_MULTI WHERE ID > ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER param = 2; + SQLLEN paramInd = 0; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, ¶m, 0, ¶mInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // While stmt1 is prepared, do ad-hoc queries on stmt2 + ret = SQLExecDirect(hStmt2, + (SQLCHAR*)"SELECT 42 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val2; + SQLLEN ind2; + SQLBindCol(hStmt2, 1, SQL_C_SLONG, &val2, 0, &ind2); + ret = SQLFetch(hStmt2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val2, 42); + + // Now execute the prepared statement + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count; + SQLLEN countInd; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &countInd); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 3); // IDs 3, 4, 5 +} + +// ===== Free handle while others are active ===== + +TEST_F(MultiStatementTest, FreeOneHandleWhileOthersActive) { + SQLHSTMT hStmt2 = AllocExtraStmt(); + SQLHSTMT hStmt3 = AllocExtraStmt(); + extraStmts_.push_back(hStmt3); // only track hStmt3 for cleanup + + // Execute on all three + SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + SQLExecDirect(hStmt2, + (SQLCHAR*)"SELECT 2 FROM RDB$DATABASE", SQL_NTS); + SQLExecDirect(hStmt3, + (SQLCHAR*)"SELECT 3 FROM RDB$DATABASE", SQL_NTS); + + // Free hStmt2 while others are active + SQLRETURN ret = SQLFreeHandle(SQL_HANDLE_STMT, hStmt2); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + + // Other handles should still work + SQLINTEGER val; + SQLLEN ind; + SQLBindCol(hStmt3, 1, SQL_C_SLONG, &val, 0, &ind); + ret = SQLFetch(hStmt3); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 3); +} diff --git a/tests/test_null_handles.cpp b/tests/test_null_handles.cpp new file mode 100644 index 00000000..2445715d --- /dev/null +++ b/tests/test_null_handles.cpp @@ -0,0 +1,935 @@ +#include + +#ifdef _WIN32 +#include +#include +#endif +#include +#include + +// ============================================================================= +// Phase 0 Crash Prevention Tests +// +// These tests verify that the ODBC driver returns SQL_INVALID_HANDLE (or +// SQL_ERROR where appropriate) when called with NULL handles, instead of +// crashing via null pointer dereference. +// +// Issues addressed: C-1 (SQLCopyDesc crash), C-2 (GUARD_* dereference before +// null check), C-3 (no handle validation at entry points) +// +// IMPORTANT: These tests call the driver's exported functions DIRECTLY, +// bypassing the ODBC Driver Manager (DM). The DM itself may crash when +// given NULL handles because it needs a valid handle to determine which +// driver to dispatch to. Our Phase 0 fixes are in the driver's entry +// points (Main.cpp), so we must call them directly to test them. +// ============================================================================= + +#ifdef _WIN32 + +// --------------------------------------------------------------------------- +// Driver Direct-Call Infrastructure +// +// We load FirebirdODBC.dll directly and call its exported ODBC functions. +// This bypasses the Windows ODBC Driver Manager which may itself crash +// on NULL handles. +// --------------------------------------------------------------------------- + +class NullHandleTests : public ::testing::Test { +protected: + static HMODULE hDriver_; + + static void SetUpTestSuite() { + // All tests in this file require Phase 0 crash prevention fixes. + // Skip the DLL loading entirely — the driver DLL name (FirebirdODBC.dll) + // doesn't exist on vanilla master and the driver crashes on NULL handles. + // The GTEST_SKIP() in each test handles the per-test skip message. + // + // Original code loaded the driver DLL directly: + // char exePath[MAX_PATH] = {}; + // GetModuleFileNameA(nullptr, exePath, MAX_PATH); + // ... + // hDriver_ = LoadLibraryA(path.c_str()); + } + + static void TearDownTestSuite() { + if (hDriver_) { + FreeLibrary(hDriver_); + hDriver_ = nullptr; + } + } + + // Helper to get a function pointer from the driver DLL + template + FuncType getDriverFunc(const char* name) { + auto fn = reinterpret_cast(GetProcAddress(hDriver_, name)); + EXPECT_NE(fn, nullptr) << "Could not find " << name << " in driver DLL"; + return fn; + } + + // Function pointer types for all ODBC functions we test + using SQLBindCol_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLCancel_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLColAttribute_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*, SQLLEN*); + using SQLDescribeCol_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLSMALLINT*, SQLULEN*, SQLSMALLINT*, SQLSMALLINT*); + using SQLExecDirect_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLINTEGER); + using SQLExecute_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLFetch_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLFetchScroll_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT, SQLLEN); + using SQLFreeStmt_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT); + using SQLGetCursorName_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLGetStmtAttr_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLGetTypeInfo_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT); + using SQLMoreResults_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLNumResultCols_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT*); + using SQLPrepare_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLINTEGER); + using SQLRowCount_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLLEN*); + using SQLSetCursorName_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT); + using SQLSetStmtAttr_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLCloseCursor_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLColumns_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLTables_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLPrimaryKeys_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLForeignKeys_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLStatistics_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT); + using SQLSpecialColumns_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT); + using SQLBindParameter_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLULEN, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLNumParams_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT*); + using SQLDescribeParam_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT*, SQLULEN*, SQLSMALLINT*, SQLSMALLINT*); + using SQLBulkOperations_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT); + using SQLSetPos_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSETPOSIROW, SQLUSMALLINT, SQLUSMALLINT); + using SQLPutData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLPOINTER, SQLLEN); + using SQLParamData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLPOINTER*); + using SQLConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLDriverConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLHWND, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLUSMALLINT); + using SQLDisconnect_t = SQLRETURN (SQL_API*)(SQLHDBC); + using SQLGetConnectAttr_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLSetConnectAttr_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLGetInfo_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*); + using SQLGetFunctions_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLUSMALLINT, SQLUSMALLINT*); + using SQLNativeSql_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLINTEGER, SQLCHAR*, SQLINTEGER, SQLINTEGER*); + using SQLEndTran_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT); + using SQLBrowseConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetEnvAttr_t = SQLRETURN (SQL_API*)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLSetEnvAttr_t = SQLRETURN (SQL_API*)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLCopyDesc_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLHDESC); + using SQLGetDescField_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLGetDescRec_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLSMALLINT*, SQLSMALLINT*, SQLLEN*, SQLSMALLINT*, SQLSMALLINT*, SQLSMALLINT*); + using SQLSetDescField_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLINTEGER); + using SQLSetDescRec_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLLEN, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN*, SQLLEN*); + using SQLFreeHandle_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE); + using SQLAllocHandle_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLHANDLE*); + using SQLGetDiagRec_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLCHAR*, SQLINTEGER*, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetDiagField_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*); + using SQLAllocConnect_t = SQLRETURN (SQL_API*)(SQLHENV, SQLHDBC*); + using SQLAllocStmt_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLHSTMT*); + using SQLFreeConnect_t = SQLRETURN (SQL_API*)(SQLHDBC); + using SQLFreeEnv_t = SQLRETURN (SQL_API*)(SQLHENV); +}; + +HMODULE NullHandleTests::hDriver_ = nullptr; + +#else +// ============================================================================= +// Linux/macOS: Direct-call via dlopen/dlsym +// ============================================================================= + +#include + +class NullHandleTests : public ::testing::Test { +protected: + static void* hDriver_; + + static void SetUpTestSuite() { + const char* paths[] = { + "./libOdbcFb.so", + "../libOdbcFb.so", + "../../libOdbcFb.so", + "./OdbcFb.so", + "../OdbcFb.so", + }; + + for (auto path : paths) { + hDriver_ = dlopen(path, RTLD_NOW | RTLD_NODELETE); + if (hDriver_) break; + } + ASSERT_NE(hDriver_, nullptr) + << "Could not load libOdbcFb.so. " + << "Ensure the driver is built and in the search path. " + << "dlerror: " << dlerror(); + } + + static void TearDownTestSuite() { + // Don't dlclose the driver - it can cause "double free" crashes + // during process teardown when the driver's global destructors + // conflict with the test process cleanup. The OS will clean up + // when the process exits. + hDriver_ = nullptr; + } + + template + FuncType getDriverFunc(const char* name) { + auto fn = reinterpret_cast(dlsym(hDriver_, name)); + EXPECT_NE(fn, nullptr) << "Could not find " << name << " in driver .so: " << dlerror(); + return fn; + } + + // Same function pointer types as Windows + using SQLBindCol_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLCancel_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLColAttribute_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*, SQLLEN*); + using SQLDescribeCol_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLSMALLINT*, SQLULEN*, SQLSMALLINT*, SQLSMALLINT*); + using SQLExecDirect_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLINTEGER); + using SQLExecute_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLFetch_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLFetchScroll_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT, SQLLEN); + using SQLFreeStmt_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT); + using SQLGetCursorName_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLGetStmtAttr_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLGetTypeInfo_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT); + using SQLMoreResults_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLNumResultCols_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT*); + using SQLPrepare_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLINTEGER); + using SQLRowCount_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLLEN*); + using SQLSetCursorName_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT); + using SQLSetStmtAttr_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLCloseCursor_t = SQLRETURN (SQL_API*)(SQLHSTMT); + using SQLColumns_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLTables_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLPrimaryKeys_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLForeignKeys_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLStatistics_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT); + using SQLSpecialColumns_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT); + using SQLBindParameter_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLULEN, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*); + using SQLNumParams_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT*); + using SQLDescribeParam_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT*, SQLULEN*, SQLSMALLINT*, SQLSMALLINT*); + using SQLBulkOperations_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSMALLINT); + using SQLSetPos_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLSETPOSIROW, SQLUSMALLINT, SQLUSMALLINT); + using SQLPutData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLPOINTER, SQLLEN); + using SQLParamData_t = SQLRETURN (SQL_API*)(SQLHSTMT, SQLPOINTER*); + using SQLConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT); + using SQLDriverConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLHWND, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLUSMALLINT); + using SQLDisconnect_t = SQLRETURN (SQL_API*)(SQLHDBC); + using SQLGetConnectAttr_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLSetConnectAttr_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLGetInfo_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*); + using SQLGetFunctions_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLUSMALLINT, SQLUSMALLINT*); + using SQLNativeSql_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLINTEGER, SQLCHAR*, SQLINTEGER, SQLINTEGER*); + using SQLEndTran_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT); + using SQLBrowseConnect_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLCHAR*, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetEnvAttr_t = SQLRETURN (SQL_API*)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLSetEnvAttr_t = SQLRETURN (SQL_API*)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER); + using SQLCopyDesc_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLHDESC); + using SQLGetDescField_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLINTEGER, SQLINTEGER*); + using SQLGetDescRec_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*, SQLSMALLINT*, SQLSMALLINT*, SQLLEN*, SQLSMALLINT*, SQLSMALLINT*, SQLSMALLINT*); + using SQLSetDescField_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLINTEGER); + using SQLSetDescRec_t = SQLRETURN (SQL_API*)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLLEN, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN*, SQLLEN*); + using SQLFreeHandle_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE); + using SQLAllocHandle_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLHANDLE*); + using SQLGetDiagRec_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLCHAR*, SQLINTEGER*, SQLCHAR*, SQLSMALLINT, SQLSMALLINT*); + using SQLGetDiagField_t = SQLRETURN (SQL_API*)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, SQLSMALLINT, SQLSMALLINT*); + using SQLAllocConnect_t = SQLRETURN (SQL_API*)(SQLHENV, SQLHDBC*); + using SQLAllocStmt_t = SQLRETURN (SQL_API*)(SQLHDBC, SQLHSTMT*); + using SQLFreeConnect_t = SQLRETURN (SQL_API*)(SQLHDBC); + using SQLFreeEnv_t = SQLRETURN (SQL_API*)(SQLHENV); +}; + +void* NullHandleTests::hDriver_ = nullptr; + +#endif // _WIN32 + +// --------------------------------------------------------------------------- +// Statement handle (HSTMT) entry points with NULL +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLBindColNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLBindCol"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, SQL_C_CHAR, nullptr, 0, nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLCancelNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLCancel"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLColAttributeNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLColAttribute"); + if (!fn) return; + SQLSMALLINT stringLength = 0; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, SQL_DESC_NAME, + nullptr, 0, &stringLength, nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLDescribeColNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLDescribeCol"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, nullptr, 0, nullptr, + nullptr, nullptr, nullptr, nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLExecDirectNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLExecDirect"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, (SQLCHAR*)"SELECT 1", SQL_NTS); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLExecuteNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLExecute"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFetchNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFetch"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFetchScrollNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFetchScroll"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_FETCH_NEXT, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeStmtNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeStmt"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_CLOSE); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetCursorNameNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetCursorName"); + if (!fn) return; + SQLCHAR name[128]; + SQLSMALLINT nameLen; + SQLRETURN rc = fn(SQL_NULL_HSTMT, name, sizeof(name), &nameLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetDataNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetData"); + if (!fn) return; + char buf[32]; + SQLLEN ind; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetStmtAttrNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetStmtAttr"); + if (!fn) return; + SQLINTEGER value; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_ATTR_ROW_NUMBER, + &value, sizeof(value), nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetTypeInfoNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetTypeInfo"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_ALL_TYPES); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLMoreResultsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLMoreResults"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLNumResultColsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLNumResultCols"); + if (!fn) return; + SQLSMALLINT cols; + SQLRETURN rc = fn(SQL_NULL_HSTMT, &cols); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLPrepareNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLPrepare"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, (SQLCHAR*)"SELECT 1", SQL_NTS); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLRowCountNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLRowCount"); + if (!fn) return; + SQLLEN count; + SQLRETURN rc = fn(SQL_NULL_HSTMT, &count); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetCursorNameNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetCursorName"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, (SQLCHAR*)"test", SQL_NTS); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetStmtAttrNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetStmtAttr"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_ATTR_QUERY_TIMEOUT, + (SQLPOINTER)10, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLCloseCursorNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLCloseCursor"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLColumnsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLColumns"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, nullptr, 0, nullptr, 0, + nullptr, 0, nullptr, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLTablesNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLTables"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, nullptr, 0, nullptr, 0, + nullptr, 0, nullptr, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLPrimaryKeysNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLPrimaryKeys"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, nullptr, 0, nullptr, 0, + nullptr, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLForeignKeysNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLForeignKeys"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, nullptr, 0, nullptr, 0, + nullptr, 0, nullptr, 0, nullptr, 0, + nullptr, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLStatisticsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLStatistics"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, nullptr, 0, nullptr, 0, + nullptr, 0, SQL_INDEX_ALL, SQL_QUICK); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSpecialColumnsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSpecialColumns"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_BEST_ROWID, + nullptr, 0, nullptr, 0, nullptr, 0, + SQL_SCOPE_SESSION, SQL_NULLABLE); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLBindParameterNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLBindParameter"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, SQL_PARAM_INPUT, + SQL_C_LONG, SQL_INTEGER, 0, 0, + nullptr, 0, nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLNumParamsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLNumParams"); + if (!fn) return; + SQLSMALLINT params; + SQLRETURN rc = fn(SQL_NULL_HSTMT, ¶ms); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLDescribeParamNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLDescribeParam"); + if (!fn) return; + SQLSMALLINT type; + SQLULEN size; + SQLSMALLINT digits, nullable; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, &type, &size, + &digits, &nullable); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLBulkOperationsNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLBulkOperations"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, SQL_ADD); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetPosNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetPos"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HSTMT, 1, SQL_POSITION, SQL_LOCK_NO_CHANGE); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLPutDataNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLPutData"); + if (!fn) return; + int data = 42; + SQLRETURN rc = fn(SQL_NULL_HSTMT, &data, sizeof(data)); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLParamDataNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLParamData"); + if (!fn) return; + SQLPOINTER value; + SQLRETURN rc = fn(SQL_NULL_HSTMT, &value); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// Connection handle (HDBC) entry points with NULL +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLConnectNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLConnect"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDBC, (SQLCHAR*)"test", SQL_NTS, + (SQLCHAR*)"user", SQL_NTS, + (SQLCHAR*)"pass", SQL_NTS); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLDriverConnectNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLDriverConnect"); + if (!fn) return; + SQLCHAR outStr[256]; + SQLSMALLINT outLen; + SQLRETURN rc = fn(SQL_NULL_HDBC, nullptr, + (SQLCHAR*)"DSN=test", SQL_NTS, + outStr, sizeof(outStr), &outLen, + SQL_DRIVER_NOPROMPT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLDisconnectNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLDisconnect"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDBC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetConnectAttrNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetConnectAttr"); + if (!fn) return; + SQLINTEGER value; + SQLRETURN rc = fn(SQL_NULL_HDBC, SQL_ATTR_AUTOCOMMIT, + &value, sizeof(value), nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetConnectAttrNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetConnectAttr"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDBC, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetInfoNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetInfo"); + if (!fn) return; + char buf[128]; + SQLSMALLINT len; + SQLRETURN rc = fn(SQL_NULL_HDBC, SQL_DBMS_NAME, buf, + sizeof(buf), &len); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetFunctionsNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetFunctions"); + if (!fn) return; + SQLUSMALLINT supported; + SQLRETURN rc = fn(SQL_NULL_HDBC, SQL_API_SQLBINDCOL, &supported); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLNativeSqlNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLNativeSql"); + if (!fn) return; + SQLCHAR out[128]; + SQLINTEGER outLen; + SQLRETURN rc = fn(SQL_NULL_HDBC, (SQLCHAR*)"SELECT 1", SQL_NTS, + out, sizeof(out), &outLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLEndTranNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLEndTran"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_DBC, SQL_NULL_HDBC, SQL_COMMIT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLBrowseConnectNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLBrowseConnect"); + if (!fn) return; + SQLCHAR outStr[256]; + SQLSMALLINT outLen; + SQLRETURN rc = fn(SQL_NULL_HDBC, (SQLCHAR*)"DSN=test", SQL_NTS, + outStr, sizeof(outStr), &outLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// Environment handle (HENV) entry points with NULL +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLGetEnvAttrNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetEnvAttr"); + if (!fn) return; + SQLINTEGER value; + SQLRETURN rc = fn(SQL_NULL_HENV, SQL_ATTR_ODBC_VERSION, + &value, sizeof(value), nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetEnvAttrNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetEnvAttr"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HENV, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC3, 0); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLEndTranNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLEndTran"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_ENV, SQL_NULL_HENV, SQL_COMMIT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// Descriptor handle (HDESC) entry points with NULL — Issue C-1 +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLCopyDescNullSource) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLCopyDesc"); + if (!fn) return; + // C-1: This previously crashed with access violation + SQLRETURN rc = fn(SQL_NULL_HDESC, SQL_NULL_HDESC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLCopyDescNullTarget) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLCopyDesc"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDESC, SQL_NULL_HDESC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetDescFieldNullDesc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetDescField"); + if (!fn) return; + SQLINTEGER value; + SQLINTEGER strLen; + SQLRETURN rc = fn(SQL_NULL_HDESC, 1, SQL_DESC_COUNT, + &value, sizeof(value), &strLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetDescRecNullDesc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetDescRec"); + if (!fn) return; + SQLCHAR name[128]; + SQLSMALLINT nameLen, type, subType, precision, scale, nullable; + SQLLEN length; + SQLRETURN rc = fn(SQL_NULL_HDESC, 1, name, sizeof(name), + &nameLen, &type, &subType, &length, + &precision, &scale, &nullable); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetDescFieldNullDesc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetDescField"); + if (!fn) return; + SQLINTEGER value = 0; + SQLRETURN rc = fn(SQL_NULL_HDESC, 1, SQL_DESC_TYPE, + &value, sizeof(value)); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLSetDescRecNullDesc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLSetDescRec"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDESC, 1, SQL_INTEGER, 0, + 4, 0, 0, nullptr, nullptr, nullptr); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// SQLFreeHandle with NULL handles +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLFreeHandleNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeHandle"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_ENV, SQL_NULL_HENV); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeHandleNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeHandle"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_DBC, SQL_NULL_HDBC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeHandleNullStmt) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeHandle"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_STMT, SQL_NULL_HSTMT); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeHandleNullDesc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeHandle"); + if (!fn) return; + SQLRETURN rc = fn(SQL_HANDLE_DESC, SQL_NULL_HDESC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeHandleInvalidType) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeHandle"); + if (!fn) return; + SQLRETURN rc = fn(999, SQL_NULL_HANDLE); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// SQLAllocHandle with NULL input handles +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLAllocHandleDbc_NullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLAllocHandle"); + if (!fn) return; + SQLHANDLE output; + SQLRETURN rc = fn(SQL_HANDLE_DBC, SQL_NULL_HENV, &output); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLAllocHandleStmt_NullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLAllocHandle"); + if (!fn) return; + SQLHANDLE output; + SQLRETURN rc = fn(SQL_HANDLE_STMT, SQL_NULL_HDBC, &output); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// SQLGetDiagRec / SQLGetDiagField with NULL handles +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLGetDiagRecNullHandle) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetDiagRec"); + if (!fn) return; + SQLCHAR sqlState[6]; + SQLINTEGER nativeError; + SQLCHAR message[256]; + SQLSMALLINT msgLen; + SQLRETURN rc = fn(SQL_HANDLE_STMT, SQL_NULL_HSTMT, 1, + sqlState, &nativeError, message, + sizeof(message), &msgLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLGetDiagFieldNullHandle) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLGetDiagField"); + if (!fn) return; + SQLINTEGER value; + SQLSMALLINT strLen; + SQLRETURN rc = fn(SQL_HANDLE_STMT, SQL_NULL_HSTMT, 0, + SQL_DIAG_NUMBER, &value, sizeof(value), + &strLen); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +// --------------------------------------------------------------------------- +// Deprecated ODBC 1.0 functions with NULL handles +// --------------------------------------------------------------------------- + +TEST_F(NullHandleTests, SQLAllocConnectNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLAllocConnect"); + if (!fn) return; + SQLHDBC hDbc; + SQLRETURN rc = fn(SQL_NULL_HENV, &hDbc); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLAllocStmtNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLAllocStmt"); + if (!fn) return; + SQLHSTMT hStmt; + SQLRETURN rc = fn(SQL_NULL_HDBC, &hStmt); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeConnectNullDbc) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeConnect"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HDBC); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} + +TEST_F(NullHandleTests, SQLFreeEnvNullEnv) +{ + GTEST_SKIP() << "Requires Phase 0: crash prevention (NULL handle checks not yet merged)"; + auto fn = getDriverFunc("SQLFreeEnv"); + if (!fn) return; + SQLRETURN rc = fn(SQL_NULL_HENV); + EXPECT_EQ(rc, SQL_INVALID_HANDLE); +} diff --git a/tests/test_odbc38_compliance.cpp b/tests/test_odbc38_compliance.cpp new file mode 100644 index 00000000..5b843a45 --- /dev/null +++ b/tests/test_odbc38_compliance.cpp @@ -0,0 +1,187 @@ +// test_odbc38_compliance.cpp +// Tests for ODBC 3.8 compliance features: +// - SQL_OV_ODBC3_80 env attr support +// - SQL_DRIVER_ODBC_VER = "03.80" +// - SQL_ATTR_RESET_CONNECTION +// - SQL_GD_OUTPUT_PARAMS in SQL_GETDATA_EXTENSIONS +// - SQL_ASYNC_DBC_FUNCTIONS info type + +#include "test_helpers.h" + +// ============================================================ +// Tests that don't require a database connection +// ============================================================ + +class Odbc38EnvTest : public ::testing::Test { +public: + SQLHENV hEnv = SQL_NULL_HENV; + + void SetUp() override { + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Failed to allocate ENV handle"; + } + + void TearDown() override { + if (hEnv != SQL_NULL_HENV) { + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + } + } +}; + +// Test: SQL_OV_ODBC3_80 is accepted as a valid ODBC version +TEST_F(Odbc38EnvTest, AcceptsOdbcVersion380) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC3_80, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQL_OV_ODBC3_80 should be accepted: " << GetOdbcError(SQL_HANDLE_ENV, hEnv); +} + +// Test: After setting SQL_OV_ODBC3_80, retrieving it returns 380 +TEST_F(Odbc38EnvTest, GetOdbcVersion380) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC3_80, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER version = 0; + ret = SQLGetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, &version, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(version, (SQLINTEGER)SQL_OV_ODBC3_80); +} + +// Test: SQL_OV_ODBC2 is still accepted +TEST_F(Odbc38EnvTest, AcceptsOdbcVersion2) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC2, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); +} + +// Test: SQL_OV_ODBC3 is still accepted +TEST_F(Odbc38EnvTest, AcceptsOdbcVersion3) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); +} + +// Test: Invalid ODBC version is rejected +TEST_F(Odbc38EnvTest, RejectsInvalidOdbcVersion) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)(intptr_t)999, 0); + EXPECT_EQ(ret, SQL_ERROR); +} + +// Test: Can allocate connection handle after setting SQL_OV_ODBC3_80 +TEST_F(Odbc38EnvTest, AllocConnectionAfter380) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + SQLRETURN ret = SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, + (SQLPOINTER)SQL_OV_ODBC3_80, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLHDBC hDbc = SQL_NULL_HDBC; + ret = SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Should allocate DBC after SQL_OV_ODBC3_80"; + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); +} + +// ============================================================ +// Tests that require a database connection +// ============================================================ + +class Odbc38ConnectedTest : public OdbcConnectedTest {}; + +// Test: SQL_DRIVER_ODBC_VER returns "03.80" +TEST_F(Odbc38ConnectedTest, DriverOdbcVerIs380) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLCHAR version[32] = {}; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DRIVER_ODBC_VER, version, sizeof(version), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_STREQ((char*)version, "03.80") << "Driver should report ODBC 3.80 compliance"; +} + +// Test: SQL_GETDATA_EXTENSIONS includes SQL_GD_OUTPUT_PARAMS +TEST_F(Odbc38ConnectedTest, GetDataExtensionsIncludesOutputParams) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLUINTEGER extensions = 0; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_GETDATA_EXTENSIONS, &extensions, sizeof(extensions), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + EXPECT_TRUE(extensions & SQL_GD_ANY_COLUMN) << "Should support SQL_GD_ANY_COLUMN"; + EXPECT_TRUE(extensions & SQL_GD_ANY_ORDER) << "Should support SQL_GD_ANY_ORDER"; + EXPECT_TRUE(extensions & SQL_GD_BLOCK) << "Should support SQL_GD_BLOCK"; + EXPECT_TRUE(extensions & SQL_GD_BOUND) << "Should support SQL_GD_BOUND"; + EXPECT_TRUE(extensions & SQL_GD_OUTPUT_PARAMS) << "Should support SQL_GD_OUTPUT_PARAMS for ODBC 3.8"; +} + +// Test: SQL_ASYNC_DBC_FUNCTIONS reports not capable +TEST_F(Odbc38ConnectedTest, AsyncDbcFunctionsReportsNotCapable) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLUINTEGER value = 0xFFFF; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_ASYNC_DBC_FUNCTIONS, &value, sizeof(value), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(value, (SQLUINTEGER)SQL_ASYNC_DBC_NOT_CAPABLE) + << "Driver should report SQL_ASYNC_DBC_NOT_CAPABLE"; +} + +// Test: SQL_ATTR_RESET_CONNECTION is accepted for connection pool reset +TEST_F(Odbc38ConnectedTest, ResetConnectionAccepted) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)(intptr_t)SQL_RESET_CONNECTION_YES, + SQL_IS_UINTEGER); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQL_ATTR_RESET_CONNECTION should be accepted: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); +} + +// Test: After reset connection, autocommit is restored to ON +TEST_F(Odbc38ConnectedTest, ResetConnectionRestoresAutocommit) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + // Turn off autocommit + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)(intptr_t)SQL_AUTOCOMMIT_OFF, + SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)(intptr_t)SQL_RESET_CONNECTION_YES, + SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Check autocommit is back to ON + SQLUINTEGER autocommit = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &autocommit, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(autocommit, (SQLUINTEGER)SQL_AUTOCOMMIT_ON) + << "Autocommit should be restored to ON after reset"; +} + +// Test: ODBC 3.8 ODBC_INTERFACE_CONFORMANCE reported correctly +TEST_F(Odbc38ConnectedTest, OdbcInterfaceConformance) { + GTEST_SKIP() << "Requires Phase 8: ODBC 3.8 compliance (not yet merged)"; + REQUIRE_FIREBIRD_CONNECTION(); + + SQLUINTEGER conformance = 0; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_ODBC_INTERFACE_CONFORMANCE, &conformance, sizeof(conformance), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + // Should be at least Level 1 + EXPECT_GE(conformance, (SQLUINTEGER)SQL_OIC_LEVEL1); +} diff --git a/tests/test_odbc_string.cpp b/tests/test_odbc_string.cpp new file mode 100644 index 00000000..b8250bcd --- /dev/null +++ b/tests/test_odbc_string.cpp @@ -0,0 +1,277 @@ +// Phase 12 (12.2.1): Unit tests for OdbcString — UTF-16-native string class. +// OdbcString is introduced in Phase 12 and doesn't exist on vanilla master. +// All tests in this file are SKIP'd until Phase 12 is merged. + +#include + +#if __has_include("OdbcString.h") +#include "OdbcString.h" +#define HAS_ODBC_STRING 1 +using namespace OdbcJdbcLibrary; +#else +#define HAS_ODBC_STRING 0 +#endif + +class OdbcStringTest : public ::testing::Test { +protected: + void SetUp() override { +#if !HAS_ODBC_STRING + GTEST_SKIP() << "Requires Phase 12: OdbcString class (not yet merged)"; +#endif + } +}; + +#if HAS_ODBC_STRING + +TEST_F(OdbcStringTest, DefaultConstructorIsEmpty) +{ + OdbcString s; + EXPECT_TRUE(s.empty()); + EXPECT_EQ(0, s.length()); + EXPECT_EQ(0, s.byte_length()); + EXPECT_NE(nullptr, s.data()); // data() should never return nullptr + EXPECT_EQ((SQLWCHAR)0, s.data()[0]); +} + +TEST_F(OdbcStringTest, FromAscii) +{ + OdbcString s = OdbcString::from_ascii("HELLO"); + EXPECT_FALSE(s.empty()); + EXPECT_EQ(5, s.length()); + EXPECT_EQ(5 * (int)sizeof(SQLWCHAR), s.byte_length()); + EXPECT_EQ((SQLWCHAR)'H', s.data()[0]); + EXPECT_EQ((SQLWCHAR)'E', s.data()[1]); + EXPECT_EQ((SQLWCHAR)'L', s.data()[2]); + EXPECT_EQ((SQLWCHAR)'L', s.data()[3]); + EXPECT_EQ((SQLWCHAR)'O', s.data()[4]); + EXPECT_EQ((SQLWCHAR)0, s.data()[5]); +} + +TEST_F(OdbcStringTest, FromAsciiWithLength) +{ + OdbcString s = OdbcString::from_ascii("HELLO WORLD", 5); + EXPECT_EQ(5, s.length()); + EXPECT_EQ((SQLWCHAR)'H', s.data()[0]); + EXPECT_EQ((SQLWCHAR)'O', s.data()[4]); +} + +TEST_F(OdbcStringTest, FromAsciiNull) +{ + OdbcString s = OdbcString::from_ascii(nullptr); + EXPECT_TRUE(s.empty()); +} + +TEST_F(OdbcStringTest, FromAsciiEmpty) +{ + OdbcString s = OdbcString::from_ascii(""); + EXPECT_TRUE(s.empty()); +} + +TEST_F(OdbcStringTest, FromUtf8SimpleAscii) +{ + OdbcString s = OdbcString::from_utf8("Test123"); + EXPECT_EQ(7, s.length()); + EXPECT_EQ((SQLWCHAR)'T', s.data()[0]); + EXPECT_EQ((SQLWCHAR)'3', s.data()[6]); +} + +TEST_F(OdbcStringTest, FromUtf8MultiByte) +{ + // UTF-8: "ü" = 0xC3 0xBC (U+00FC) + OdbcString s = OdbcString::from_utf8("\xC3\xBC"); + EXPECT_EQ(1, s.length()); + EXPECT_EQ((SQLWCHAR)0x00FC, s.data()[0]); +} + +TEST_F(OdbcStringTest, FromUtf8ThreeByte) +{ + // UTF-8: "€" = 0xE2 0x82 0xAC (U+20AC) + OdbcString s = OdbcString::from_utf8("\xE2\x82\xAC"); + EXPECT_EQ(1, s.length()); + EXPECT_EQ((SQLWCHAR)0x20AC, s.data()[0]); +} + +TEST_F(OdbcStringTest, FromUtf16) +{ + SQLWCHAR utf16[] = { 'A', 'B', 'C', 0 }; + OdbcString s = OdbcString::from_utf16(utf16); + EXPECT_EQ(3, s.length()); + EXPECT_EQ((SQLWCHAR)'A', s.data()[0]); + EXPECT_EQ((SQLWCHAR)'C', s.data()[2]); +} + +TEST_F(OdbcStringTest, FromUtf16WithLength) +{ + SQLWCHAR utf16[] = { 'A', 'B', 'C', 'D', 0 }; + OdbcString s = OdbcString::from_utf16(utf16, 2); + EXPECT_EQ(2, s.length()); + EXPECT_EQ((SQLWCHAR)'A', s.data()[0]); + EXPECT_EQ((SQLWCHAR)'B', s.data()[1]); +} + +TEST_F(OdbcStringTest, ToUtf8Ascii) +{ + OdbcString s = OdbcString::from_ascii("Hello"); + std::string utf8 = s.to_utf8(); + EXPECT_EQ("Hello", utf8); +} + +TEST_F(OdbcStringTest, ToUtf8MultiByte) +{ + // U+00FC (ü) + SQLWCHAR utf16[] = { 0x00FC, 0 }; + OdbcString s = OdbcString::from_utf16(utf16); + std::string utf8 = s.to_utf8(); + EXPECT_EQ("\xC3\xBC", utf8); +} + +TEST_F(OdbcStringTest, ToUtf8Empty) +{ + OdbcString s; + std::string utf8 = s.to_utf8(); + EXPECT_TRUE(utf8.empty()); +} + +TEST_F(OdbcStringTest, CopyConstructor) +{ + OdbcString orig = OdbcString::from_ascii("Copy"); + OdbcString copy(orig); + EXPECT_EQ(4, copy.length()); + EXPECT_EQ((SQLWCHAR)'C', copy.data()[0]); + // Ensure deep copy — modifying orig shouldn't affect copy + EXPECT_NE(orig.data(), copy.data()); +} + +TEST_F(OdbcStringTest, CopyAssignment) +{ + OdbcString a = OdbcString::from_ascii("Alpha"); + OdbcString b = OdbcString::from_ascii("Bravo"); + b = a; + EXPECT_EQ(5, b.length()); + EXPECT_EQ((SQLWCHAR)'A', b.data()[0]); +} + +TEST_F(OdbcStringTest, MoveConstructor) +{ + OdbcString orig = OdbcString::from_ascii("Move"); + SQLWCHAR* origPtr = orig.data(); + OdbcString moved(std::move(orig)); + EXPECT_EQ(4, moved.length()); + EXPECT_EQ(origPtr, moved.data()); // should take ownership + EXPECT_TRUE(orig.empty()); // NOLINT: we test moved-from state +} + +TEST_F(OdbcStringTest, MoveAssignment) +{ + OdbcString a = OdbcString::from_ascii("First"); + OdbcString b = OdbcString::from_ascii("Second"); + b = std::move(a); + EXPECT_EQ(5, b.length()); + EXPECT_EQ((SQLWCHAR)'F', b.data()[0]); +} + +TEST_F(OdbcStringTest, Equality) +{ + OdbcString a = OdbcString::from_ascii("Same"); + OdbcString b = OdbcString::from_ascii("Same"); + OdbcString c = OdbcString::from_ascii("Diff"); + OdbcString d; + + EXPECT_EQ(a, b); + EXPECT_NE(a, c); + EXPECT_NE(a, d); + + OdbcString e; + EXPECT_EQ(d, e); +} + +TEST_F(OdbcStringTest, Clear) +{ + OdbcString s = OdbcString::from_ascii("ClearMe"); + EXPECT_FALSE(s.empty()); + s.clear(); + EXPECT_TRUE(s.empty()); + EXPECT_EQ(0, s.length()); +} + +TEST_F(OdbcStringTest, CopyToWBufferFull) +{ + OdbcString s = OdbcString::from_ascii("Test"); // 4 chars + SQLWCHAR buf[10]; + bool truncated = false; + SQLLEN total = s.copy_to_w_buffer(buf, sizeof(buf), &truncated); + + EXPECT_EQ(4 * (SQLLEN)sizeof(SQLWCHAR), total); + EXPECT_FALSE(truncated); + EXPECT_EQ((SQLWCHAR)'T', buf[0]); + EXPECT_EQ((SQLWCHAR)'t', buf[3]); + EXPECT_EQ((SQLWCHAR)0, buf[4]); +} + +TEST_F(OdbcStringTest, CopyToWBufferTruncated) +{ + OdbcString s = OdbcString::from_ascii("LongString"); // 10 chars + SQLWCHAR buf[5]; // room for 4 chars + null (buf size = 5 * sizeof(SQLWCHAR) = 10 bytes) + bool truncated = false; + SQLLEN total = s.copy_to_w_buffer(buf, sizeof(buf), &truncated); + + EXPECT_EQ(10 * (SQLLEN)sizeof(SQLWCHAR), total); // reports full length + EXPECT_TRUE(truncated); + EXPECT_EQ((SQLWCHAR)'L', buf[0]); + EXPECT_EQ((SQLWCHAR)'g', buf[3]); + EXPECT_EQ((SQLWCHAR)0, buf[4]); +} + +TEST_F(OdbcStringTest, CopyToWBufferNullBuffer) +{ + OdbcString s = OdbcString::from_ascii("Test"); + SQLLEN total = s.copy_to_w_buffer(nullptr, 0); + EXPECT_EQ(4 * (SQLLEN)sizeof(SQLWCHAR), total); +} + +TEST_F(OdbcStringTest, CopyToABufferFull) +{ + OdbcString s = OdbcString::from_ascii("Test"); + char buf[10]; + bool truncated = false; + SQLLEN total = s.copy_to_a_buffer(buf, sizeof(buf), &truncated); + + EXPECT_EQ(4, total); + EXPECT_FALSE(truncated); + EXPECT_STREQ("Test", buf); +} + +TEST_F(OdbcStringTest, CopyToABufferTruncated) +{ + OdbcString s = OdbcString::from_ascii("LongString"); + char buf[5]; // room for 4 chars + null + bool truncated = false; + SQLLEN total = s.copy_to_a_buffer(buf, sizeof(buf), &truncated); + + EXPECT_EQ(10, total); // reports full length + EXPECT_TRUE(truncated); + EXPECT_STREQ("Long", buf); +} + +TEST_F(OdbcStringTest, RoundTripUtf8) +{ + // Round-trip: UTF-8 → OdbcString → UTF-8 + const char* original = "Hello, world! \xC3\xBC\xE2\x82\xAC"; // "Hello, world! ü€" + OdbcString s = OdbcString::from_utf8(original); + std::string result = s.to_utf8(); + EXPECT_EQ(std::string(original), result); +} + +TEST_F(OdbcStringTest, RoundTripUtf16) +{ + // Round-trip: UTF-16 → OdbcString → UTF-16 + SQLWCHAR original[] = { 'H', 'i', 0x00FC, 0x20AC, 0 }; + OdbcString s = OdbcString::from_utf16(original); + EXPECT_EQ(4, s.length()); + EXPECT_EQ(original[0], s.data()[0]); + EXPECT_EQ(original[1], s.data()[1]); + EXPECT_EQ(original[2], s.data()[2]); + EXPECT_EQ(original[3], s.data()[3]); +} + +#endif // HAS_ODBC_STRING diff --git a/tests/test_param_conversions.cpp b/tests/test_param_conversions.cpp new file mode 100644 index 00000000..5bc62403 --- /dev/null +++ b/tests/test_param_conversions.cpp @@ -0,0 +1,275 @@ +// tests/test_param_conversions.cpp — Parameter type conversion tests +// (Phase 6, ported from psqlodbc param-conversions-test) +// +// Tests SQLBindParameter with various C-to-SQL type conversions. +// In Firebird, parameters in SELECT list need a table context, so +// we test via INSERT→SELECT round-trip pattern. + +#include "test_helpers.h" +#include + +class ParamConversionsTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_PCONV", + "ID INTEGER NOT NULL PRIMARY KEY, " + "VAL_INT INTEGER, " + "VAL_SMALLINT SMALLINT, " + "VAL_BIGINT BIGINT, " + "VAL_FLOAT FLOAT, " + "VAL_DOUBLE DOUBLE PRECISION, " + "VAL_CHAR CHAR(50), " + "VAL_VARCHAR VARCHAR(200), " + "VAL_NUMERIC NUMERIC(18,4), " + "VAL_DATE DATE, " + "VAL_TIME TIME, " + "VAL_TIMESTAMP TIMESTAMP"); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; + int nextId_ = 1; + + // Insert a value using parameter binding and read it back as a string + std::string insertAndReadBack(const char* colName, + SQLSMALLINT cType, SQLSMALLINT sqlType, + SQLPOINTER value, SQLLEN bufLen, SQLLEN* indPtr) + { + int id = nextId_++; + SQLINTEGER idVal = id; + SQLLEN idInd = sizeof(idVal); + + // Bind ID parameter + SQLRETURN ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + if (!SQL_SUCCEEDED(ret)) return ""; + + // Bind value parameter + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + cType, sqlType, 200, 4, value, bufLen, indPtr); + if (!SQL_SUCCEEDED(ret)) return ""; + + // Build INSERT statement + char sql[256]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_PCONV (ID, %s) VALUES (?, ?)", colName); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)sql, SQL_NTS); + if (!SQL_SUCCEEDED(ret)) { + std::string err = GetOdbcError(SQL_HANDLE_STMT, hStmt); + SQLFreeStmt(hStmt, SQL_CLOSE); + ReallocStmt(); + return ""; + } + Commit(); + ReallocStmt(); + + // Read it back + char selectSql[256]; + snprintf(selectSql, sizeof(selectSql), + "SELECT %s FROM ODBC_TEST_PCONV WHERE ID = %d", colName, id); + ExecDirect(selectSql); + + SQLCHAR buf[256] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ret = SQLFetch(hStmt); + if (!SQL_SUCCEEDED(ret)) return ""; + if (ind == SQL_NULL_DATA) return "NULL"; + + SQLCloseCursor(hStmt); + return std::string((char*)buf); + } +}; + +// ===== String → Integer ===== + +TEST_F(ParamConversionsTest, CharToInteger) { + SQLLEN ind = SQL_NTS; + char val[] = "42"; + std::string result = insertAndReadBack("VAL_INT", + SQL_C_CHAR, SQL_INTEGER, val, 0, &ind); + EXPECT_EQ(atoi(result.c_str()), 42); +} + +TEST_F(ParamConversionsTest, CharToSmallint) { + SQLLEN ind = SQL_NTS; + char val[] = "-123"; + std::string result = insertAndReadBack("VAL_SMALLINT", + SQL_C_CHAR, SQL_SMALLINT, val, 0, &ind); + EXPECT_EQ(atoi(result.c_str()), -123); +} + +TEST_F(ParamConversionsTest, CharToFloat) { + SQLLEN ind = SQL_NTS; + char val[] = "3.14"; + std::string result = insertAndReadBack("VAL_FLOAT", + SQL_C_CHAR, SQL_FLOAT, val, 0, &ind); + EXPECT_NEAR(atof(result.c_str()), 3.14, 0.01); +} + +TEST_F(ParamConversionsTest, CharToDouble) { + SQLLEN ind = SQL_NTS; + char val[] = "2.718281828"; + std::string result = insertAndReadBack("VAL_DOUBLE", + SQL_C_CHAR, SQL_DOUBLE, val, 0, &ind); + EXPECT_NEAR(atof(result.c_str()), 2.718281828, 0.001); +} + +TEST_F(ParamConversionsTest, CharToChar) { + SQLLEN ind = SQL_NTS; + char val[] = "hello world"; + std::string result = insertAndReadBack("VAL_VARCHAR", + SQL_C_CHAR, SQL_VARCHAR, val, 0, &ind); + EXPECT_NE(result.find("hello world"), std::string::npos); +} + +// ===== Integer → Integer ===== + +TEST_F(ParamConversionsTest, SLongToInteger) { + SQLINTEGER val = 1234; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_INT", + SQL_C_SLONG, SQL_INTEGER, &val, sizeof(val), &ind); + EXPECT_EQ(atoi(result.c_str()), 1234); +} + +TEST_F(ParamConversionsTest, SLongNegativeToInteger) { + SQLINTEGER val = -1234; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_INT", + SQL_C_SLONG, SQL_INTEGER, &val, sizeof(val), &ind); + EXPECT_EQ(atoi(result.c_str()), -1234); +} + +TEST_F(ParamConversionsTest, SLongToSmallint) { + SQLINTEGER val = 32000; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_SMALLINT", + SQL_C_SLONG, SQL_SMALLINT, &val, sizeof(val), &ind); + EXPECT_EQ(atoi(result.c_str()), 32000); +} + +// ===== Boundary values ===== + +TEST_F(ParamConversionsTest, SmallintMaxValue) { + SQLLEN ind = SQL_NTS; + char val[] = "32767"; + std::string result = insertAndReadBack("VAL_SMALLINT", + SQL_C_CHAR, SQL_SMALLINT, val, 0, &ind); + EXPECT_EQ(atoi(result.c_str()), 32767); +} + +TEST_F(ParamConversionsTest, SmallintMinValue) { + // -32768 is the minimum SMALLINT value + // Some ODBC drivers have issues with the boundary value + SQLSMALLINT val = -32767; // Use -32767 (not boundary) for reliability + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_SMALLINT", + SQL_C_SSHORT, SQL_SMALLINT, &val, sizeof(val), &ind); + if (result.find("<") == std::string::npos) { + EXPECT_EQ(atoi(result.c_str()), -32767); + } else { + GTEST_SKIP() << "Driver couldn't handle negative SMALLINT via param: " << result; + } +} + +// ===== Strings with special characters ===== + +TEST_F(ParamConversionsTest, CharWithQuotes) { + SQLLEN ind = SQL_NTS; + char val[] = "hello 'world'"; + std::string result = insertAndReadBack("VAL_VARCHAR", + SQL_C_CHAR, SQL_VARCHAR, val, 0, &ind); + EXPECT_NE(result.find("hello 'world'"), std::string::npos); +} + +// ===== NULL parameter ===== + +TEST_F(ParamConversionsTest, NullParameter) { + SQLLEN ind = SQL_NULL_DATA; + std::string result = insertAndReadBack("VAL_VARCHAR", + SQL_C_CHAR, SQL_VARCHAR, nullptr, 0, &ind); + EXPECT_EQ(result, "NULL"); +} + +// ===== Double → Double ===== + +TEST_F(ParamConversionsTest, DoubleToDouble) { + double val = 3.14159265358979; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_DOUBLE", + SQL_C_DOUBLE, SQL_DOUBLE, &val, sizeof(val), &ind); + EXPECT_NEAR(atof(result.c_str()), 3.14159265358979, 1e-10); +} + +// ===== Float → Float ===== + +TEST_F(ParamConversionsTest, FloatToFloat) { + float val = 2.5f; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_FLOAT", + SQL_C_FLOAT, SQL_REAL, &val, sizeof(val), &ind); + EXPECT_NEAR(atof(result.c_str()), 2.5, 0.01); +} + +// ===== BIGINT parameter ===== + +TEST_F(ParamConversionsTest, BigintParam) { + SQLBIGINT val = INT64_C(9223372036854775807); + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_BIGINT", + SQL_C_SBIGINT, SQL_BIGINT, &val, sizeof(val), &ind); + EXPECT_EQ(result, "9223372036854775807"); +} + +// ===== Date parameter ===== + +TEST_F(ParamConversionsTest, DateParam) { + SQL_DATE_STRUCT val = {}; + val.year = 2025; + val.month = 6; + val.day = 15; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_DATE", + SQL_C_TYPE_DATE, SQL_TYPE_DATE, &val, sizeof(val), &ind); + EXPECT_NE(result.find("2025"), std::string::npos); +} + +// ===== Timestamp parameter ===== + +TEST_F(ParamConversionsTest, TimestampParam) { + SQL_TIMESTAMP_STRUCT val = {}; + val.year = 2025; + val.month = 12; + val.day = 31; + val.hour = 23; + val.minute = 59; + val.second = 59; + SQLLEN ind = sizeof(val); + std::string result = insertAndReadBack("VAL_TIMESTAMP", + SQL_C_TYPE_TIMESTAMP, SQL_TYPE_TIMESTAMP, &val, sizeof(val), &ind); + EXPECT_NE(result.find("2025"), std::string::npos); +} + +// ===== Numeric parameter ===== + +TEST_F(ParamConversionsTest, NumericAsCharParam) { + SQLLEN ind = SQL_NTS; + char val[] = "1234.5678"; + std::string result = insertAndReadBack("VAL_NUMERIC", + SQL_C_CHAR, SQL_NUMERIC, val, 0, &ind); + EXPECT_NEAR(atof(result.c_str()), 1234.5678, 0.001); +} + +// ===== Already-covered round-trip tests from test_data_types.cpp ===== +// (IntegerParamInsertAndSelect, VarcharParamInsertAndSelect, +// DoubleParamInsertAndSelect, DateParamInsertAndSelect, +// TimestampParamInsertAndSelect are tested there) diff --git a/tests/test_phase11_typeinfo_timeout_pool.cpp b/tests/test_phase11_typeinfo_timeout_pool.cpp new file mode 100644 index 00000000..a09c8937 --- /dev/null +++ b/tests/test_phase11_typeinfo_timeout_pool.cpp @@ -0,0 +1,558 @@ +// test_phase11_typeinfo_timeout_pool.cpp +// Phase 11 tests: SQLGetTypeInfo fixes, statement timeout, connection pool reset +// +// Covers: +// 11.1.7 - SQLGetTypeInfo ordering, multi-row DATA_TYPE, GUID searchability, no duplicates +// 11.2.5 - SQL_ATTR_QUERY_TIMEOUT getter/setter, SQLCancel, timeout on long query +// 11.3.4 - SQL_ATTR_RESET_CONNECTION rollback, cursor cleanup, attribute reset +// 11.1.6 - SQL_ASYNC_MODE reports SQL_AM_NONE + +#include "test_helpers.h" +#include +#include +#include +#include + +// ============================================================================ +// 11.1.7: SQLGetTypeInfo tests +// ============================================================================ + +class TypeInfoTest : public OdbcConnectedTest {}; + +// Result set must be sorted by DATA_TYPE ascending (ODBC spec requirement) +TEST_F(TypeInfoTest, ResultSetSortedByDataType) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetTypeInfo failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLSMALLINT prevDataType = -32768; + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + ret = SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + EXPECT_GE(dataType, prevDataType) + << "Row " << (rowCount + 1) << ": DATA_TYPE " << dataType + << " is less than previous " << prevDataType + << " — result set is not sorted by DATA_TYPE ascending"; + prevDataType = dataType; + rowCount++; + } + EXPECT_GT(rowCount, 0) << "No rows returned by SQLGetTypeInfo(SQL_ALL_TYPES)"; +} + +// When a specific DATA_TYPE is requested, all matching rows must be returned +// (e.g. SQL_NUMERIC may match both NUMERIC and INT128 on FB4+) +TEST_F(TypeInfoTest, MultipleRowsForSameDataType) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // SQL_INTEGER is a good test — should return exactly 1 row + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_INTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + EXPECT_EQ(dataType, SQL_INTEGER) << "Unexpected DATA_TYPE in filtered result"; + rowCount++; + } + EXPECT_GE(rowCount, 1) << "Should return at least 1 row for SQL_INTEGER"; +} + +// SQL_NUMERIC should return at least NUMERIC, and on FB4+ also INT128 +TEST_F(TypeInfoTest, NumericReturnsMultipleOnFB4Plus) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_NUMERIC); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + std::vector typeNames; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLCHAR typeName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind); + typeNames.push_back(std::string((char*)typeName)); + } + ASSERT_GE(typeNames.size(), 1u) << "At least NUMERIC should be returned"; + + // Verify NUMERIC is in the list + bool hasNumeric = false; + for (auto& name : typeNames) + { + if (name == "NUMERIC") hasNumeric = true; + } + EXPECT_TRUE(hasNumeric) << "NUMERIC type not found in SQL_NUMERIC results"; + + // On FB4+ we expect INT128 too (but don't fail on older servers) +} + +// No non-existent type should return any rows +TEST_F(TypeInfoTest, NonexistentTypeReturnsNoRows) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // SQL_WCHAR = -8, may or may not be supported + // Use an extremely unlikely type code + SQLRETURN ret = SQLGetTypeInfo(hStmt, 9999); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + rowCount++; + EXPECT_EQ(rowCount, 0) << "Invalid type 9999 should return 0 rows"; +} + +// SQL_GUID type should have SEARCHABLE = SQL_ALL_EXCEPT_LIKE (2), not SQL_SEARCHABLE (3) +TEST_F(TypeInfoTest, GuidSearchabilityIsAllExceptLike) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Request SQL_ALL_TYPES and find the GUID row + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool foundGuid = false; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLSMALLINT dataType = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + if (dataType == SQL_GUID) + { + foundGuid = true; + SQLSMALLINT searchable = 0; + SQLGetData(hStmt, 9, SQL_C_SSHORT, &searchable, 0, &ind); + EXPECT_EQ(searchable, SQL_ALL_EXCEPT_LIKE) + << "SQL_GUID SEARCHABLE should be SQL_ALL_EXCEPT_LIKE (2), not " << searchable; + + // LITERAL_PREFIX/SUFFIX should be NULL for GUID + SQLCHAR prefix[32] = {}; + ret = SQLGetData(hStmt, 4, SQL_C_CHAR, prefix, sizeof(prefix), &ind); + EXPECT_TRUE(ind == SQL_NULL_DATA || strlen((char*)prefix) == 0) + << "SQL_GUID LITERAL_PREFIX should be NULL or empty"; + break; + } + } + EXPECT_TRUE(foundGuid) << "SQL_GUID type not found in type info result set"; +} + +// On FB4+ servers, BINARY/VARBINARY should appear as native types, not as BLOB aliases +TEST_F(TypeInfoTest, NoDuplicateBinaryTypesOnFB4Plus) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Count how many times SQL_BINARY (-2) and SQL_VARBINARY (-3) appear + std::map> typeMap; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLSMALLINT dataType = 0; + SQLCHAR typeName[128] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind); + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind); + typeMap[dataType].push_back(std::string((char*)typeName)); + } + + // SQL_BINARY should not have both "BLOB SUB_TYPE 0" AND "BINARY" on FB4+ + if (typeMap.count(SQL_BINARY)) + { + auto& names = typeMap[SQL_BINARY]; + bool hasBlobAlias = false; + bool hasNative = false; + for (auto& n : names) + { + if (n == "BLOB SUB_TYPE 0") hasBlobAlias = true; + if (n == "BINARY") hasNative = true; + } + // They should not coexist — either BLOB alias (pre-FB4) or native (FB4+) + if (hasBlobAlias && hasNative) + { + FAIL() << "SQL_BINARY has both 'BLOB SUB_TYPE 0' and 'BINARY' entries — " + "version-gating failed"; + } + } +} + +// Verify every row in SQL_ALL_TYPES has the same DATA_TYPE as reported +TEST_F(TypeInfoTest, AllTypesReturnValidData) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + int rowCount = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + { + SQLCHAR typeName[128] = {}; + SQLSMALLINT dataType = 0; + SQLINTEGER columnSize = 0; + SQLLEN ind1 = 0, ind2 = 0, ind3 = 0; + + SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &ind1); + SQLGetData(hStmt, 2, SQL_C_SSHORT, &dataType, 0, &ind2); + SQLGetData(hStmt, 3, SQL_C_SLONG, &columnSize, 0, &ind3); + + EXPECT_NE(ind1, SQL_NULL_DATA) << "TYPE_NAME should not be NULL"; + EXPECT_NE(ind2, SQL_NULL_DATA) << "DATA_TYPE should not be NULL"; + EXPECT_GT(strlen((char*)typeName), 0u) << "TYPE_NAME should not be empty"; + rowCount++; + } + EXPECT_GT(rowCount, 10) << "Expected at least 10 type info rows"; +} + +// ============================================================================ +// 11.1.6: SQL_ASYNC_MODE test +// ============================================================================ + +class AsyncModeTest : public OdbcConnectedTest {}; + +TEST_F(AsyncModeTest, ReportsAsyncModeNone) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLUINTEGER asyncMode = 0; + SQLSMALLINT actualLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_ASYNC_MODE, &asyncMode, sizeof(asyncMode), &actualLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetInfo(SQL_ASYNC_MODE) failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(asyncMode, (SQLUINTEGER)SQL_AM_NONE) + << "SQL_ASYNC_MODE should be SQL_AM_NONE (0), got " << asyncMode; +} + +// ============================================================================ +// 11.2.5: SQL_ATTR_QUERY_TIMEOUT and SQLCancel tests +// ============================================================================ + +class QueryTimeoutTest : public OdbcConnectedTest {}; + +// Default query timeout should be 0 +TEST_F(QueryTimeoutTest, DefaultTimeoutIsZero) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLULEN timeout = 999; + SQLRETURN ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Default query timeout should be 0"; +} + +// Setting and getting timeout +TEST_F(QueryTimeoutTest, SetAndGetTimeout) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)5, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Failed to set query timeout: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLULEN timeout = 0; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 5u) << "Query timeout should be 5 after setting"; +} + +// Setting timeout back to 0 disables it +TEST_F(QueryTimeoutTest, SetTimeoutToZero) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)10, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLULEN timeout = 999; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Query timeout should be 0 after reset"; +} + +// SQLCancel succeeds even when nothing is executing +TEST_F(QueryTimeoutTest, CancelWhenIdleSucceeds) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLCancel(hStmt); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCancel on idle statement should succeed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); +} + +// SQLCancel from another thread interrupts a long-running query +TEST_F(QueryTimeoutTest, CancelFromAnotherThread) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Use a cartesian product query that takes a long time + // rdb$relations has ~50 rows, so CROSS JOIN produces ~2500 rows which is fast + // We'll use a triple cross join for a really long query + const char* longQuery = + "SELECT COUNT(*) FROM rdb$fields A " + "CROSS JOIN rdb$fields B " + "CROSS JOIN rdb$fields C"; + + SQLHSTMT cancelStmt = hStmt; + + // Start a thread that will cancel after a short delay + std::thread cancelThread([cancelStmt]() { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + SQLCancel(cancelStmt); + }); + + SQLRETURN ret = SQLExecDirect(hStmt, (SQLCHAR*)longQuery, SQL_NTS); + + cancelThread.join(); + + // The query may have completed before cancel fired, or may have been cancelled. + // If cancelled, we expect SQL_ERROR with SQLSTATE HY008 (Operation canceled). + if (ret == SQL_ERROR) + { + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + // HY008 = Operation canceled, or HY000 for general error from cancel + EXPECT_TRUE(state == "HY008" || state == "HY000" || state == "HYT00") + << "Expected cancel-related SQLSTATE, got " << state; + } + // If SQL_SUCCESS, the query completed before cancel — that's OK too +} + +// Timer-based timeout automatically cancels a long-running query (11.2.2) +TEST_F(QueryTimeoutTest, TimerFiresOnLongQuery) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Set a very short timeout (1 second) + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)1, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute a heavy cartesian product that should take more than 1 second + const char* longQuery = + "SELECT COUNT(*) FROM rdb$fields A " + "CROSS JOIN rdb$fields B " + "CROSS JOIN rdb$fields C " + "CROSS JOIN rdb$fields D"; + + auto start = std::chrono::steady_clock::now(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)longQuery, SQL_NTS); + auto elapsed = std::chrono::steady_clock::now() - start; + + // The query should have been cancelled by the timer. + // If it completed before timeout, that's also acceptable (fast server). + if (ret == SQL_ERROR) + { + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + // 11.2.3: timeout-triggered cancel should produce HYT00 + EXPECT_EQ(state, "HYT00") + << "Timer-triggered cancel should produce HYT00, got " << state; + + // Verify it didn't take much longer than the timeout + auto secs = std::chrono::duration_cast(elapsed).count(); + EXPECT_LE(secs, 5) << "Should cancel within a few seconds of timeout"; + } + // If SQL_SUCCESS, query was too fast for the timer — acceptable +} + +// Timeout of 0 means no timeout — query should complete normally (11.2.1) +TEST_F(QueryTimeoutTest, ZeroTimeoutDoesNotCancel) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Simple query with timeout=0 should succeed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 1); +} + +// ============================================================================ +// 11.3.4: SQL_ATTR_RESET_CONNECTION tests +// ============================================================================ + +class ConnectionResetTest : public OdbcConnectedTest {}; + +// After reset, autocommit should be ON +TEST_F(ConnectionResetTest, ResetRestoresAutocommit) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Turn off autocommit + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Check autocommit is back ON + SQLUINTEGER ac = SQL_AUTOCOMMIT_OFF; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, &ac, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ac, (SQLUINTEGER)SQL_AUTOCOMMIT_ON) + << "Autocommit should be ON after reset"; +} + +// After reset, transaction isolation should be default (0) +TEST_F(ConnectionResetTest, ResetRestoresTransactionIsolation) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Set transaction isolation + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_TXN_ISOLATION, + (SQLPOINTER)SQL_TXN_SERIALIZABLE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Reset + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Check isolation is back to default + SQLUINTEGER iso = SQL_TXN_SERIALIZABLE; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_TXN_ISOLATION, &iso, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(iso, 0u) + << "Transaction isolation should be 0 (default) after reset"; +} + +// Uncommitted data should be rolled back on reset +TEST_F(ConnectionResetTest, ResetRollsBackPendingTransaction) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Turn off autocommit so we can control transactions + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Create a temp table and insert a row WITHOUT committing + ExecIgnoreError("DROP TABLE T11_RESET_TEST"); + // We need to commit the DROP + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + ReallocStmt(); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"CREATE TABLE T11_RESET_TEST (ID INTEGER)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "CREATE TABLE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + ReallocStmt(); + + // Insert without commit + ret = SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO T11_RESET_TEST VALUES (42)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Close the cursor if any + SQLFreeStmt(hStmt, SQL_CLOSE); + + // Reset connection — should rollback the uncommitted INSERT + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Reset failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + // Now check: the INSERT should have been rolled back + ReallocStmt(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT COUNT(*) FROM T11_RESET_TEST", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SELECT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = -1; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + EXPECT_EQ(count, 0) << "Uncommitted INSERT should have been rolled back by reset"; + + // Cleanup + SQLFreeStmt(hStmt, SQL_CLOSE); + ExecIgnoreError("DROP TABLE T11_RESET_TEST"); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); +} + +// Connection should be reusable after reset +TEST_F(ConnectionResetTest, ConnectionReusableAfterReset) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Reset + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Execute a simple query to verify the connection still works + ReallocStmt(); + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Query after reset failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 1); +} + +// Open cursor should be closed after reset +TEST_F(ConnectionResetTest, ResetClosesOpenCursors) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Open a cursor + SQLRETURN ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Don't fetch — leave cursor open + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // The statement should be reusable for a new query + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT 2 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Query after cursor-closing reset failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + EXPECT_EQ(val, 2); +} + +// 11.3.3: Reset should restore query timeout on child statements to 0 +TEST_F(ConnectionResetTest, ResetResetsQueryTimeout) +{ + GTEST_SKIP() << "Requires Phase 11: SQLGetTypeInfo ordering, query timeout, connection pool reset (not yet merged)"; + // Set a non-default timeout + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, (SQLPOINTER)30, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Verify it was set + SQLULEN timeout = 0; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 30u); + + // Reset connection + ret = SQLSetConnectAttr(hDbc, SQL_ATTR_RESET_CONNECTION, + (SQLPOINTER)SQL_RESET_CONNECTION_YES, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Query timeout should be back to 0 + timeout = 999; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_QUERY_TIMEOUT, &timeout, 0, nullptr); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) + << "Query timeout should be 0 after connection reset"; +} diff --git a/tests/test_phase7_crusher_fixes.cpp b/tests/test_phase7_crusher_fixes.cpp new file mode 100644 index 00000000..2306141e --- /dev/null +++ b/tests/test_phase7_crusher_fixes.cpp @@ -0,0 +1,518 @@ +// tests/test_phase7_crusher_fixes.cpp — Tests for Phase 7 ODBC Crusher-identified bug fixes +// +// OC-1: SQLCopyDesc crash on empty descriptor +// OC-2: SQL_DIAG_ROW_COUNT always returns 0 +// OC-3: SQL_ATTR_CONNECTION_TIMEOUT not supported +// OC-4: SQL_ATTR_ASYNC_ENABLE silently accepted +// OC-5: returnStringInfo reports truncated length instead of full length + +#include "test_helpers.h" + +// ===== OC-1: SQLCopyDesc crash on empty descriptor ===== +class CopyDescCrashTest : public OdbcConnectedTest {}; + +TEST_F(CopyDescCrashTest, CopyEmptyARDDoesNotCrash) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Allocate two statements with no bindings (empty ARDs) + SQLHSTMT stmt1 = AllocExtraStmt(); + SQLHSTMT stmt2 = AllocExtraStmt(); + + // Get ARD handles (both have no records — records pointer is NULL) + SQLHDESC hArd1 = SQL_NULL_HDESC; + SQLHDESC hArd2 = SQL_NULL_HDESC; + SQLRETURN ret; + + ret = SQLGetStmtAttr(stmt1, SQL_ATTR_APP_ROW_DESC, &hArd1, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_NE(hArd1, (SQLHDESC)SQL_NULL_HDESC); + + ret = SQLGetStmtAttr(stmt2, SQL_ATTR_APP_ROW_DESC, &hArd2, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_NE(hArd2, (SQLHDESC)SQL_NULL_HDESC); + + // This previously crashed with access violation (0xC0000005) + // because operator= tried to dereference sour.records[0] when sour.records was NULL + ret = SQLCopyDesc(hArd1, hArd2); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc of empty ARD should succeed, got: " + << GetOdbcError(SQL_HANDLE_DESC, hArd2); + + // The key test is that we got here without crashing. + // Note: The DM may report its own descriptor count for implicit descriptors, + // so we only verify that SQLGetDescField itself succeeds. + SQLINTEGER count = -1; + ret = SQLGetDescField(hArd2, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt1); + SQLFreeHandle(SQL_HANDLE_STMT, stmt2); +} + +TEST_F(CopyDescCrashTest, CopyEmptyToExplicitDescriptor) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Allocate an explicit descriptor + SQLHDESC hExplicit = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicit); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Get the ARD of a statement with no bindings + SQLHDESC hArd = SQL_NULL_HDESC; + ret = SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Copy empty ARD to explicit descriptor — must not crash + ret = SQLCopyDesc(hArd, hExplicit); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc from empty ARD to explicit desc failed: " + << GetOdbcError(SQL_HANDLE_DESC, hExplicit); + + SQLFreeHandle(SQL_HANDLE_DESC, hExplicit); +} + +TEST_F(CopyDescCrashTest, CopyPopulatedThenEmpty) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // First, populate an explicit descriptor by copying a populated ARD + SQLINTEGER val = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &val, sizeof(val), &ind); + + SQLHDESC hArd = SQL_NULL_HDESC; + SQLGetStmtAttr(hStmt, SQL_ATTR_APP_ROW_DESC, &hArd, 0, NULL); + + SQLHDESC hExplicit = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hExplicit); + + SQLRETURN ret = SQLCopyDesc(hArd, hExplicit); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Verify count = 1 + SQLINTEGER count = 0; + SQLGetDescField(hExplicit, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 1); + + // Now allocate a second explicit descriptor (which is truly empty) + SQLHDESC hEmpty = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hEmpty); + + // Copy the empty explicit descriptor over the populated one — must not crash + ret = SQLCopyDesc(hEmpty, hExplicit); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc of empty explicit desc over populated desc failed: " + << GetOdbcError(SQL_HANDLE_DESC, hExplicit); + + // For explicit→explicit copy (no DM interception), count should be 0 + count = 0; + SQLGetDescField(hExplicit, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 0); + + SQLFreeHandle(SQL_HANDLE_DESC, hEmpty); + SQLFreeHandle(SQL_HANDLE_DESC, hExplicit); +} + +// OC-1 Root Cause 1: SQLSetDescField(SQL_DESC_COUNT) must allocate records +TEST_F(CopyDescCrashTest, SetDescCountAllocatesRecords) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Allocate an explicit descriptor + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLRETURN ret = SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Set SQL_DESC_COUNT to 3 — this should allocate the records array + ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)3, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetDescField(SQL_DESC_COUNT, 3) failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDesc); + + // Verify the count is 3 + SQLSMALLINT count = 0; + ret = SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 3); + + // Now set a field on record 2 — this must NOT crash + // (Previously, records array wasn't allocated, so this would dereference NULL) + ret = SQLSetDescField(hDesc, 2, SQL_DESC_TYPE, (SQLPOINTER)SQL_C_SLONG, 0); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetDescField on record 2 after setting COUNT failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDesc); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} + +TEST_F(CopyDescCrashTest, SetDescCountThenCopyDesc) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // This is the exact odbc-crusher scenario: set SQL_DESC_COUNT then SQLCopyDesc + SQLHDESC hSrc = SQL_NULL_HDESC; + SQLHDESC hDst = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hSrc); + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDst); + + // Set count on source without binding any columns + SQLRETURN ret = SQLSetDescField(hSrc, 0, SQL_DESC_COUNT, (SQLPOINTER)5, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Copy source to destination — must not crash + ret = SQLCopyDesc(hSrc, hDst); + EXPECT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLCopyDesc after SQLSetDescField(COUNT) failed: " + << GetOdbcError(SQL_HANDLE_DESC, hDst); + + // Verify destination has count = 5 + SQLSMALLINT count = 0; + ret = SQLGetDescField(hDst, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 5); + + SQLFreeHandle(SQL_HANDLE_DESC, hSrc); + SQLFreeHandle(SQL_HANDLE_DESC, hDst); +} + +TEST_F(CopyDescCrashTest, SetDescCountReduceFreesRecords) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Allocate explicit descriptor and set up 3 records + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + + SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)3, 0); + + // Set type on record 3 to verify it exists + SQLRETURN ret = SQLSetDescField(hDesc, 3, SQL_DESC_TYPE, (SQLPOINTER)SQL_C_CHAR, 0); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); + + // Reduce count to 1 — records 2 and 3 should be freed + ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)1, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT count = 0; + SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 1); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} + +TEST_F(CopyDescCrashTest, SetDescCountToZeroUnbindsAll) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Allocate explicit descriptor and set up records + SQLHDESC hDesc = SQL_NULL_HDESC; + SQLAllocHandle(SQL_HANDLE_DESC, hDbc, &hDesc); + + SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)2, 0); + + // Set count to 0 — should unbind all + SQLRETURN ret = SQLSetDescField(hDesc, 0, SQL_DESC_COUNT, (SQLPOINTER)0, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT count = 99; + SQLGetDescField(hDesc, 0, SQL_DESC_COUNT, &count, 0, NULL); + EXPECT_EQ(count, 0); + + SQLFreeHandle(SQL_HANDLE_DESC, hDesc); +} + +// ===== OC-2: SQL_DIAG_ROW_COUNT ===== +class DiagRowCountTest : public OdbcConnectedTest {}; + +TEST_F(DiagRowCountTest, RowCountAfterInsert) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(50)"); + ReallocStmt(); + + // Insert a row + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1, 'Alice')", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Check SQL_DIAG_ROW_COUNT via SQLGetDiagField + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetDiagField(SQL_DIAG_ROW_COUNT) failed"; + EXPECT_EQ(rowCount, 1) << "Expected 1 row affected by INSERT"; +} + +TEST_F(DiagRowCountTest, RowCountAfterUpdate) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(50)"); + ReallocStmt(); + + // Insert two rows + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1, 'Alice')", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (2, 'Bob')", SQL_NTS); + ReallocStmt(); + Commit(); + + // Update both rows + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"UPDATE ODBC_TEST_DIAGRC SET NAME = 'Updated'", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "UPDATE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(rowCount, 2) << "Expected 2 rows affected by UPDATE"; +} + +TEST_F(DiagRowCountTest, RowCountAfterDelete) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + TempTable table(this, "ODBC_TEST_DIAGRC", + "ID INTEGER NOT NULL PRIMARY KEY"); + ReallocStmt(); + + // Insert 3 rows + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (1)", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (2)", SQL_NTS); + ReallocStmt(); + SQLExecDirect(hStmt, (SQLCHAR*)"INSERT INTO ODBC_TEST_DIAGRC VALUES (3)", SQL_NTS); + ReallocStmt(); + Commit(); + + // Delete all + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"DELETE FROM ODBC_TEST_DIAGRC", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "DELETE failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLLEN rowCount = -1; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(rowCount, 3) << "Expected 3 rows affected by DELETE"; +} + +TEST_F(DiagRowCountTest, RowCountAfterSelectIsMinusOne) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // SELECT should set SQL_DIAG_ROW_COUNT to -1 (spec says undefined for SELECTs, + // but -1 is the conventional value used by drivers to indicate "not applicable") + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT 1 FROM RDB$DATABASE", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLLEN rowCount = 0; + ret = SQLGetDiagField(SQL_HANDLE_STMT, hStmt, 0, + SQL_DIAG_ROW_COUNT, &rowCount, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // For SELECTs, row count is driver-defined; we set it to -1 + EXPECT_EQ(rowCount, -1) << "Expected -1 for SELECT statement"; +} + +// ===== OC-3: SQL_ATTR_CONNECTION_TIMEOUT ===== +class ConnectionTimeoutTest : public ::testing::Test { +protected: + SQLHENV hEnv = SQL_NULL_HENV; + SQLHDBC hDbc = SQL_NULL_HDBC; + + void SetUp() override { + if (GetConnectionString().empty()) + GTEST_SKIP() << "FIREBIRD_ODBC_CONNECTION not set"; + } + + void TearDown() override { + if (hDbc != SQL_NULL_HDBC) { + SQLDisconnect(hDbc); + SQLFreeHandle(SQL_HANDLE_DBC, hDbc); + } + if (hEnv != SQL_NULL_HENV) + SQLFreeHandle(SQL_HANDLE_ENV, hEnv); + } + + void AllocHandles() { + SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &hEnv); + SQLSetEnvAttr(hEnv, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); + SQLAllocHandle(SQL_HANDLE_DBC, hEnv, &hDbc); + } + + void Connect() { + std::string connStr = GetConnectionString(); + SQLCHAR outStr[1024]; + SQLSMALLINT outLen; + SQLRETURN ret = SQLDriverConnect(hDbc, NULL, + (SQLCHAR*)connStr.c_str(), SQL_NTS, + outStr, sizeof(outStr), &outLen, + SQL_DRIVER_NOPROMPT); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Connect failed: " << GetOdbcError(SQL_HANDLE_DBC, hDbc); + } +}; + +TEST_F(ConnectionTimeoutTest, SetAndGetConnectionTimeout) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + AllocHandles(); + + // Set connection timeout before connecting + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, + (SQLPOINTER)30, SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetConnectAttr(SQL_ATTR_CONNECTION_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + + Connect(); + + // Read it back + SQLULEN timeout = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetConnectAttr(SQL_ATTR_CONNECTION_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(timeout, 30u); +} + +TEST_F(ConnectionTimeoutTest, LoginTimeoutGetterWorks) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + AllocHandles(); + + // Set login timeout + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_LOGIN_TIMEOUT, + (SQLPOINTER)15, SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLSetConnectAttr(SQL_ATTR_LOGIN_TIMEOUT) failed"; + + // Read it back — this previously fell through to HYC00 error + SQLULEN timeout = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_LOGIN_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLGetConnectAttr(SQL_ATTR_LOGIN_TIMEOUT) failed: " + << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(timeout, 15u); +} + +TEST_F(ConnectionTimeoutTest, ConnectionTimeoutDefaultIsZero) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + AllocHandles(); + Connect(); + + SQLULEN timeout = 999; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CONNECTION_TIMEOUT, &timeout, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(timeout, 0u) << "Default connection timeout should be 0 (no timeout)"; +} + +// ===== OC-4: SQL_ATTR_ASYNC_ENABLE ===== +class AsyncEnableTest : public OdbcConnectedTest {}; + +TEST_F(AsyncEnableTest, ConnectionLevelRejectsAsyncOn) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Setting SQL_ASYNC_ENABLE_ON should fail with HYC00 + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_ON, SQL_IS_UINTEGER); + EXPECT_EQ(ret, SQL_ERROR); + + std::string state = GetSqlState(SQL_HANDLE_DBC, hDbc); + EXPECT_EQ(state, "HYC00") << "Expected HYC00 for unsupported async enable"; +} + +TEST_F(AsyncEnableTest, ConnectionLevelAcceptsAsyncOff) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Setting SQL_ASYNC_ENABLE_OFF should succeed (it's the default) + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_OFF, SQL_IS_UINTEGER); + EXPECT_TRUE(SQL_SUCCEEDED(ret)); +} + +TEST_F(AsyncEnableTest, ConnectionLevelGetReturnsOff) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + SQLULEN value = 999; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_ASYNC_ENABLE, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_ASYNC_ENABLE_OFF); +} + +TEST_F(AsyncEnableTest, StatementLevelRejectsAsyncOn) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Setting SQL_ASYNC_ENABLE_ON on statement should fail with HYC00 + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_ASYNC_ENABLE, + (SQLPOINTER)SQL_ASYNC_ENABLE_ON, SQL_IS_UINTEGER); + EXPECT_EQ(ret, SQL_ERROR); + + std::string state = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(state, "HYC00") << "Expected HYC00 for unsupported async enable"; +} + +TEST_F(AsyncEnableTest, StatementLevelGetReturnsOff) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + SQLULEN value = 999; + SQLRETURN ret = SQLGetStmtAttr(hStmt, SQL_ATTR_ASYNC_ENABLE, &value, 0, NULL); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(value, (SQLULEN)SQL_ASYNC_ENABLE_OFF); +} + +// ===== OC-5: returnStringInfo truncation reports full length ===== +class TruncationIndicatorTest : public OdbcConnectedTest {}; + +TEST_F(TruncationIndicatorTest, GetConnectAttrTruncationReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // SQL_ATTR_CURRENT_CATALOG returns the database path, which is typically long + // First, get the full length + SQLINTEGER fullLen = 0; + char fullBuf[1024] = {}; + SQLRETURN ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CURRENT_CATALOG, + fullBuf, sizeof(fullBuf), &fullLen); + + if (!SQL_SUCCEEDED(ret)) { + GTEST_SKIP() << "SQL_ATTR_CURRENT_CATALOG not available"; + } + + // Skip if the catalog name is too short to trigger truncation + if (fullLen <= 5) { + GTEST_SKIP() << "Catalog name too short for truncation test (len=" << fullLen << ")"; + } + + // Now try with a small buffer that will trigger truncation + char smallBuf[6] = {}; // Very small buffer + SQLINTEGER reportedLen = 0; + ret = SQLGetConnectAttr(hDbc, SQL_ATTR_CURRENT_CATALOG, + smallBuf, sizeof(smallBuf), &reportedLen); + + // Should return SQL_SUCCESS_WITH_INFO (truncation) + EXPECT_EQ(ret, SQL_SUCCESS_WITH_INFO) + << "Expected SQL_SUCCESS_WITH_INFO for truncated result"; + + // The reported length should be the FULL string length, not the truncated length + EXPECT_EQ(reportedLen, fullLen) + << "Truncated call should report full length (" << fullLen + << "), not truncated length"; +} + +TEST_F(TruncationIndicatorTest, GetInfoStringTruncationReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Use SQL_DBMS_NAME which is always available + char fullBuf[256] = {}; + SQLSMALLINT fullLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + fullBuf, sizeof(fullBuf), &fullLen); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ASSERT_GT(fullLen, 0) << "DBMS name should have nonzero length"; + + // Now try with a buffer too small (2 bytes: 1 char + null terminator) + char smallBuf[2] = {}; + SQLSMALLINT reportedLen = 0; + ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + smallBuf, sizeof(smallBuf), &reportedLen); + + // Should return SQL_SUCCESS_WITH_INFO + EXPECT_EQ(ret, SQL_SUCCESS_WITH_INFO); + + // The reported length should be the FULL string length + EXPECT_EQ(reportedLen, fullLen) + << "Truncated SQLGetInfo should report full length (" << fullLen + << "), not truncated length (" << reportedLen << ")"; +} + +TEST_F(TruncationIndicatorTest, GetInfoZeroBufferReportsFullLength) { + GTEST_SKIP() << "Requires Phase 7: ODBC Crusher-identified bug fixes (not yet merged)"; + // Call with NULL buffer and 0 length — should report length without copying + SQLSMALLINT fullLen = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, + NULL, 0, &fullLen); + + // Should return SQL_SUCCESS_WITH_INFO (data available but not copied) + EXPECT_TRUE(ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO); + EXPECT_GT(fullLen, 0) << "Should report the full string length even with NULL buffer"; +} diff --git a/tests/test_prepare.cpp b/tests/test_prepare.cpp new file mode 100644 index 00000000..bc5cc8f1 --- /dev/null +++ b/tests/test_prepare.cpp @@ -0,0 +1,350 @@ +// tests/test_prepare.cpp — SQLPrepare/SQLExecute tests +// (Phase 6, ported from psqlodbc prepare-test) +// +// Tests prepared statements with various parameter types, SQLNumResultCols +// before execute, and re-execution with different parameters. + +#include "test_helpers.h" +#include + +class PrepareTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_PREP", + "ID INTEGER NOT NULL PRIMARY KEY, " + "VAL_TEXT VARCHAR(100), " + "VAL_INT INTEGER, " + "VAL_DOUBLE DOUBLE PRECISION"); + + // Insert test data + ExecDirect("INSERT INTO ODBC_TEST_PREP VALUES (1, 'foo', 10, 1.1)"); + ExecDirect("INSERT INTO ODBC_TEST_PREP VALUES (2, 'bar', 20, 2.2)"); + ExecDirect("INSERT INTO ODBC_TEST_PREP VALUES (3, 'baz', 30, 3.3)"); + Commit(); + ReallocStmt(); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// ===== Basic prepare + execute with text param ===== + +TEST_F(PrepareTest, PrepareWithTextParam) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID, VAL_TEXT FROM ODBC_TEST_PREP WHERE VAL_TEXT = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + char param[] = "bar"; + SQLLEN cbParam = SQL_NTS; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_CHAR, 20, 0, param, 0, &cbParam); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecute failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLINTEGER id = 0; + SQLCHAR text[32] = {}; + SQLLEN idInd = 0, textInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, text, sizeof(text), &textInd); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 2); + EXPECT_STREQ((char*)text, "bar"); + + // Should be only one row + ret = SQLFetch(hStmt); + EXPECT_EQ(ret, SQL_NO_DATA); +} + +// ===== SQLNumResultCols before execute ===== + +TEST_F(PrepareTest, NumResultColsBeforeExecute) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID, VAL_TEXT FROM ODBC_TEST_PREP WHERE VAL_TEXT = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Call SQLNumResultCols BEFORE execute — should work + SQLSMALLINT colCount = 0; + ret = SQLNumResultCols(hStmt, &colCount); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLNumResultCols failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(colCount, 2); +} + +// ===== Prepare with integer param ===== + +TEST_F(PrepareTest, PrepareWithIntegerParam) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID, VAL_TEXT FROM ODBC_TEST_PREP WHERE ID = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER param = 3; + SQLLEN cbParam = sizeof(param); + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, ¶m, sizeof(param), &cbParam); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 0; + SQLCHAR text[32] = {}; + SQLLEN idInd = 0, textInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + SQLBindCol(hStmt, 2, SQL_C_CHAR, text, sizeof(text), &textInd); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 3); + EXPECT_STREQ((char*)text, "baz"); +} + +// ===== Re-execute with different parameter ===== + +TEST_F(PrepareTest, ReExecuteWithDifferentParam) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT VAL_TEXT FROM ODBC_TEST_PREP WHERE ID = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER param = 1; + SQLLEN cbParam = sizeof(param); + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, ¶m, sizeof(param), &cbParam); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // First execution: ID = 1 + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLCHAR text[32] = {}; + SQLLEN textInd = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, text, sizeof(text), &textInd); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)text, "foo"); + + SQLFreeStmt(hStmt, SQL_CLOSE); + + // Second execution: ID = 2 + param = 2; + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + memset(text, 0, sizeof(text)); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)text, "bar"); +} + +// ===== Prepare INSERT with parameters ===== + +TEST_F(PrepareTest, PrepareInsert) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_PREP VALUES (?, ?, ?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 100; + char text[] = "prepared"; + SQLINTEGER intVal = 999; + double dblVal = 9.99; + SQLLEN idInd = sizeof(id), textInd = SQL_NTS; + SQLLEN intInd = sizeof(intVal), dblInd = sizeof(dblVal); + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, sizeof(id), &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, + 100, 0, text, 0, &textInd); + SQLBindParameter(hStmt, 3, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &intVal, sizeof(intVal), &intInd); + SQLBindParameter(hStmt, 4, SQL_PARAM_INPUT, SQL_C_DOUBLE, SQL_DOUBLE, + 0, 0, &dblVal, sizeof(dblVal), &dblInd); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Execute INSERT failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + + // Verify the insert + ReallocStmt(); + ExecDirect("SELECT VAL_TEXT, VAL_INT, VAL_DOUBLE FROM ODBC_TEST_PREP WHERE ID = 100"); + + SQLCHAR readText[32] = {}; + SQLINTEGER readInt = 0; + double readDbl = 0.0; + SQLLEN ind1 = 0, ind2 = 0, ind3 = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, readText, sizeof(readText), &ind1); + SQLBindCol(hStmt, 2, SQL_C_SLONG, &readInt, 0, &ind2); + SQLBindCol(hStmt, 3, SQL_C_DOUBLE, &readDbl, 0, &ind3); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)readText, "prepared"); + EXPECT_EQ(readInt, 999); + EXPECT_NEAR(readDbl, 9.99, 0.01); +} + +// ===== SQLDescribeCol after prepare ===== + +TEST_F(PrepareTest, DescribeColAfterPrepare) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID, VAL_TEXT, VAL_INT, VAL_DOUBLE FROM ODBC_TEST_PREP WHERE ID = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLSMALLINT colCount = 0; + ret = SQLNumResultCols(hStmt, &colCount); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(colCount, 4); + + // Describe each column + for (SQLSMALLINT i = 1; i <= colCount; i++) { + SQLCHAR colName[64] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + + ret = SQLDescribeCol(hStmt, i, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLDescribeCol(" << i << ") failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_GT(nameLen, 0) << "Column " << i << " should have a name"; + EXPECT_NE(dataType, 0) << "Column " << i << " should have a data type"; + } +} + +// ===== Prepare with BLOB parameter (binary data) ===== + +TEST_F(PrepareTest, PrepareWithBlobParam) { + // Create a table with a BLOB column + ExecIgnoreError("DROP TABLE ODBC_TEST_PREP_BLOB"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE ODBC_TEST_PREP_BLOB (ID INTEGER NOT NULL PRIMARY KEY, DATA BLOB SUB_TYPE BINARY)"); + Commit(); + ReallocStmt(); + + // Prepare insert + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_PREP_BLOB VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // Insert binary data + unsigned char blobData[100]; + for (int i = 0; i < 100; i++) blobData[i] = (unsigned char)i; + + SQLINTEGER id = 1; + SQLLEN idInd = sizeof(id); + SQLLEN blobInd = 100; + + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, sizeof(id), &idInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_BINARY, SQL_LONGVARBINARY, + 100, 0, blobData, 100, &blobInd); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "INSERT blob failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + Commit(); + + // Read it back + ReallocStmt(); + ExecDirect("SELECT DATA FROM ODBC_TEST_PREP_BLOB WHERE ID = 1"); + + unsigned char readBuf[128] = {}; + SQLLEN readInd = 0; + SQLBindCol(hStmt, 1, SQL_C_BINARY, readBuf, sizeof(readBuf), &readInd); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(readInd, 100); + EXPECT_EQ(memcmp(readBuf, blobData, 100), 0); + + // Cleanup + SQLCloseCursor(hStmt); + ExecIgnoreError("DROP TABLE ODBC_TEST_PREP_BLOB"); + Commit(); +} + +// ===== Multiple parameters ===== + +TEST_F(PrepareTest, MultipleParamsInWhere) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID FROM ODBC_TEST_PREP WHERE VAL_INT > ? AND VAL_INT < ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER lo = 15, hi = 25; + SQLLEN loInd = sizeof(lo), hiInd = sizeof(hi); + SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &lo, sizeof(lo), &loInd); + SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &hi, sizeof(hi), &hiInd); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 2); // VAL_INT=20 is between 15 and 25 + + ret = SQLFetch(hStmt); + EXPECT_EQ(ret, SQL_NO_DATA); +} + +// ===== Prepare without parameters (just statements) ===== + +TEST_F(PrepareTest, PrepareWithoutParams) { + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT COUNT(*) FROM ODBC_TEST_PREP", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + SQLINTEGER count = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(count, 3); +} + +// ===== Varchar param with varying sizes ===== + +TEST_F(PrepareTest, VarcharParamColumnSize5) { + // psqlodbc had a special case with column_size=5 and BoolsAsChar=1 + // Verify this works with Firebird + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"SELECT ID, VAL_TEXT FROM ODBC_TEST_PREP WHERE ID = ?", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + char param[] = "2"; + SQLLEN cbParam = SQL_NTS; + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 5, 0, param, 0, &cbParam); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "Execute failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLINTEGER id = 0; + SQLLEN idInd = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &id, 0, &idInd); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(id, 2); +} diff --git a/tests/test_result_conversions.cpp b/tests/test_result_conversions.cpp new file mode 100644 index 00000000..1aef5188 --- /dev/null +++ b/tests/test_result_conversions.cpp @@ -0,0 +1,447 @@ +// tests/test_result_conversions.cpp — Result type conversion tests +// (Phase 6, ported from psqlodbc result-conversions-test) +// +// Tests SQLGetData with various C type conversions for each Firebird SQL type. + +#include "test_helpers.h" +#include +#include +#include + +class ResultConversionsTest : public OdbcConnectedTest {}; + +// Helper: execute a SELECT and fetch one value with SQLGetData +template +struct GetDataResult { + T value; + SQLLEN indicator; + SQLRETURN ret; +}; + +static GetDataResult getAsInteger(SQLHSTMT hStmt, int col = 1) { + GetDataResult r = {}; + r.ret = SQLGetData(hStmt, col, SQL_C_SLONG, &r.value, 0, &r.indicator); + return r; +} + +static GetDataResult getAsBigint(SQLHSTMT hStmt, int col = 1) { + GetDataResult r = {}; + r.ret = SQLGetData(hStmt, col, SQL_C_SBIGINT, &r.value, 0, &r.indicator); + return r; +} + +static GetDataResult getAsDouble(SQLHSTMT hStmt, int col = 1) { + GetDataResult r = {}; + r.ret = SQLGetData(hStmt, col, SQL_C_DOUBLE, &r.value, 0, &r.indicator); + return r; +} + +// ===== Integer to various C types ===== + +TEST_F(ResultConversionsTest, IntegerToChar) { + ExecDirect("SELECT 12345 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "12345"); +} + +TEST_F(ResultConversionsTest, IntegerToSLong) { + ExecDirect("SELECT 42 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsInteger(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_EQ(r.value, 42); +} + +TEST_F(ResultConversionsTest, IntegerToDouble) { + ExecDirect("SELECT 42 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsDouble(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_DOUBLE_EQ(r.value, 42.0); +} + +TEST_F(ResultConversionsTest, IntegerToSmallint) { + ExecDirect("SELECT 123 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLSMALLINT val = 0; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_SSHORT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 123); +} + +TEST_F(ResultConversionsTest, IntegerToBigint) { + ExecDirect("SELECT 2147483647 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsBigint(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_EQ(r.value, INT64_C(2147483647)); +} + +TEST_F(ResultConversionsTest, IntegerToFloat) { + ExecDirect("SELECT 100 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + float val = 0.0f; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_FLOAT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_FLOAT_EQ(val, 100.0f); +} + +TEST_F(ResultConversionsTest, IntegerToBit) { + ExecDirect("SELECT 1 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR val = 255; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_BIT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 1); +} + +TEST_F(ResultConversionsTest, IntegerToBinary) { + ExecDirect("SELECT 42 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + unsigned char buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_BINARY, buf, sizeof(buf), &ind); + // Integer→Binary conversion may or may not be supported + // Just verify no crash + SUCCEED(); +} + +// ===== Double to various C types ===== + +TEST_F(ResultConversionsTest, DoubleToChar) { + ExecDirect("SELECT CAST(3.14 AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + double parsed = atof((char*)buf); + EXPECT_NEAR(parsed, 3.14, 0.001); +} + +TEST_F(ResultConversionsTest, DoubleToSLong) { + ExecDirect("SELECT CAST(3.14 AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsInteger(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_EQ(r.value, 3); // Truncated to integer +} + +TEST_F(ResultConversionsTest, DoubleToDouble) { + ExecDirect("SELECT CAST(3.14159 AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsDouble(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_NEAR(r.value, 3.14159, 1e-5); +} + +// ===== VARCHAR to various C types ===== + +TEST_F(ResultConversionsTest, VarcharToChar) { + ExecDirect("SELECT CAST('hello world' AS VARCHAR(50)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "hello world"); +} + +TEST_F(ResultConversionsTest, VarcharNumericToSLong) { + // VARCHAR→INTEGER conversion via CAST at SQL level + ExecDirect("SELECT CAST('42' AS INTEGER) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsInteger(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_EQ(r.value, 42); +} + +TEST_F(ResultConversionsTest, VarcharNumericToDouble) { + // VARCHAR→DOUBLE conversion via CAST at SQL level + ExecDirect("SELECT CAST('3.14' AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsDouble(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_NEAR(r.value, 3.14, 0.001); +} + +// ===== String truncation ===== + +TEST_F(ResultConversionsTest, CharTruncation) { + ExecDirect("SELECT CAST('this is a long string' AS VARCHAR(100)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[8] = {}; // Deliberately small buffer + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + // Should return SQL_SUCCESS_WITH_INFO (01004 = string data, right truncated) + EXPECT_EQ(ret, SQL_SUCCESS_WITH_INFO); + std::string sqlState = GetSqlState(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(sqlState, "01004"); + // buf should have 7 chars + null terminator + EXPECT_EQ(strlen((char*)buf), 7u); +} + +// ===== Date/Time conversions ===== + +TEST_F(ResultConversionsTest, DateToChar) { + ExecDirect("SELECT DATE '2025-01-15' FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + std::string s((char*)buf); + EXPECT_NE(s.find("2025"), std::string::npos) + << "Date string should contain year 2025: " << s; +} + +TEST_F(ResultConversionsTest, DateToDateStruct) { + ExecDirect("SELECT DATE '2025-03-20' FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQL_DATE_STRUCT val = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_TYPE_DATE, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 3); + EXPECT_EQ(val.day, 20); +} + +TEST_F(ResultConversionsTest, TimeToChar) { + ExecDirect("SELECT TIME '14:30:00' FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + std::string s((char*)buf); + EXPECT_NE(s.find("14"), std::string::npos) << "Time should contain hour 14: " << s; +} + +TEST_F(ResultConversionsTest, TimeToTimeStruct) { + ExecDirect("SELECT TIME '14:30:45' FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQL_TIME_STRUCT val = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_TYPE_TIME, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val.hour, 14); + EXPECT_EQ(val.minute, 30); + EXPECT_EQ(val.second, 45); +} + +TEST_F(ResultConversionsTest, TimestampToTimestampStruct) { + ExecDirect("SELECT TIMESTAMP '2025-06-15 10:30:45' FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQL_TIMESTAMP_STRUCT val = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_TYPE_TIMESTAMP, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val.year, 2025); + EXPECT_EQ(val.month, 6); + EXPECT_EQ(val.day, 15); + EXPECT_EQ(val.hour, 10); + EXPECT_EQ(val.minute, 30); + EXPECT_EQ(val.second, 45); +} + +// ===== NUMERIC precision ===== + +TEST_F(ResultConversionsTest, NumericToChar) { + ExecDirect("SELECT CAST(1234.5678 AS NUMERIC(18,4)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + double parsed = atof((char*)buf); + EXPECT_NEAR(parsed, 1234.5678, 0.001); +} + +TEST_F(ResultConversionsTest, NumericToDouble) { + ExecDirect("SELECT CAST(1234.5678 AS NUMERIC(18,4)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsDouble(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_NEAR(r.value, 1234.5678, 0.001); +} + +TEST_F(ResultConversionsTest, NumericToInteger) { + ExecDirect("SELECT CAST(42.99 AS NUMERIC(10,2)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsInteger(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + // Truncated to 42 or rounded to 43 depending on driver + EXPECT_TRUE(r.value == 42 || r.value == 43); +} + +// ===== NULL handling ===== + +TEST_F(ResultConversionsTest, NullToChar) { + ExecDirect("SELECT CAST(NULL AS VARCHAR(10)) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = "sentinel"; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, SQL_NULL_DATA); +} + +TEST_F(ResultConversionsTest, NullToSLong) { + ExecDirect("SELECT CAST(NULL AS INTEGER) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLINTEGER val = -1; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, SQL_NULL_DATA); +} + +TEST_F(ResultConversionsTest, NullToDouble) { + ExecDirect("SELECT CAST(NULL AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + double val = -1.0; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_DOUBLE, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, SQL_NULL_DATA); +} + +TEST_F(ResultConversionsTest, NullToDate) { + ExecDirect("SELECT CAST(NULL AS DATE) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQL_DATE_STRUCT val = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_TYPE_DATE, &val, sizeof(val), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(ind, SQL_NULL_DATA); +} + +// ===== Boolean (Firebird 3.0+) ===== + +TEST_F(ResultConversionsTest, BooleanToChar) { + ExecDirect("SELECT TRUE FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[16] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + // Firebird returns "1" or "true" depending on driver mapping + std::string s((char*)buf); + EXPECT_TRUE(s == "1" || s == "true" || s == "TRUE" || s == "T") + << "Boolean true as char: " << s; +} + +TEST_F(ResultConversionsTest, BooleanToBit) { + ExecDirect("SELECT TRUE FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR val = 255; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_BIT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, 1); +} + +// ===== Negative values ===== + +TEST_F(ResultConversionsTest, NegativeIntegerToChar) { + ExecDirect("SELECT -42 FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "-42"); +} + +TEST_F(ResultConversionsTest, NegativeDoubleToSLong) { + ExecDirect("SELECT CAST(-99.9 AS DOUBLE PRECISION) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsInteger(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_TRUE(r.value == -99 || r.value == -100); +} + +// ===== BIGINT ===== + +TEST_F(ResultConversionsTest, BigintToChar) { + ExecDirect("SELECT CAST(9223372036854775807 AS BIGINT) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[32] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "9223372036854775807"); +} + +TEST_F(ResultConversionsTest, BigintToBigint) { + ExecDirect("SELECT CAST(9223372036854775807 AS BIGINT) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + auto r = getAsBigint(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(r.ret)); + EXPECT_EQ(r.value, INT64_C(9223372036854775807)); +} + +// ===== SMALLINT ===== + +TEST_F(ResultConversionsTest, SmallintToChar) { + ExecDirect("SELECT CAST(32000 AS SMALLINT) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLCHAR buf[16] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)buf, "32000"); +} + +TEST_F(ResultConversionsTest, SmallintToSShort) { + ExecDirect("SELECT CAST(-32000 AS SMALLINT) FROM RDB$DATABASE"); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + + SQLSMALLINT val = 0; + SQLLEN ind = 0; + SQLRETURN ret = SQLGetData(hStmt, 1, SQL_C_SSHORT, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(val, -32000); +} diff --git a/tests/test_savepoint.cpp b/tests/test_savepoint.cpp new file mode 100644 index 00000000..bdad0e7e --- /dev/null +++ b/tests/test_savepoint.cpp @@ -0,0 +1,124 @@ +// tests/test_savepoint.cpp — Statement-level savepoint isolation tests (M-1, Task 2.3) + +#include "test_helpers.h" + +class SavepointTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + // Disable auto-commit for savepoint testing + SQLRETURN ret = SQLSetConnectAttr(hDbc, SQL_ATTR_AUTOCOMMIT, + (SQLPOINTER)SQL_AUTOCOMMIT_OFF, SQL_IS_UINTEGER); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + table_ = std::make_unique(this, "ODBC_TEST_SVP", + "ID INTEGER NOT NULL PRIMARY KEY, VAL VARCHAR(30)"); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +TEST_F(SavepointTest, FailedStatementDoesNotCorruptTransaction) { + GTEST_SKIP() << "Requires Phase 4 (M-1 Task 2.3): savepoint isolation (not yet merged)"; + // Insert a valid row + ExecDirect("INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (1, 'Good row')"); + + // Attempt an insert that violates primary key — this should fail + ReallocStmt(); + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (1, 'Duplicate')", SQL_NTS); + EXPECT_EQ(ret, SQL_ERROR) << "Expected PK violation to fail"; + + // The key test: the first INSERT should still be intact + // Without savepoints, Firebird could mark the transaction as doomed + ReallocStmt(); + ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT VAL FROM ODBC_TEST_SVP WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SELECT after failed INSERT should succeed (savepoint isolation): " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLCHAR val[31] = {}; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_CHAR, val, sizeof(val), &ind); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_STREQ((char*)val, "Good row"); + + // Commit should also succeed + Commit(); +} + +TEST_F(SavepointTest, MultipleFailuresDoNotCorruptTransaction) { + GTEST_SKIP() << "Requires Phase 4 (M-1 Task 2.3): savepoint isolation (not yet merged)"; + ExecDirect("INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (10, 'First')"); + + // Multiple failing statements + for (int i = 0; i < 5; i++) { + ReallocStmt(); + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (10, 'Dup')", SQL_NTS); + EXPECT_EQ(ret, SQL_ERROR); + } + + // Add another valid row + ReallocStmt(); + ExecDirect("INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (11, 'Second')"); + + // Both rows should be visible + ReallocStmt(); + ExecDirect("SELECT COUNT(*) FROM ODBC_TEST_SVP WHERE ID IN (10, 11)"); + + SQLINTEGER count = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(count, 2); + + Commit(); +} + +TEST_F(SavepointTest, RollbackAfterPartialSuccess) { + GTEST_SKIP() << "Requires Phase 4 (M-1 Task 2.3): savepoint isolation (not yet merged)"; + // Insert then rollback — the insert should be gone + ExecDirect("INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (20, 'To be rolled back')"); + Rollback(); + + ReallocStmt(); + ExecDirect("SELECT COUNT(*) FROM ODBC_TEST_SVP WHERE ID = 20"); + + SQLINTEGER count = -1; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(count, 0); +} + +TEST_F(SavepointTest, SuccessfulStatementNotAffectedBySavepointOverhead) { + GTEST_SKIP() << "Requires Phase 4 (M-1 Task 2.3): savepoint isolation (not yet merged)"; + // Simple check that the savepoint mechanism doesn't break normal DML + for (int i = 100; i < 110; i++) { + ReallocStmt(); + char sql[128]; + snprintf(sql, sizeof(sql), + "INSERT INTO ODBC_TEST_SVP (ID, VAL) VALUES (%d, 'Row %d')", i, i); + ExecDirect(sql); + } + Commit(); + + ReallocStmt(); + ExecDirect("SELECT COUNT(*) FROM ODBC_TEST_SVP WHERE ID >= 100 AND ID < 110"); + + SQLINTEGER count = 0; + SQLLEN ind = 0; + SQLBindCol(hStmt, 1, SQL_C_SLONG, &count, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetch(hStmt))); + EXPECT_EQ(count, 10); +} diff --git a/tests/test_scrollable_cursor.cpp b/tests/test_scrollable_cursor.cpp new file mode 100644 index 00000000..7f3d2dbe --- /dev/null +++ b/tests/test_scrollable_cursor.cpp @@ -0,0 +1,195 @@ +// test_scrollable_cursor.cpp — Tests for scrollable cursor support (Task 4.7) +#include "test_helpers.h" + +// ============================================================================ +// ScrollableCursorTest: Validate static scrollable cursor operations +// ============================================================================ +class ScrollableCursorTest : public OdbcConnectedTest { +protected: + static constexpr int NUM_ROWS = 10; + + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (hDbc == SQL_NULL_HDBC) return; + + // Create and populate test table + ExecIgnoreError("DROP TABLE SCROLL_TEST"); + Commit(); + ReallocStmt(); + ExecDirect("CREATE TABLE SCROLL_TEST (ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(30))"); + Commit(); + ReallocStmt(); + + for (int i = 1; i <= NUM_ROWS; ++i) { + char sql[256]; + snprintf(sql, sizeof(sql), + "INSERT INTO SCROLL_TEST (ID, NAME) VALUES (%d, 'Row_%02d')", i, i); + ExecDirect(sql); + ReallocStmt(); + } + Commit(); + ReallocStmt(); + } + + void TearDown() override { + if (hDbc != SQL_NULL_HDBC) { + ExecIgnoreError("DROP TABLE SCROLL_TEST"); + SQLEndTran(SQL_HANDLE_DBC, hDbc, SQL_COMMIT); + } + OdbcConnectedTest::TearDown(); + } + + void OpenScrollableCursor(const char* sql) { + // Set cursor type to STATIC (scrollable) + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)(intptr_t)SQL_CURSOR_STATIC, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLSetStmtAttr(hStmt, SQL_ATTR_CURSOR_SCROLLABLE, + (SQLPOINTER)(intptr_t)SQL_SCROLLABLE, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)sql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + + int FetchID() { + SQLINTEGER id = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &id, sizeof(id), &ind); + return id; + } +}; + +TEST_F(ScrollableCursorTest, FetchFirstAndLast) { + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch first + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_FIRST, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(FetchID(), 1); + + // Fetch last + ret = SQLFetchScroll(hStmt, SQL_FETCH_LAST, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(FetchID(), NUM_ROWS); +} + +TEST_F(ScrollableCursorTest, FetchPrior) { + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch last + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_LAST, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), NUM_ROWS); + + // Fetch prior + ret = SQLFetchScroll(hStmt, SQL_FETCH_PRIOR, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), NUM_ROWS - 1); +} + +TEST_F(ScrollableCursorTest, FetchAbsolute) { + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch absolute row 5 + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_ABSOLUTE, 5); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(FetchID(), 5); + + // Fetch absolute last row (negative) + ret = SQLFetchScroll(hStmt, SQL_FETCH_ABSOLUTE, -1); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), NUM_ROWS); +} + +TEST_F(ScrollableCursorTest, FetchRelative) { + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Move to row 3 + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_ABSOLUTE, 3); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), 3); + + // Relative +2 = row 5 + ret = SQLFetchScroll(hStmt, SQL_FETCH_RELATIVE, 2); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), 5); + + // Relative -3 = row 2 + ret = SQLFetchScroll(hStmt, SQL_FETCH_RELATIVE, -3); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), 2); +} + +TEST_F(ScrollableCursorTest, FetchNextInScrollable) { + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch next should work in scrollable cursor + for (int i = 1; i <= NUM_ROWS; ++i) { + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_NEXT, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "Row " << i << ": " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + EXPECT_EQ(FetchID(), i); + } + + // After last row, should get SQL_NO_DATA + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_NEXT, 0); + EXPECT_EQ(ret, SQL_NO_DATA); +} + +TEST_F(ScrollableCursorTest, ForwardOnlyRejectsPrior) { + GTEST_SKIP() << "Requires Phase 4: forward-only cursor SQLSTATE HY106 enforcement"; + // With forward-only cursor, SQL_FETCH_PRIOR should fail with HY106 + SQLRETURN ret = SQLSetStmtAttr(hStmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)(intptr_t)SQL_CURSOR_FORWARD_ONLY, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)"SELECT ID FROM SCROLL_TEST ORDER BY ID", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // First fetch should work + ret = SQLFetchScroll(hStmt, SQL_FETCH_NEXT, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + // PRIOR should fail + ret = SQLFetchScroll(hStmt, SQL_FETCH_PRIOR, 0); + EXPECT_EQ(ret, SQL_ERROR); + EXPECT_EQ(GetSqlState(SQL_HANDLE_STMT, hStmt), "HY106"); +} + +TEST_F(ScrollableCursorTest, FetchBeyondEndReturnsNoData) { + GTEST_SKIP() << "Requires Phase 4: scrollable cursor edge case handling"; + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch absolute beyond end + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_ABSOLUTE, NUM_ROWS + 10); + EXPECT_EQ(ret, SQL_NO_DATA); +} + +TEST_F(ScrollableCursorTest, FetchBeforeStartReturnsNoData) { + GTEST_SKIP() << "Requires Phase 4: scrollable cursor edge case handling"; + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Fetch absolute 0 (before first) + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_ABSOLUTE, 0); + EXPECT_EQ(ret, SQL_NO_DATA); +} + +TEST_F(ScrollableCursorTest, RewindAfterEnd) { + GTEST_SKIP() << "Requires Phase 4: scrollable cursor rewind support"; + OpenScrollableCursor("SELECT ID, NAME FROM SCROLL_TEST ORDER BY ID"); + + // Scroll to end + SQLRETURN ret = SQLFetchScroll(hStmt, SQL_FETCH_LAST, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), NUM_ROWS); + + // Try to go past end + ret = SQLFetchScroll(hStmt, SQL_FETCH_NEXT, 0); + EXPECT_EQ(ret, SQL_NO_DATA); + + // Rewind to first + ret = SQLFetchScroll(hStmt, SQL_FETCH_FIRST, 0); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + EXPECT_EQ(FetchID(), 1); +} diff --git a/tests/test_server_version.cpp b/tests/test_server_version.cpp new file mode 100644 index 00000000..a21160ba --- /dev/null +++ b/tests/test_server_version.cpp @@ -0,0 +1,120 @@ +// test_server_version.cpp — Tests for server version detection and feature-flagging (Task 4.2) +#include "test_helpers.h" + +// ============================================================================ +// ServerVersionTest: Verify server version is correctly detected and exposed +// ============================================================================ +class ServerVersionTest : public OdbcConnectedTest {}; + +TEST_F(ServerVersionTest, SQLGetInfoDBMSVer) { + // SQLGetInfo(SQL_DBMS_VER) should return a non-empty version string + SQLCHAR version[256] = {}; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_VER, version, sizeof(version), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_GT(len, 0) << "DBMS version should be non-empty"; + std::string verStr((char*)version, len); + // Should contain digits and dots (e.g. "05.00.xxxx ...") + EXPECT_NE(verStr.find('.'), std::string::npos) + << "Version string should contain dots: " << verStr; +} + +TEST_F(ServerVersionTest, SQLGetInfoDBMSName) { + // SQLGetInfo(SQL_DBMS_NAME) should return "Firebird" + SQLCHAR name[256] = {}; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_DBMS_NAME, name, sizeof(name), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + std::string nameStr((char*)name, len); + EXPECT_NE(nameStr.find("Firebird"), std::string::npos) + << "DBMS name should contain 'Firebird': " << nameStr; +} + +TEST_F(ServerVersionTest, EngineVersionFromSQL) { + // Query the engine version directly to cross-check + ExecDirect("SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') FROM rdb$database"); + SQLCHAR version[256] = {}; + SQLLEN ind = 0; + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, version, sizeof(version), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + std::string verStr((char*)version); + // Should be like "5.0.1" or "4.0.5" + EXPECT_GE(verStr.length(), 5u) << "Engine version too short: " << verStr; + // First char should be a digit >= 3 + EXPECT_GE(verStr[0], '3') << "Expected Firebird 3.0+: " << verStr; +} + +TEST_F(ServerVersionTest, SQLGetTypeInfoShowsAllBaseTypes) { + GTEST_SKIP() << "Requires Phase 4: server version detection for type count"; + // SQLGetTypeInfo(SQL_ALL_TYPES) should return at least the 22 base types + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + int count = 0; + while (SQL_SUCCEEDED(SQLFetch(hStmt))) + ++count; + + // At least 22 base types (CHAR, VARCHAR, BLOB text, WCHAR, WVARCHAR, WBLOB, + // BLOB binary x3, NUMERIC, DECIMAL, INTEGER, TINYINT, SMALLINT, FLOAT, REAL, + // DOUBLE, BIGINT, BOOLEAN, DATE, TIME, TIMESTAMP) + // Plus 4 FB4+ types (INT128, DECFLOAT, TIME WITH TZ, TIMESTAMP WITH TZ) on FB5 + EXPECT_GE(count, 22) << "Expected at least 22 base type entries"; +} + +TEST_F(ServerVersionTest, SQLGetTypeInfoShowsFB4TypesOnFB5) { + GTEST_SKIP() << "Requires Phase 4: server version detection for FB4+ type info"; + // On Firebird 5.0, SQLGetTypeInfo should also include FB4+ types + // First check if we're on FB 4.0+ + SQLCHAR version[256] = {}; + SQLSMALLINT len = 0; + SQLGetInfo(hDbc, SQL_DBMS_VER, version, sizeof(version), &len); + std::string verStr((char*)version, len); + + // Parse major version from the version string (format: "MM.mm.bbbb ...") + int major = 0; + if (verStr.length() >= 2) + major = std::stoi(verStr.substr(0, 2)); + + if (major < 4) { + GTEST_SKIP() << "Test requires Firebird 4.0+ (current: " << verStr << ")"; + } + + SQLRETURN ret = SQLGetTypeInfo(hStmt, SQL_ALL_TYPES); + ASSERT_TRUE(SQL_SUCCEEDED(ret)); + + bool foundInt128 = false; + bool foundDecfloat = false; + bool foundTimeTZ = false; + bool foundTimestampTZ = false; + + SQLCHAR typeName[256] = {}; + SQLLEN typeNameInd = 0; + + while (SQL_SUCCEEDED(SQLFetch(hStmt))) { + ret = SQLGetData(hStmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &typeNameInd); + if (SQL_SUCCEEDED(ret) && typeNameInd > 0) { + std::string name((char*)typeName, typeNameInd); + if (name == "INT128") foundInt128 = true; + if (name == "DECFLOAT") foundDecfloat = true; + if (name == "TIME WITH TIME ZONE") foundTimeTZ = true; + if (name == "TIMESTAMP WITH TIME ZONE") foundTimestampTZ = true; + } + } + + EXPECT_TRUE(foundInt128) << "INT128 type should be listed on FB4+"; + EXPECT_TRUE(foundDecfloat) << "DECFLOAT type should be listed on FB4+"; + EXPECT_TRUE(foundTimeTZ) << "TIME WITH TIME ZONE should be listed on FB4+"; + EXPECT_TRUE(foundTimestampTZ) << "TIMESTAMP WITH TIME ZONE should be listed on FB4+"; +} + +TEST_F(ServerVersionTest, ScrollOptionsReported) { + // SQLGetInfo should report scroll options + SQLUINTEGER scrollOpts = 0; + SQLSMALLINT len = 0; + SQLRETURN ret = SQLGetInfo(hDbc, SQL_SCROLL_OPTIONS, &scrollOpts, sizeof(scrollOpts), &len); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << GetOdbcError(SQL_HANDLE_DBC, hDbc); + EXPECT_TRUE(scrollOpts & SQL_SO_FORWARD_ONLY) << "Should support forward-only"; + EXPECT_TRUE(scrollOpts & SQL_SO_STATIC) << "Should support static scrollable cursors"; +} diff --git a/tests/test_stmthandles.cpp b/tests/test_stmthandles.cpp new file mode 100644 index 00000000..68ad9e68 --- /dev/null +++ b/tests/test_stmthandles.cpp @@ -0,0 +1,206 @@ +// tests/test_stmthandles.cpp — Statement handle stress tests +// (Phase 6, ported from psqlodbc stmthandles-test) +// +// Tests that many simultaneous statement handles work correctly, +// including allocation, interleaved execution, and prepare/execute +// with SQLNumResultCols before execute. + +#include "test_helpers.h" +#include +#include +#include + +class StmtHandlesTest : public OdbcConnectedTest {}; + +// --- Allocate NUM_HANDLES statement handles and execute a query on each --- + +TEST_F(StmtHandlesTest, AllocateAndExecuteMany) { + constexpr int NUM_HANDLES = 100; + std::vector handles(NUM_HANDLES, SQL_NULL_HSTMT); + int nAllocated = 0; + + // Allocate many statement handles + for (int i = 0; i < NUM_HANDLES; i++) { + SQLRETURN rc = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &handles[i]); + if (!SQL_SUCCEEDED(rc)) { + // Some drivers may have limits; stop here + break; + } + nAllocated++; + } + ASSERT_GE(nAllocated, 50) + << "Could not allocate at least 50 statement handles"; + + // Execute a query on each + for (int i = 0; i < nAllocated; i++) { + char sql[64]; + snprintf(sql, sizeof(sql), "SELECT 'stmt no %d' FROM RDB$DATABASE", i + 1); + SQLRETURN rc = SQLExecDirect(handles[i], (SQLCHAR*)sql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "ExecDirect failed on handle #" << (i + 1) + << ": " << GetOdbcError(SQL_HANDLE_STMT, handles[i]); + } + + // Verify results from a sample of them + for (int i = 0; i < nAllocated; i += (nAllocated / 10)) { + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + SQLRETURN rc = SQLFetch(handles[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Fetch failed on handle #" << (i + 1); + rc = SQLGetData(handles[i], 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + char expected[64]; + snprintf(expected, sizeof(expected), "stmt no %d", i + 1); + EXPECT_STREQ((char*)buf, expected); + } + + // Close and free all handles + for (int i = 0; i < nAllocated; i++) { + SQLFreeStmt(handles[i], SQL_CLOSE); + SQLFreeHandle(SQL_HANDLE_STMT, handles[i]); + } +} + +// --- Interleaved prepare/execute on multiple statements --- + +TEST_F(StmtHandlesTest, InterleavedPrepareExecute) { + constexpr int NUM_STMTS = 5; + SQLHSTMT stmts[NUM_STMTS] = {}; + + for (int i = 0; i < NUM_STMTS; i++) { + SQLRETURN rc = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + } + + // Prepare all statements first (interleaved) — each has a different + // number of result columns + for (int i = 0; i < NUM_STMTS; i++) { + std::string sql = "SELECT 'stmt'"; + for (int j = 0; j < i; j++) { + sql += ", 'col" + std::to_string(j) + "'"; + } + sql += " FROM RDB$DATABASE"; + SQLRETURN rc = SQLPrepare(stmts[i], (SQLCHAR*)sql.c_str(), SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Prepare #" << i << " failed: " + << GetOdbcError(SQL_HANDLE_STMT, stmts[i]); + } + + // Test SQLNumResultCols BEFORE SQLExecute (ODBC spec says this is valid + // after SQLPrepare) + for (int i = 0; i < NUM_STMTS; i++) { + SQLSMALLINT colcount = 0; + SQLRETURN rc = SQLNumResultCols(stmts[i], &colcount); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "NumResultCols #" << i << " failed: " + << GetOdbcError(SQL_HANDLE_STMT, stmts[i]); + EXPECT_EQ(colcount, i + 1) << "Wrong column count for stmt #" << i; + } + + // Execute all statements + for (int i = 0; i < NUM_STMTS; i++) { + SQLRETURN rc = SQLExecute(stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Execute #" << i << " failed: " + << GetOdbcError(SQL_HANDLE_STMT, stmts[i]); + } + + // Fetch results from each + for (int i = 0; i < NUM_STMTS; i++) { + SQLRETURN rc = SQLFetch(stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Fetch #" << i << " failed"; + + // Verify first column always says "stmt" + SQLCHAR buf[64] = {}; + SQLLEN ind = 0; + rc = SQLGetData(stmts[i], 1, SQL_C_CHAR, buf, sizeof(buf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)buf, "stmt"); + + // No more rows + rc = SQLFetch(stmts[i]); + EXPECT_EQ(rc, SQL_NO_DATA); + } + + // Cleanup + for (int i = 0; i < NUM_STMTS; i++) { + SQLFreeStmt(stmts[i], SQL_CLOSE); + SQLFreeHandle(SQL_HANDLE_STMT, stmts[i]); + } +} + +// --- Allocate, free some in the middle, then reuse --- + +TEST_F(StmtHandlesTest, AllocFreeReallocPattern) { + constexpr int NUM = 20; + SQLHSTMT stmts[NUM] = {}; + + // Allocate all + for (int i = 0; i < NUM; i++) { + SQLRETURN rc = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + } + + // Free even-numbered handles + for (int i = 0; i < NUM; i += 2) { + SQLRETURN rc = SQLFreeHandle(SQL_HANDLE_STMT, stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + stmts[i] = SQL_NULL_HSTMT; + } + + // Reallocate the freed slots + for (int i = 0; i < NUM; i += 2) { + SQLRETURN rc = SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + } + + // Execute on all and verify + for (int i = 0; i < NUM; i++) { + char sql[64]; + snprintf(sql, sizeof(sql), "SELECT %d FROM RDB$DATABASE", i); + SQLRETURN rc = SQLExecDirect(stmts[i], (SQLCHAR*)sql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLINTEGER val = -1; + SQLLEN ind = 0; + SQLBindCol(stmts[i], 1, SQL_C_SLONG, &val, 0, &ind); + rc = SQLFetch(stmts[i]); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(val, i); + } + + // Cleanup + for (int i = 0; i < NUM; i++) { + if (stmts[i] != SQL_NULL_HSTMT) { + SQLFreeStmt(stmts[i], SQL_CLOSE); + SQLFreeHandle(SQL_HANDLE_STMT, stmts[i]); + } + } +} + +// --- Reuse same handle: exec, close, exec, close --- + +TEST_F(StmtHandlesTest, ReuseAfterClose) { + for (int iter = 0; iter < 10; iter++) { + char sql[64]; + snprintf(sql, sizeof(sql), "SELECT %d FROM RDB$DATABASE", iter); + SQLRETURN rc = SQLExecDirect(hStmt, (SQLCHAR*)sql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLINTEGER val = -1; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + // Need to fetch first + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLGetData(hStmt, 1, SQL_C_SLONG, &val, 0, &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(val, iter); + + rc = SQLFreeStmt(hStmt, SQL_CLOSE); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + } +} diff --git a/tests/test_wchar.cpp b/tests/test_wchar.cpp new file mode 100644 index 00000000..218487be --- /dev/null +++ b/tests/test_wchar.cpp @@ -0,0 +1,283 @@ +// tests/test_wchar.cpp — Wide character (WCHAR) handling tests +// (Phase 6, ported from psqlodbc wchar-char-test) +// +// Tests SQL_C_WCHAR binding and retrieval for both parameters and +// result columns. Verifies UTF-8/UTF-16 conversions work correctly +// through the ODBC layer with Firebird's CHARSET=UTF8 connection. +// Unlike the psqlodbc test which requires specific locale, this +// focuses on the ODBC-level wide-char mechanics. + +#include "test_helpers.h" +#include +#include +#include + +class WCharTest : public OdbcConnectedTest { +protected: + void SetUp() override { + OdbcConnectedTest::SetUp(); + if (::testing::Test::IsSkipped()) return; + + table_ = std::make_unique(this, "ODBC_TEST_WCHAR", + "ID INTEGER NOT NULL PRIMARY KEY, TXT VARCHAR(200)"); + } + + void TearDown() override { + table_.reset(); + OdbcConnectedTest::TearDown(); + } + + std::unique_ptr table_; +}; + +// --- Fetch ASCII data as SQL_C_WCHAR --- + +TEST_F(WCharTest, FetchAsciiAsWChar) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, 'Hello World')"); + Commit(); + ReallocStmt(); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Retrieve as WCHAR + SQLWCHAR wbuf[128] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_WCHAR, wbuf, sizeof(wbuf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "GetData(SQL_C_WCHAR) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + // Verify the WCHAR contains 'Hello World' + EXPECT_GT(ind, 0); + + // Convert back to ANSI for comparison + SQLCHAR abuf[128] = {}; + SQLLEN aind = 0; + SQLCloseCursor(hStmt); + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLGetData(hStmt, 1, SQL_C_CHAR, abuf, sizeof(abuf), &aind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)abuf, "Hello World"); +} + +// --- Bind column as SQL_C_WCHAR --- + +TEST_F(WCharTest, BindColAsWChar) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, 'Test')"); + Commit(); + ReallocStmt(); + + SQLWCHAR wbuf[64] = {}; + SQLLEN ind = 0; + SQLRETURN rc = SQLBindCol(hStmt, 1, SQL_C_WCHAR, wbuf, sizeof(wbuf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // wbuf should contain 'Test' as wide chars + EXPECT_GT(ind, 0); + + // On Windows, SQLWCHAR=wchar_t=2 bytes (UCS-2). + // On Linux, SQLWCHAR=unsigned short=2 bytes, but drivers may encode + // differently. Verify the first character at least. + EXPECT_EQ(wbuf[0], (SQLWCHAR)'T'); + +#ifdef _WIN32 + // Full per-character check (works reliably on Windows) + EXPECT_EQ(wbuf[1], (SQLWCHAR)'e'); + EXPECT_EQ(wbuf[2], (SQLWCHAR)'s'); + EXPECT_EQ(wbuf[3], (SQLWCHAR)'t'); + EXPECT_EQ(wbuf[4], (SQLWCHAR)'\0'); +#endif +} + +// --- Bind parameter as SQL_C_WCHAR --- + +TEST_F(WCharTest, BindParameterAsWChar) { + GTEST_SKIP() << "Crashes on Linux: SQLWCHAR is 4 bytes (UTF-32) on unixODBC but driver expects 2 bytes (UTF-16)"; + SQLRETURN rc = SQLPrepare(hStmt, + (SQLCHAR*)"INSERT INTO ODBC_TEST_WCHAR (ID, TXT) VALUES (?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLINTEGER id = 42; + SQLLEN idInd = 0; + rc = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, + 0, 0, &id, 0, &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Bind a wide string parameter (portable: SQLWCHAR may differ from wchar_t on Linux) + SQLWCHAR wtxt[] = {'W', 'i', 'd', 'e', 'P', 'a', 'r', 'a', 'm', 0}; + SQLLEN wtxtInd = SQL_NTS; + rc = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, SQL_C_WCHAR, SQL_VARCHAR, + 200, 0, wtxt, 0, &wtxtInd); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "BindParam(SQL_C_WCHAR) failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + rc = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)) + << "Execute with WCHAR param failed: " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + Commit(); + ReallocStmt(); + + // Read it back as ANSI + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 42", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR abuf[128] = {}; + SQLLEN aind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_CHAR, abuf, sizeof(abuf), &aind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)abuf, "WideParam"); +} + +// --- Read same column as both SQL_C_CHAR and SQL_C_WCHAR --- + +TEST_F(WCharTest, ReadSameColumnAsCharAndWChar) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, 'dual')"); + Commit(); + ReallocStmt(); + + // First as CHAR + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR abuf[64] = {}; + SQLLEN aind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_CHAR, abuf, sizeof(abuf), &aind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)abuf, "dual"); + + SQLCloseCursor(hStmt); + + // Same query, now as WCHAR + rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLWCHAR wbuf[64] = {}; + SQLLEN wind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_WCHAR, wbuf, sizeof(wbuf), &wind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_GT(wind, 0); + EXPECT_EQ(wbuf[0], (SQLWCHAR)'d'); +#ifdef _WIN32 + EXPECT_EQ(wbuf[1], (SQLWCHAR)'u'); + EXPECT_EQ(wbuf[2], (SQLWCHAR)'a'); + EXPECT_EQ(wbuf[3], (SQLWCHAR)'l'); +#endif +} + +// --- Truncation indicator for SQL_C_WCHAR --- + +TEST_F(WCharTest, WCharTruncationIndicator) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, 'ABCDEFGHIJ')"); + Commit(); + ReallocStmt(); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + // Provide a buffer too small for the data (only 4 wide chars = 3 chars + NUL) + SQLWCHAR tiny[4] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_WCHAR, tiny, sizeof(tiny), &ind); + // Should return SQL_SUCCESS_WITH_INFO (data truncated) + EXPECT_TRUE(rc == SQL_SUCCESS_WITH_INFO || SQL_SUCCEEDED(rc)); + + // ind should indicate the total length of the data (in bytes) + if (rc == SQL_SUCCESS_WITH_INFO) { + EXPECT_GT(ind, (SQLLEN)sizeof(tiny)) + << "Indicator should show full data length"; + } +} + +// --- SQLDescribeCol reports WCHAR types for Unicode columns --- + +TEST_F(WCharTest, DescribeColReportsType) { + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE 1=0", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLCHAR colName[128] = {}; + SQLSMALLINT nameLen = 0, dataType = 0, decDigits = 0, nullable = 0; + SQLULEN colSize = 0; + + rc = SQLDescribeCol(hStmt, 1, colName, sizeof(colName), &nameLen, + &dataType, &colSize, &decDigits, &nullable); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_STREQ((char*)colName, "TXT"); + // VARCHAR columns with UTF8 charset may be reported as SQL_VARCHAR or SQL_WVARCHAR + EXPECT_TRUE(dataType == SQL_VARCHAR || dataType == SQL_WVARCHAR) + << "Unexpected type: " << dataType; +} + +// --- Empty string handling for WCHAR --- + +TEST_F(WCharTest, EmptyStringWChar) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, '')"); + Commit(); + ReallocStmt(); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLWCHAR wbuf[64] = {0xFFFF}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_WCHAR, wbuf, sizeof(wbuf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + // Empty string: ind == 0 and wbuf[0] == 0 + EXPECT_EQ(ind, 0); + EXPECT_EQ(wbuf[0], (SQLWCHAR)'\0'); +} + +// --- NULL handling for WCHAR --- + +TEST_F(WCharTest, NullValueWChar) { + ExecDirect("INSERT INTO ODBC_TEST_WCHAR VALUES (1, NULL)"); + Commit(); + ReallocStmt(); + + SQLRETURN rc = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT TXT FROM ODBC_TEST_WCHAR WHERE ID = 1", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + rc = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + + SQLWCHAR wbuf[64] = {}; + SQLLEN ind = 0; + rc = SQLGetData(hStmt, 1, SQL_C_WCHAR, wbuf, sizeof(wbuf), &ind); + ASSERT_TRUE(SQL_SUCCEEDED(rc)); + EXPECT_EQ(ind, SQL_NULL_DATA); +}