diff --git a/README.md b/README.md index e63e72d8d6..5c76a6b798 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Ubuntu 24.04: ```bash sudo apt-get -y update sudo apt-get -y install build-essential ca-certificates ccache clang cmake git \ - libidn11-dev libssl-dev lld ninja-build pkg-config python3 ragel unixodbc-dev yasm + libidn11-dev libssl-dev lld ninja-build odbcinst pkg-config python3 ragel \ + unixodbc-dev yasm ``` `unixodbc-dev` is only required when configuring with `YDB_SDK_ODBC=ON`. @@ -36,16 +37,19 @@ Fedora 43: ```bash sudo dnf install -y ccache cmake gcc gcc-c++ git libidn-devel \ - ninja-build openssl-devel openssl-devel-engine pkgconf-pkg-config python3 ragel yasm + ninja-build openssl-devel openssl-devel-engine pkgconf-pkg-config python3 ragel \ + unixODBC-devel yasm ``` macOS 14: ```bash xcode-select --install # if the Command Line Tools are not installed yet -brew install ccache cmake git libidn ninja openssl@3 python ragel yasm +brew install ccache cmake git libidn libiodbc ninja openssl@3 python ragel yasm ``` +`libiodbc` is only required when configuring with `YDB_SDK_ODBC=ON`. + ### Clone the ydb-cpp-sdk repository ```bash @@ -84,18 +88,21 @@ The SDK can be packaged as Debian development packages with CPack. The complete - `libydb-cpp-iam-dev` — IAM credentials plugin; - `libydb-cpp-otel-metrics-dev` — OpenTelemetry metrics plugin; - `libydb-cpp-otel-tracing-dev` — OpenTelemetry tracing plugin (requires `libydb-cpp-otel-metrics-dev` for OTel headers/libs). +- `ydb-odbc` — YDB ODBC driver and unixODBC registration template. The CPack-only packaging flow is intended for Ubuntu 24.04. It builds and -installs the Google common-protos package before packaging the four SDK -components, so all five packages use the distro protobuf ABI: +installs the Google common-protos package before packaging the SDK components, +so all six packages use the distro protobuf ABI: ```bash ./scripts/build_cpack_deb_packages.sh build-deb/packages ``` -The generated `.deb` files are placed into `build-deb/packages/` and install -under `/usr/share/yandex`. The IAM and OTel packages require the matching core -version; tracing additionally requires the matching metrics package. +The generated `.deb` files are placed into `build-deb/packages/`. SDK files +install under `/usr/share/yandex`; the ODBC driver installs under the system +multiarch library directory with its template under `/usr/share/ydb-odbc`. +The IAM and OTel packages require the matching core version; tracing +additionally requires the matching metrics package. To smoke-test generated `.deb` packages with the sample consumer project: diff --git a/cmake/common.cmake b/cmake/common.cmake index 6e71fff4d5..e0597ea44f 100644 --- a/cmake/common.cmake +++ b/cmake/common.cmake @@ -149,6 +149,7 @@ function(vcs_info Tgt) DEPENDS ${YDB_SDK_SOURCE_DIR}/scripts/vcs_info.py ${YDB_SDK_SOURCE_DIR}/scripts/c_templates/svn_interface.c ${CMAKE_CURRENT_BINARY_DIR}/vcs_info.json ) target_sources(${Tgt} PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/__vcs_version__.c) + target_include_directories(${Tgt} PRIVATE ${YDB_SDK_SOURCE_DIR}) endfunction() function(resources Tgt Output) diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake index 90c90d83ea..d566e6b662 100644 --- a/cmake/dependencies.cmake +++ b/cmake/dependencies.cmake @@ -404,13 +404,11 @@ else() NAME RapidJSON GITHUB_REPOSITORY Tencent/rapidjson GIT_TAG v${YDB_SDK_RAPIDJSON_VERSION} - EXCLUDE_FROM_ALL YES - OPTIONS - "RAPIDJSON_BUILD_DOC OFF" - "RAPIDJSON_BUILD_EXAMPLES OFF" - "RAPIDJSON_BUILD_TESTS OFF" - "RAPIDJSON_BUILD_THIRDPARTY_GTEST OFF" + DOWNLOAD_ONLY YES ) + # RapidJSON is header-only. Adding its CMake project would globally set + # RULE_LAUNCH_COMPILE and RULE_LAUNCH_LINK when ccache is installed, + # duplicating our compiler launcher and wrapping non-compiler commands. if(NOT TARGET RapidJSON::RapidJSON) add_library(RapidJSON::RapidJSON INTERFACE IMPORTED GLOBAL) target_include_directories(RapidJSON::RapidJSON INTERFACE diff --git a/cmake/testing.cmake b/cmake/testing.cmake index cc185131a4..c0d3a6c3d8 100644 --- a/cmake/testing.cmake +++ b/cmake/testing.cmake @@ -131,9 +131,13 @@ if (YDB_SDK_ODBC) target_compile_definitions(${ODBC_TEST_NAME} PRIVATE ODBC_DRIVER_PATH="$" - ODBC_TEST_ODBCINI="${CMAKE_BINARY_DIR}/odbc/odbc.ini" + ODBC_DRIVER_VERSION="${YDB_SDK_VERSION}" + ODBC_TEST_ODBCINI="${YDB_ODBC_TEST_INI}" ODBC_TEST_ODBCSYSINI="${CMAKE_BINARY_DIR}/odbc" ) + if (ODBC_LIBRARY MATCHES "[iI][oO][dD][bB][cC]") + target_compile_definitions(${ODBC_TEST_NAME} PRIVATE ODBC_TEST_IODBC=1) + endif() add_dependencies(${ODBC_TEST_NAME} ydb-odbc) endfunction() diff --git a/odbc/CMakeLists.txt b/odbc/CMakeLists.txt index 381a97e7eb..294cd5e1a5 100644 --- a/odbc/CMakeLists.txt +++ b/odbc/CMakeLists.txt @@ -18,6 +18,24 @@ add_library(ydb-odbc SHARED src/descriptor.cpp ) +get_filename_component(_ydb_odbc_library_dir "${ODBC_LIBRARY}" DIRECTORY) +get_filename_component(_ydb_odbc_library_name "${ODBC_LIBRARY}" NAME) +if (WIN32) + set(_ydb_odbcinst_names odbccp32) +elseif (_ydb_odbc_library_name MATCHES "[iI][oO][dD][bB][cC]") + set(_ydb_odbcinst_names iodbcinst) +else() + set(_ydb_odbcinst_names odbcinst) +endif() +find_library(YDB_ODBCINST_LIBRARY + NAMES ${_ydb_odbcinst_names} + HINTS "${_ydb_odbc_library_dir}" + REQUIRED +) +unset(_ydb_odbc_library_dir) +unset(_ydb_odbc_library_name) +unset(_ydb_odbcinst_names) + target_include_directories(ydb-odbc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include @@ -25,6 +43,11 @@ target_include_directories(ydb-odbc ${ODBC_INCLUDE_DIRS} ) +target_compile_definitions(ydb-odbc + PRIVATE + YDB_ODBC_DRIVER_VERSION="${YDB_SDK_VERSION}" +) + target_link_libraries(ydb-odbc PRIVATE YDB-CPP-SDK::Query @@ -34,10 +57,24 @@ target_link_libraries(ydb-odbc YDB-CPP-SDK::Credentials YDB-CPP-SDK::Helpers YDB-CPP-SDK::Iam - ODBC::ODBC - odbcinst + ${YDB_ODBCINST_LIBRARY} ) +if (APPLE) + add_executable(ydb-odbc-register-macos + packaging/register_macos.cpp + ) + target_include_directories(ydb-odbc-register-macos + PRIVATE + ${ODBC_INCLUDE_DIRS} + ) + target_link_libraries(ydb-odbc-register-macos + PRIVATE + ${YDB_ODBCINST_LIBRARY} + ) + add_dependencies(ydb-odbc ydb-odbc-register-macos) +endif() + set_target_properties(ydb-odbc PROPERTIES POSITION_INDEPENDENT_CODE ON ) @@ -118,6 +155,21 @@ install(TARGETS ydb-odbc COMPONENT ydb-odbc ) +if (APPLE) + install(CODE " + if (\"\$ENV{DESTDIR}\" STREQUAL \"\") + execute_process( + COMMAND \"$\" + \"${YDB_ODBC_DRIVER_PATH}\" + RESULT_VARIABLE _ydb_odbc_register_result + ) + if (NOT _ydb_odbc_register_result EQUAL 0) + message(FATAL_ERROR \"Failed to register the YDB ODBC driver with iODBC\") + endif() + endif() + " COMPONENT ydb-odbc) +endif() + if (YDB_SDK_EXAMPLES) add_subdirectory(examples) endif() diff --git a/odbc/README.md b/odbc/README.md index b4a9b63483..9ce1fd848b 100644 --- a/odbc/README.md +++ b/odbc/README.md @@ -4,28 +4,39 @@ ODBC driver for YDB. ## Requirements -- CMake 3.10 or higher +- CMake 3.22 or higher - C/C++ compiler with C11 and C++20 support - YDB C++ SDK (build with `YDB_SDK_ODBC=ON`) -- unixODBC development packages (`unixodbc`, `unixodbc-dev` on Debian/Ubuntu) +- Linux: unixODBC development packages and `odbcinst` +- macOS: iODBC development headers and the OpenLink iODBC SDK frameworks -Static dependencies under `~/ydb_deps` must be built with -`-DCMAKE_POSITION_INDEPENDENT_CODE=ON` when linking the shared ODBC driver. See the -main [README](../README.md) dependency install section. +Dependencies are fetched at the versions pinned by the standalone SDK build. + +## Supported platforms + +| Platform | Driver manager | Status | +| --- | --- | --- | +| Linux | unixODBC | Source build; Ubuntu 24.04 amd64 package and automated API/consumer tests | +| macOS | iODBC 3.52.16 | Source build matching the application architecture; verified on arm64 | +| Windows | — | Not currently packaged or validated | ## Build ```bash -cmake --preset release-test-clang -cmake --build build --target ydb-odbc -j$(nproc) +cmake --preset release-clang -DYDB_SDK_ODBC=ON -DYDB_SDK_EXAMPLES=OFF +cmake --build build --target ydb-odbc --parallel ``` -The shared library is produced as `build/odbc/libydb-odbc.so`. +The shared library is `build/odbc/libydb-odbc.so` on Linux and +`build/odbc/libydb-odbc.dylib` on macOS. -## Install +## Linux installation ```bash -cmake --install build --prefix /usr/local +cmake --preset release-clang -DYDB_SDK_ODBC=ON -DYDB_SDK_EXAMPLES=OFF \ + -DCMAKE_INSTALL_PREFIX=/usr/local +cmake --build build --target ydb-odbc --parallel +sudo cmake --install build --component ydb-odbc sudo odbcinst -i -d -f /usr/local/share/ydb-odbc/odbcinst.ini ``` @@ -34,14 +45,54 @@ This installs `libydb-odbc` and its unixODBC registration template. The and unregisters the driver when the package is removed. `odbc.ini` is not installed or modified — create your own DSN (see below). +## macOS installation + +Use Homebrew iODBC to build the driver. Also install the current +[OpenLink iODBC SDK](https://www.iodbc.org/dataspace/doc/iodbc/wiki/iodbcWiki/Downloads), +which supplies the universal `iODBC.framework` and `iODBCinst.framework` under +`/Library/Frameworks`. The driver itself must contain the architecture used by +the client process. + +Pin all ODBC paths so CMake cannot mix unixODBC libraries with iODBC headers. +The static installer, IDN, and OpenSSL libraries keep the installed driver free +of Homebrew runtime paths: + +```bash +brew install libidn libiodbc openssl@3 +IODBC_ROOT="$(brew --prefix libiodbc)" +IDN_ROOT="$(brew --prefix libidn)" +OPENSSL_ROOT="$(brew --prefix openssl@3)" +cmake --preset release-clang \ + -DYDB_SDK_ODBC=ON \ + -DYDB_SDK_EXAMPLES=OFF \ + -DODBC_CONFIG="${IODBC_ROOT}/bin/iodbc-config" \ + -DODBC_INCLUDE_DIR="${IODBC_ROOT}/include" \ + -DODBC_LIBRARY="${IODBC_ROOT}/lib/libiodbc.dylib" \ + -DYDB_ODBCINST_LIBRARY="${IODBC_ROOT}/lib/libiodbcinst.a" \ + -DIDN_LIBRARIES="${IDN_ROOT}/lib/libidn.a" \ + -DOPENSSL_ROOT_DIR="${OPENSSL_ROOT}" \ + -DOPENSSL_USE_STATIC_LIBS=ON \ + -DYDB_ODBC_INSTALL_LIBDIR=/Library/ODBC/YDB \ + -DYDB_ODBC_INSTALL_DATADIR=/Library/ODBC/YDB +cmake --build build --target ydb-odbc --parallel +sudo cmake --install build --component ydb-odbc +otool -L /Library/ODBC/YDB/libydb-odbc.dylib +``` + +The install command registers the driver and a local `YDB` system DSN in +`/Library/ODBC/odbcinst.ini` and `/Library/ODBC/odbc.ini`. Existing sections +for other drivers and data sources are preserved. The final `otool` output +must not contain build-directory or Homebrew paths. + ## Configuration For `SQLConnect("YDB", ...)`, `isql -v YDB`, or `Driver=YDB`. **`odbcinst.ini`** — driver registration template (generated on build/install). Section `[YDB]` is the driver name used as `Driver=YDB` in connection strings -and DSNs. `Driver` and `Setup` are the full path to `libydb-odbc.so`. Register -the template with `odbcinst -i -d -f`; the Debian package does this for you. +and DSNs. `Driver` and `Setup` are the full path to the platform driver library. +Register the template with `odbcinst -i -d -f`; the Debian package does this +for you. ```ini [YDB] @@ -50,7 +101,10 @@ Driver=/path/to/libydb-odbc.so Setup=/path/to/libydb-odbc.so ``` -**`odbc.ini`** — DSN named `YDB`. In section `[YDB]`: `Driver` is the registered driver name, `Server` is the YDB endpoint, `Database` is the database path. Use `/etc/odbc.ini` or set `ODBCINI` to your file path. +**`odbc.ini`** — DSN named `YDB`. In section `[YDB]`, `Driver` is the +registered driver name or an absolute driver-library path, `Endpoint` (or its +`Server` alias) is the YDB endpoint, and `Database` is the database path. On +Linux use `~/.odbc.ini` or `/etc/odbc.ini`; `ODBCINI` can override the path. ```ini [ODBC Data Sources] @@ -63,6 +117,38 @@ Database=/local AuthMode=Anonymous ``` +On macOS, `sudo cmake --install build --component ydb-odbc` writes the +following sections to `/Library/ODBC/odbcinst.ini` and +`/Library/ODBC/odbc.ini`. They are shown here for reference and for manual +registration of an already-built driver. + +```ini +; /Library/ODBC/odbcinst.ini +[ODBC Drivers] +YDB ODBC Driver=Installed + +[YDB ODBC Driver] +Description=YDB ODBC Driver +Driver=/Library/ODBC/YDB/libydb-odbc.dylib +Setup=/Library/ODBC/YDB/libydb-odbc.dylib +``` + +```ini +; /Library/ODBC/odbc.ini +[ODBC Data Sources] +YDB=YDB ODBC Driver + +[YDB] +Driver=/Library/ODBC/YDB/libydb-odbc.dylib +Endpoint=grpc://localhost:2136 +Database=/local +AuthMode=Anonymous +``` + +For a non-sandboxed per-user setup, the equivalent macOS files live under +`~/Library/ODBC`. Verify the DSN with +`"$(brew --prefix libiodbc)/bin/iodbctest" "DSN=YDB"`. + `SQLDriverConnect` may also combine a DSN with explicit attributes. Values in the connection string take precedence over values from the DSN. The user name and password passed to `SQLConnect` take precedence over `User` and `Password` @@ -155,8 +241,9 @@ For statements without an applicable count, it returns `-1`. ## Parameters -`?` placeholders are rewritten to `$p1`, `$p2`, ... with auto-generated `DECLARE $pN AS ?;` -from `SQLBindParameter` types. YDB-native `$pN` syntax also works. +`?` placeholders are rewritten to `$p1`, `$p2`, ... with auto-generated +`DECLARE` statements derived from `SQLBindParameter` types. Null values use an +optional YDB type. YDB-native `$pN` syntax also works. ## License diff --git a/odbc/packaging/register_macos.cpp b/odbc/packaging/register_macos.cpp new file mode 100644 index 0000000000..b34ab4cc06 --- /dev/null +++ b/odbc/packaging/register_macos.cpp @@ -0,0 +1,85 @@ +#include + +#include +#include + +namespace { + +struct TProfileEntry { + const char* File; + const char* Section; + const char* Key; + const char* Value; +}; + +void PrintInstallerErrors() { + for (WORD record = 1;; ++record) { + DWORD errorCode = 0; + char message[1024] = {}; + WORD messageLength = 0; + const SQLRETURN result = SQLInstallerError( + record, &errorCode, message, sizeof(message), &messageLength); + if (result == SQL_NO_DATA) { + return; + } + if (result != SQL_SUCCESS && result != SQL_SUCCESS_WITH_INFO) { + return; + } + std::fprintf(stderr, "iODBC installer error %lu: %.*s\n", + static_cast(errorCode), + static_cast(messageLength), message); + } +} + +bool WriteEntry(const TProfileEntry& entry) { + if (SQLWritePrivateProfileString( + entry.Section, entry.Key, entry.Value, entry.File)) { + return true; + } + + std::fprintf(stderr, "Failed to write [%s] %s to %s\n", + entry.Section, entry.Key, entry.File); + PrintInstallerErrors(); + return false; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 2) { + std::fprintf(stderr, "Usage: %s /absolute/path/to/libydb-odbc.dylib\n", argv[0]); + return 2; + } + + if (!SQLSetConfigMode(ODBC_SYSTEM_DSN)) { + std::fputs("Failed to select the system iODBC configuration\n", stderr); + PrintInstallerErrors(); + return 1; + } + + const char* driverPath = argv[1]; + const std::array entries = { + TProfileEntry{"odbcinst.ini", "ODBC Drivers", "YDB ODBC Driver", "Installed"}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "Description", "YDB ODBC Driver"}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "Driver", driverPath}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "Setup", driverPath}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "APILevel", "1"}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "ConnectFunctions", "YYY"}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "DriverODBCVer", "03.00"}, + TProfileEntry{"odbcinst.ini", "YDB ODBC Driver", "FileUsage", "0"}, + TProfileEntry{"odbc.ini", "ODBC Data Sources", "YDB", "YDB ODBC Driver"}, + TProfileEntry{"odbc.ini", "YDB", "Driver", driverPath}, + TProfileEntry{"odbc.ini", "YDB", "Description", "Local YDB"}, + TProfileEntry{"odbc.ini", "YDB", "Endpoint", "grpc://localhost:2136"}, + TProfileEntry{"odbc.ini", "YDB", "Database", "/local"}, + TProfileEntry{"odbc.ini", "YDB", "AuthMode", "Anonymous"}, + }; + + for (const auto& entry : entries) { + if (!WriteEntry(entry)) { + return 1; + } + } + + return 0; +} diff --git a/odbc/src/connection.cpp b/odbc/src/connection.cpp index 4d61a60eba..8c9f0e882a 100644 --- a/odbc/src/connection.cpp +++ b/odbc/src/connection.cpp @@ -7,11 +7,9 @@ #include #include -#include #include -#include -#include +#include "odbc_compat.h" namespace NYdb::NOdbc { @@ -20,6 +18,7 @@ TConnection::~TConnection() { } void TConnection::DestroyYdbState() { + InvalidatePreparedStatementMetadata(); QuerySession_.reset(); Tx_.reset(); Ydb_.reset(); @@ -90,6 +89,7 @@ SQLRETURN TConnection::Disconnect() { DriverConfig_.reset(); DbmsVersionCache_.reset(); Database_.clear(); + ServerName_.clear(); DataSourceName_.clear(); return SQL_SUCCESS; } @@ -134,6 +134,12 @@ void TConnection::CloseStatementCursors() { } } +void TConnection::InvalidatePreparedStatementMetadata() { + for (TStatement* stmt : Statements_) { + stmt->InvalidatePreparedColumnMeta(); + } +} + SQLRETURN TConnection::SetAutocommit(bool value) { if (value && Tx_) { auto status = Tx_->Commit().ExtractValueSync(); @@ -161,6 +167,7 @@ SQLRETURN TConnection::SetConnectAttr(SQLINTEGER attr, SQLPOINTER value, SQLINTE if (rc != SQL_SUCCESS) { return rc; } + InvalidatePreparedStatementMetadata(); if (rebindDatabase) { RebindToDatabase(*rebindDatabase); } @@ -231,6 +238,14 @@ const std::string& TConnection::GetDataSourceName() const { return DataSourceName_; } +const std::string& TConnection::GetDatabaseName() const { + return Attributes_.GetCurrentCatalog(); +} + +const std::string& TConnection::GetServerName() const { + return ServerName_; +} + SQLUINTEGER TConnection::GetSupportedTxnIsolationOptions() const { return Attributes_.GetSupportedTxnIsolationOptions(); } @@ -257,6 +272,13 @@ const std::string& TConnection::GetDbmsVersion() { NQuery::TTxControl::NoTx(), NYdb::TParamsBuilder().Build()).ExtractValueSync(); if (!result.IsSuccess()) { + // Version() is unavailable on YDB 24.1 and 24.2. SQL_DBMS_VER is + // mandatory metadata, so report an unknown version on those servers. + const auto issues = result.GetIssues().ToOneLineString(); + if (issues.find("Unknown builtin: Version") != std::string::npos) { + fetched = "00.00.0000"; + return NYdb::TStatus(EStatus::SUCCESS, NYdb::NIssue::TIssues()); + } return result; } if (result.GetResultSets().empty()) { @@ -264,7 +286,7 @@ const std::string& TConnection::GetDbmsVersion() { } TResultSetParser parser(result.GetResultSetParser(0)); if (parser.TryNextRow()) { - fetched = parser.ColumnParser(0).GetUtf8(); + fetched = parser.ColumnParser(0).GetString(); } return NYdb::TStatus(EStatus::SUCCESS, NYdb::NIssue::TIssues()); }); @@ -292,6 +314,7 @@ void TConnection::ApplyResolvedSettings(TResolvedConnectionSettings&& settings) settings.DriverConfig.SetDatabase(settings.Database); Database_ = std::move(settings.Database); + ServerName_ = std::move(settings.Endpoint); DataSourceName_ = std::move(settings.DataSourceName); DriverConfig_.emplace(std::move(settings.DriverConfig)); RecreateYdbClients(); diff --git a/odbc/src/connection.h b/odbc/src/connection.h index 4c0584465b..e5d7b55c73 100644 --- a/odbc/src/connection.h +++ b/odbc/src/connection.h @@ -10,8 +10,7 @@ #include #include -#include -#include +#include "odbc_compat.h" #include #include @@ -52,6 +51,7 @@ class TConnection : public TErrorManager { std::optional QuerySession_; std::string Database_; + std::string ServerName_; std::string DataSourceName_; TEnvironment* ParentEnv_ = nullptr; @@ -64,6 +64,7 @@ class TConnection : public TErrorManager { void ApplyResolvedSettings(TResolvedConnectionSettings&& settings); void RecreateYdbClients(); void RebindToDatabase(std::string_view newDatabase); + void InvalidatePreparedStatementMetadata(); public: ~TConnection(); @@ -98,6 +99,8 @@ class TConnection : public TErrorManager { std::string WrapQueryForCurrentCatalog(const std::string& sql) const; TConnectionAttributes::TCatalogBinding GetCatalogBinding() const; const std::string& GetDbmsVersion(); + const std::string& GetDatabaseName() const; + const std::string& GetServerName() const; const std::string& GetDataSourceName() const; SQLUINTEGER GetSupportedTxnIsolationOptions() const; bool IsDataSourceReadOnly() const; diff --git a/odbc/src/connection_attr.h b/odbc/src/connection_attr.h index 41c94b75ae..85726cf936 100644 --- a/odbc/src/connection_attr.h +++ b/odbc/src/connection_attr.h @@ -67,9 +67,11 @@ class TConnectionAttributes { std::string CurrentCatalog_; std::optional QuietMode_; std::optional TranslateOption_; + SQLUINTEGER LoginTimeout_ = 0; SQLUINTEGER AccessMode_ = SQL_MODE_READ_WRITE; SQLUINTEGER TxnIsolation_ = SQL_TXN_SERIALIZABLE; using TStoredProperties = TScalarProperties< + TScalarProperty, TScalarProperty, TScalarProperty>; using TReadOnlyProperties = TScalarProperties< diff --git a/odbc/src/connection_config.cpp b/odbc/src/connection_config.cpp index 07a22e41ad..ba78cf98d5 100644 --- a/odbc/src/connection_config.cpp +++ b/odbc/src/connection_config.cpp @@ -96,8 +96,12 @@ std::string_view Get(const TConnectionParameters& parameters, std::string_view k uint8_t CredentialMask(const TConnectionParameters& parameters) { uint8_t mask = 0; for (const auto& key : ConnectionKeys) { - if ((key.Authentication & SelectsAuth) && Has(parameters, key.Canonical)) { - mask |= key.Authentication & AuthenticationFamilies; + const uint8_t family = key.Authentication & AuthenticationFamilies; + if ((key.Authentication & SelectsAuth) + && Has(parameters, key.Canonical) + && (family != uint8_t(EAuthenticationMode::Static) + || !Get(parameters, key.Canonical).empty())) { + mask |= family; } } return mask; diff --git a/odbc/src/descriptor.cpp b/odbc/src/descriptor.cpp index ba0cca315f..3a37c65256 100644 --- a/odbc/src/descriptor.cpp +++ b/odbc/src/descriptor.cpp @@ -139,8 +139,7 @@ TResolvedBinding TDescriptor::ResolveBinding( } void TDescriptor::Attach(TStatement* stmt) { - if (Type_ == EDescType::Explicit - && std::find(Statements_.begin(), Statements_.end(), stmt) == Statements_.end()) { + if (std::find(Statements_.begin(), Statements_.end(), stmt) == Statements_.end()) { Statements_.push_back(stmt); } } @@ -149,6 +148,12 @@ void TDescriptor::Detach(TStatement* stmt) { std::erase(Statements_, stmt); } +void TDescriptor::NotifyStatements() { + for (TStatement* stmt : Statements_) { + stmt->DescriptorChanged(this); + } +} + SQLRETURN TDescriptor::GetDescField(SQLSMALLINT recNumber, SQLSMALLINT field, SQLPOINTER value, SQLINTEGER bufferLength, SQLINTEGER* lengthPtr) { const bool stringField = field == SQL_DESC_BASE_COLUMN_NAME || field == SQL_DESC_NAME @@ -254,6 +259,7 @@ SQLRETURN TDescriptor::SetDescField(SQLSMALLINT recNumber, SQLSMALLINT field, SQ for (auto& record : Records_) { record.Active = true; } + NotifyStatements(); return SQL_SUCCESS; } case SQL_DESC_ARRAY_SIZE: { @@ -276,6 +282,7 @@ SQLRETURN TDescriptor::SetDescField(SQLSMALLINT recNumber, SQLSMALLINT field, SQ TDescRecord& record = Record(recNumber); if (TRecordProperties::Set(field, record, value)) { + NotifyStatements(); return SQL_SUCCESS; } if (field == SQL_DESC_NAME) { @@ -307,6 +314,7 @@ SQLRETURN TDescriptor::SetDescRec(SQLSMALLINT recNumber, SQLSMALLINT type, SQLSM record.DataPtr = dataPtr; record.OctetLengthPtr = stringLengthPtr; record.IndicatorPtr = indicatorPtr; + NotifyStatements(); return SQL_SUCCESS; } @@ -319,6 +327,7 @@ SQLRETURN TDescriptor::CopyDesc(TDescriptor* target) { } target->Header_ = Header_; target->Records_ = Records_; + target->NotifyStatements(); return SQL_SUCCESS; } diff --git a/odbc/src/descriptor.h b/odbc/src/descriptor.h index b57a18a7aa..f62b8a914f 100644 --- a/odbc/src/descriptor.h +++ b/odbc/src/descriptor.h @@ -2,8 +2,7 @@ #include "utils/attr.h" -#include -#include +#include "odbc_compat.h" #include #include @@ -98,6 +97,8 @@ class TDescriptor : public TErrorManager { static TDescriptor* FromHandle(SQLHDESC handle); private: + void NotifyStatements(); + EDescType Type_; TConnection* Conn_; THeader Header_; diff --git a/odbc/src/environment.h b/odbc/src/environment.h index 5dc6021ce3..150d56c29c 100644 --- a/odbc/src/environment.h +++ b/odbc/src/environment.h @@ -2,8 +2,7 @@ #include "utils/error_manager.h" -#include -#include +#include "odbc_compat.h" #include #include diff --git a/odbc/src/metadata.cpp b/odbc/src/metadata.cpp index 50eb0cb6db..0d0f5b832f 100644 --- a/odbc/src/metadata.cpp +++ b/odbc/src/metadata.cpp @@ -35,9 +35,12 @@ constexpr TInfo U32(SQLUSMALLINT id, SQLUINTEGER value) { return {id, value}; } +constexpr SQLUSMALLINT kMaxTableColumns = 200; +constexpr SQLUSMALLINT kMaxIndexColumns = 20; + constexpr TInfo kInfo[] = { S(SQL_DRIVER_NAME, "ydb-odbc"), - S(SQL_DRIVER_VER, "unknown"), + S(SQL_DRIVER_VER, YDB_ODBC_DRIVER_VERSION), S(SQL_DRIVER_ODBC_VER, "03.00"), U32(SQL_ODBC_INTERFACE_CONFORMANCE, SQL_OIC_CORE), U16(SQL_ODBC_API_CONFORMANCE, SQL_OAC_LEVEL1), @@ -50,16 +53,17 @@ constexpr TInfo kInfo[] = { U16(SQL_MAX_SCHEMA_NAME_LEN, 0), U16(SQL_MAX_PROCEDURE_NAME_LEN, 0), U16(SQL_MAX_USER_NAME_LEN, 128), - U32(SQL_MAX_DRIVER_CONNECTIONS, 0), - U32(SQL_MAX_CONCURRENT_ACTIVITIES, 0), + U16(SQL_MAX_DRIVER_CONNECTIONS, 0), + U16(SQL_MAX_CONCURRENT_ACTIVITIES, 0), U32(SQL_MAX_STATEMENT_LEN, 0), U32(SQL_MAX_BINARY_LITERAL_LEN, 0), U32(SQL_MAX_CHAR_LITERAL_LEN, 0), - U32(SQL_MAX_COLUMNS_IN_GROUP_BY, 0), - U32(SQL_MAX_COLUMNS_IN_ORDER_BY, 0), - U32(SQL_MAX_COLUMNS_IN_INDEX, 0), - U32(SQL_MAX_COLUMNS_IN_SELECT, 0), - U32(SQL_MAX_COLUMNS_IN_TABLE, 0), + U16(SQL_MAX_COLUMNS_IN_GROUP_BY, 0), + U16(SQL_MAX_COLUMNS_IN_ORDER_BY, 0), + U16(SQL_MAX_COLUMNS_IN_INDEX, kMaxIndexColumns), + U16(SQL_MAX_COLUMNS_IN_SELECT, 0), + U16(SQL_MAX_COLUMNS_IN_TABLE, kMaxTableColumns), + U16(SQL_MAX_TABLES_IN_SELECT, 0), S(SQL_SEARCH_PATTERN_ESCAPE, "\\"), S(SQL_KEYWORDS, ""), S(SQL_SPECIAL_CHARACTERS, ""), @@ -67,24 +71,42 @@ constexpr TInfo kInfo[] = { U16(SQL_NULL_COLLATION, SQL_NC_HIGH), U16(SQL_MAX_CURSOR_NAME_LEN, 128), S(SQL_DBMS_NAME, "YDB"), + S(SQL_USER_NAME, ""), S(SQL_IDENTIFIER_QUOTE_CHAR, "`"), U16(SQL_IDENTIFIER_CASE, SQL_IC_SENSITIVE), S(SQL_CATALOG_NAME, "Y"), S(SQL_CATALOG_NAME_SEPARATOR, "/"), S(SQL_CATALOG_TERM, "path"), - U32(SQL_CATALOG_USAGE, SQL_CU_DML_STATEMENTS), + U16(SQL_CATALOG_LOCATION, SQL_CL_START), + // YDB's database path scopes metadata and relative table names, but it is + // not a catalog qualifier in YQL data-manipulation statements. + U32(SQL_CATALOG_USAGE, 0), U32(SQL_SCHEMA_USAGE, 0), S(SQL_SCHEMA_TERM, ""), + U32(SQL_ALTER_TABLE, 0), + U16(SQL_GROUP_BY, SQL_GB_GROUP_BY_CONTAINS_SELECT), + U16(SQL_NON_NULLABLE_COLUMNS, SQL_NNC_NON_NULL), S(SQL_MULT_RESULT_SETS, "N"), - U32(SQL_DYNAMIC_CURSOR_ATTRIBUTES1, SQL_CA1_NEXT), + U32(SQL_DYNAMIC_CURSOR_ATTRIBUTES1, 0), U32(SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1, SQL_CA1_NEXT), - U32(SQL_STATIC_CURSOR_ATTRIBUTES1, SQL_CA1_NEXT), + U32(SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2, SQL_CA2_READ_ONLY_CONCURRENCY), + U32(SQL_STATIC_CURSOR_ATTRIBUTES1, + SQL_CA1_NEXT | SQL_CA1_ABSOLUTE | SQL_CA1_RELATIVE), + U32(SQL_STATIC_CURSOR_ATTRIBUTES2, + SQL_CA2_READ_ONLY_CONCURRENCY | SQL_CA2_MAX_ROWS_SELECT | SQL_CA2_MAX_ROWS_CATALOG), + U32(SQL_FETCH_DIRECTION, + SQL_FD_FETCH_NEXT | SQL_FD_FETCH_FIRST | SQL_FD_FETCH_LAST + | SQL_FD_FETCH_PRIOR | SQL_FD_FETCH_ABSOLUTE | SQL_FD_FETCH_RELATIVE), + U32(SQL_SCROLL_OPTIONS, SQL_SO_FORWARD_ONLY | SQL_SO_STATIC), + U32(SQL_SCROLL_CONCURRENCY, SQL_SCCO_READ_ONLY), + U32(SQL_CURSOR_SENSITIVITY, SQL_INSENSITIVE), U16(SQL_CURSOR_COMMIT_BEHAVIOR, SQL_CB_CLOSE), U16(SQL_CURSOR_ROLLBACK_BEHAVIOR, SQL_CB_CLOSE), U16(SQL_TXN_CAPABLE, SQL_TC_DML), U32(SQL_DEFAULT_TXN_ISOLATION, SQL_TXN_SERIALIZABLE), S(SQL_PROCEDURES, "N"), S(SQL_OUTER_JOINS, "Y"), + S(SQL_ORDER_BY_COLUMNS_IN_SELECT, "N"), U32(SQL_POSITIONED_STATEMENTS, 0), U32(SQL_BATCH_SUPPORT, 0), U32(SQL_BATCH_ROW_COUNT, 0), @@ -119,6 +141,7 @@ constexpr TFunctionRange kSupportedFunctions[] = { {SQL_API_SQLDESCRIBEPARAM, SQL_API_SQLDESCRIBEPARAM}, {SQL_API_SQLFOREIGNKEYS, SQL_API_SQLNUMPARAMS}, {SQL_API_SQLPRIMARYKEYS, SQL_API_SQLPRIMARYKEYS}, + {SQL_API_SQLCOLUMNPRIVILEGES, SQL_API_SQLCOLUMNPRIVILEGES}, {SQL_API_SQLBINDPARAMETER, SQL_API_SQLBINDPARAMETER}, {SQL_API_SQLALLOCHANDLE, SQL_API_SQLALLOCHANDLE}, {SQL_API_SQLCLOSECURSOR, SQL_API_SQLGETENVATTR}, @@ -190,6 +213,12 @@ SQLRETURN NMetadata::GetInfo(TConnection* connection, SQLUSMALLINT infoType, case SQL_DATA_SOURCE_NAME: return Diag::WriteOdbcString(*connection, connection->GetDataSourceName(), infoValuePtr, bufferLength, stringLengthPtr); + case SQL_DATABASE_NAME: + return Diag::WriteOdbcString(*connection, connection->GetDatabaseName(), + infoValuePtr, bufferLength, stringLengthPtr); + case SQL_SERVER_NAME: + return Diag::WriteOdbcString(*connection, connection->GetServerName(), + infoValuePtr, bufferLength, stringLengthPtr); case SQL_TXN_ISOLATION_OPTION: return WriteInfoScalar(connection, connection->GetSupportedTxnIsolationOptions(), infoValuePtr, stringLengthPtr); @@ -261,10 +290,15 @@ SQLRETURN NMetadata::ColAttribute(TStatement* statement, SQLUSMALLINT columnNumb switch (fieldIdentifier) { case SQL_DESC_NAME: case SQL_COLUMN_NAME: + case SQL_DESC_LABEL: return Diag::WriteString( statement, column.Name, characterAttributePtr, bufferLength, stringLengthAttributePtr); + case SQL_DESC_BASE_COLUMN_NAME: case SQL_DESC_BASE_TABLE_NAME: + case SQL_DESC_CATALOG_NAME: + case SQL_DESC_SCHEMA_NAME: + case SQL_DESC_TABLE_NAME: return Diag::WriteString( statement, "", characterAttributePtr, bufferLength, stringLengthAttributePtr); @@ -295,6 +329,20 @@ SQLRETURN NMetadata::ColAttribute(TStatement* statement, SQLUSMALLINT columnNumb numericAttributePtr); case SQL_DESC_AUTO_UNIQUE_VALUE: return WriteAttributeNumber(statement, SQL_FALSE, numericAttributePtr); + case SQL_DESC_CASE_SENSITIVE: + return WriteAttributeNumber(statement, + column.SqlType == SQL_CHAR || column.SqlType == SQL_VARCHAR + || column.SqlType == SQL_LONGVARCHAR || column.SqlType == SQL_WCHAR + || column.SqlType == SQL_WVARCHAR || column.SqlType == SQL_WLONGVARCHAR, + numericAttributePtr); + case SQL_DESC_FIXED_PREC_SCALE: + return WriteAttributeNumber(statement, + column.SqlType == SQL_DECIMAL || column.SqlType == SQL_NUMERIC, + numericAttributePtr); + case SQL_DESC_SEARCHABLE: + return WriteAttributeNumber(statement, SQL_PRED_SEARCHABLE, numericAttributePtr); + case SQL_DESC_UPDATABLE: + return WriteAttributeNumber(statement, SQL_ATTR_READONLY, numericAttributePtr); default: return statement->AddError("HYC00", 0, "Optional feature not implemented"); } diff --git a/odbc/src/odbc_compat.h b/odbc/src/odbc_compat.h new file mode 100644 index 0000000000..0191b138e1 --- /dev/null +++ b/odbc/src/odbc_compat.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +// iODBC exposes this generic macro from its public headers. It collides with +// SDK identifiers included by the driver after the ODBC headers. +#ifdef EXPORT +#undef EXPORT +#endif diff --git a/odbc/src/odbc_driver.cpp b/odbc/src/odbc_driver.cpp index 9e69d36f32..526e58d622 100644 --- a/odbc/src/odbc_driver.cpp +++ b/odbc/src/odbc_driver.cpp @@ -7,8 +7,7 @@ #include "utils/util.h" #include "utils/error_manager.h" -#include -#include +#include "odbc_compat.h" namespace { namespace Odbc = NYdb::NOdbc; @@ -322,14 +321,8 @@ SQLRETURN SQL_API SQLFreeStmt(SQLHSTMT statementHandle, SQLUSMALLINT option) { } SQLRETURN SQL_API SQLFetchScroll(SQLHSTMT statementHandle, SQLSMALLINT fetchOrientation, SQLLEN fetchOffset) { - return Call(statementHandle, [&](auto* stmt) { - if (fetchOrientation == SQL_FETCH_NEXT) { - return stmt->Fetch(); - } else { - throw NYdb::NOdbc::TOdbcException("HYC00", 0, "Only SQL_FETCH_NEXT is supported"); - } - //TODO other fetch-orientation - }); + return Forward( + statementHandle, fetchOrientation, fetchOffset); } ODBC_FORWARD(SQLRowCount, TStatement, TStatement::RowCount, @@ -445,6 +438,14 @@ ODBC_FORWARD(SQLForeignKeys, TStatement, TStatement::ForeignKeys, Text(pkTableName, nameLength3), Text(fkCatalogName, nameLength4), Text(fkSchemaName, nameLength5), Text(fkTableName, nameLength6))) +ODBC_FORWARD(SQLColumnPrivileges, TStatement, TStatement::ColumnPrivileges, + (SQLHSTMT statementHandle, SQLCHAR* catalogName, SQLSMALLINT nameLength1, + SQLCHAR* schemaName, SQLSMALLINT nameLength2, + SQLCHAR* tableName, SQLSMALLINT nameLength3, + SQLCHAR* columnName, SQLSMALLINT nameLength4), + (statementHandle, Text(catalogName, nameLength1), Text(schemaName, nameLength2), + Text(tableName, nameLength3), Text(columnName, nameLength4))) + ODBC_FORWARD(SQLGetDescField, TDescriptor, TDescriptor::GetDescField, (SQLHDESC descriptorHandle, SQLSMALLINT recNumber, SQLSMALLINT fieldIdentifier, SQLPOINTER value, SQLINTEGER bufferLength, SQLINTEGER* stringLengthPtr), diff --git a/odbc/src/statement.cpp b/odbc/src/statement.cpp index 37d5dba361..d67aaa0444 100644 --- a/odbc/src/statement.cpp +++ b/odbc/src/statement.cpp @@ -17,6 +17,7 @@ #include #include #include +#include namespace NYdb::NOdbc { @@ -52,6 +53,18 @@ namespace { return static_cast(affectedRows); } + std::string BuildResultMetadataQuery(std::string_view query) { + query = TrimTrailingSqlTrivia(query); + std::string_view statement = GetSqlStatement(query); + const std::string_view prologue = query.substr(0, query.size() - statement.size()); + if (!statement.empty() && statement.back() == ';') { + statement.remove_suffix(1); + statement = TrimTrailingSqlTrivia(statement); + } + return std::string(prologue) + "SELECT * FROM (\n" + std::string(statement) + + "\n) AS __ydb_odbc_result_metadata LIMIT 0"; + } + } TStatement::TStatement(TConnection* conn) @@ -62,12 +75,16 @@ TStatement::TStatement(TConnection* conn) , ImpParamDesc_(EDescType::ImpParam, conn) , CurrentAppRowDesc_(&AppRowDesc_) , CurrentAppParamDesc_(&AppParamDesc_) { + AppParamDesc_.Attach(this); + ImpParamDesc_.Attach(this); Conn_->RegisterStatement(this); } TStatement::~TStatement() { CurrentAppRowDesc_->Detach(this); CurrentAppParamDesc_->Detach(this); + AppParamDesc_.Detach(this); + ImpParamDesc_.Detach(this); Conn_->UnregisterStatement(this); } @@ -77,14 +94,16 @@ void TStatement::DetachDescriptor(TDescriptor* desc) { } if (CurrentAppParamDesc_ == desc) { CurrentAppParamDesc_ = &AppParamDesc_; + CurrentAppParamDesc_->Attach(this); AtExecValues_.clear(); + InvalidatePreparedColumnMeta(); } desc->Detach(this); } SQLRETURN TStatement::Prepare(const std::string& statementText) { - RowsFetched_ = 0; RowCount_ = -1; + PreparedColumnMeta_.reset(); SetCursor(nullptr); PreparedQuery_ = statementText; IsPrepared_ = true; @@ -198,7 +217,6 @@ SQLRETURN TStatement::ExecuteParamSet( SQLULEN paramSet, std::optional& affectedRows) { - RowsFetched_ = 0; SetCursor(nullptr); auto client = Conn_->GetClient(); if (!client) { @@ -216,13 +234,17 @@ SQLRETURN TStatement::ExecuteParamSet( const NYdb::NRetry::TRetryOperationSettings retrySettings = MakeAutocommitRetrySettings(); const NYdb::TStatus execStatus = client->RetryQuerySync( - [this, ¶ms, &affectedRows](NQuery::TSession session) -> NYdb::TStatus { - NQuery::TExecuteQueryResult result = ExecuteQuery(session, params); + [this, ¶ms, &affectedRows, paramSet](NQuery::TSession session) -> NYdb::TStatus { + NQuery::TExecuteQueryResult result = ExecuteQuery(session, params, paramSet); if (!result.IsSuccess()) { return StatusFrom(result); } affectedRows = ExtractAffectedRows(result); - SetCursor(CreateExecCursor(result)); + SetCursor(result.GetResultSets().empty() + ? nullptr + : CreateExecCursor( + result.GetResultSet(0), + Attributes_.CursorType == SQL_CURSOR_STATIC)); return NYdb::TStatus(EStatus::SUCCESS, NYdb::NIssue::TIssues()); }, retrySettings); @@ -230,10 +252,14 @@ SQLRETURN TStatement::ExecuteParamSet( NStatusHelpers::ThrowOnError(execStatus); } else { NQuery::TSession& session = Conn_->GetOrCreateQuerySession(); - NQuery::TExecuteQueryResult result = ExecuteQuery(session, params); + NQuery::TExecuteQueryResult result = ExecuteQuery(session, params, paramSet); NStatusHelpers::ThrowOnError(result); affectedRows = ExtractAffectedRows(result); - SetCursor(CreateExecCursor(result)); + SetCursor(result.GetResultSets().empty() + ? nullptr + : CreateExecCursor( + result.GetResultSet(0), + Attributes_.CursorType == SQL_CURSOR_STATIC)); } InAtExec_ = false; NeedDataParam_ = 0; @@ -266,9 +292,10 @@ NYdb::NRetry::TRetryOperationSettings TStatement::MakeAutocommitRetrySettings() NQuery::TExecuteQueryResult TStatement::ExecuteQuery( NQuery::TSession& session, - const NYdb::TParams& params) + const NYdb::TParams& params, + SQLULEN paramSet) { - const std::vector activeParams = GetBoundParams(0); + const std::vector activeParams = GetBoundParams(paramSet); const TParamRewriteResult rewritten = RewriteOdbcSql( PreparedQuery_, activeParams, Attributes_.GetNoScanMode() != SQL_NOSCAN_ON); if (!rewritten.Success) { @@ -317,13 +344,29 @@ NQuery::TExecuteQueryResult TStatement::ExecuteQuery( SQLRETURN TStatement::Fetch() { + return FetchScroll(SQL_FETCH_NEXT, 0); +} + +SQLRETURN TStatement::FetchScroll(SQLSMALLINT orientation, SQLLEN offset) { if (!Cursor_) { return SQL_NO_DATA; } - const SQLULEN maxRows = Attributes_.GetMaxRows(); - if (maxRows > 0 && RowsFetched_ >= maxRows) { - return SQL_NO_DATA; + switch (orientation) { + case SQL_FETCH_NEXT: + case SQL_FETCH_PRIOR: + case SQL_FETCH_FIRST: + case SQL_FETCH_LAST: + case SQL_FETCH_ABSOLUTE: + case SQL_FETCH_RELATIVE: + break; + default: + return AddError("HY106", 0, "Fetch type out of range"); + } + if (Attributes_.CursorType == SQL_CURSOR_FORWARD_ONLY + && orientation != SQL_FETCH_NEXT) { + return AddError("HY106", 0, "Fetch type out of range for a forward-only cursor"); } + const SQLULEN rowArraySize = CurrentAppRowDesc_->GetArraySize(); SQLUSMALLINT* const statuses = ImpRowDesc_.GetArrayStatusPtr(); SQLULEN* const fetched = ImpRowDesc_.GetRowsProcessedPtr(); @@ -334,35 +377,40 @@ SQLRETURN TStatement::Fetch() { std::fill_n(statuses, rowArraySize, SQL_ROW_NOROW); } - SQLULEN rows = 0; + const TFetchResult fetch = Cursor_->Fetch( + orientation, offset, rowArraySize, Attributes_.GetMaxRows()); + if (fetch.Rows == 0) { + GetDataOffsets_.clear(); + return SQL_NO_DATA; + } + SQLRETURN result = SQL_SUCCESS; - for (; rows < rowArraySize; ++rows) { - if (maxRows > 0 && RowsFetched_ >= maxRows) { - break; - } - BindingRow_ = rows; - if (!Cursor_->Fetch()) { - break; - } - FillBoundColumns(); - ++RowsFetched_; - GetDataOffsets_.assign(Cursor_->GetColumnMeta().size(), 0); + for (SQLULEN row = 0; row < fetch.Rows; ++row) { + const SQLULEN rows = row + 1; + const SQLRETURN rowResult = FillBoundColumns(row); if (fetched) { - *fetched = rows + 1; + *fetched = rows; } if (statuses) { - statuses[rows] = LastFetchRc_ == SQL_SUCCESS_WITH_INFO + statuses[row] = rowResult == SQL_SUCCESS_WITH_INFO ? SQL_ROW_SUCCESS_WITH_INFO - : LastFetchRc_ == SQL_SUCCESS ? SQL_ROW_SUCCESS : SQL_ROW_ERROR; + : rowResult == SQL_SUCCESS ? SQL_ROW_SUCCESS : SQL_ROW_ERROR; } - if (LastFetchRc_ == SQL_ERROR) { + if (rowResult == SQL_ERROR) { result = SQL_ERROR; - } else if (LastFetchRc_ == SQL_SUCCESS_WITH_INFO && result == SQL_SUCCESS) { + } else if (rowResult == SQL_SUCCESS_WITH_INFO && result == SQL_SUCCESS) { result = SQL_SUCCESS_WITH_INFO; } } - BindingRow_ = 0; - return rows == 0 && result != SQL_ERROR ? SQL_NO_DATA : result; + GetDataOffsets_.assign(Cursor_->GetColumnMeta().size(), 0); + if (fetch.OverlappedStart) { + AddError("01S06", 0, "Attempt to fetch before the result set returned the first rowset", + SQL_SUCCESS_WITH_INFO); + if (result == SQL_SUCCESS) { + result = SQL_SUCCESS_WITH_INFO; + } + } + return result; } SQLRETURN TStatement::GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, @@ -374,7 +422,7 @@ SQLRETURN TStatement::GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, return AddError("07009", 0, "Invalid descriptor index"); } const SQLRETURN rc = Cursor_->GetData( - columnNumber, targetType, targetValue, bufferLength, strLenOrInd, + 0, columnNumber, targetType, targetValue, bufferLength, strLenOrInd, &GetDataOffsets_[columnNumber - 1]); if (const char* sqlState = ConsumeLastConvertSqlState()) { AddError(sqlState, 0, std::strcmp(sqlState, "22003") == 0 ? "Numeric value out of range" : "Conversion error"); @@ -382,22 +430,23 @@ SQLRETURN TStatement::GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, return rc; } -void TStatement::FillBoundColumns() { +SQLRETURN TStatement::FillBoundColumns(SQLULEN row) { if (!Cursor_) { - return; + return SQL_NO_DATA; } - LastFetchRc_ = SQL_SUCCESS; + SQLRETURN result = SQL_SUCCESS; for (SQLSMALLINT number = 1; number <= CurrentAppRowDesc_->GetRecordCount(); ++number) { const TDescRecord* col = CurrentAppRowDesc_->FindRecord(number); if (!col || !col->DataPtr) { continue; } - const TResolvedBinding binding = CurrentAppRowDesc_->ResolveBinding(*col, BindingRow_); + const TResolvedBinding binding = CurrentAppRowDesc_->ResolveBinding(*col, row); SQLLEN* indicator = binding.Indicator; SQLLEN* length = binding.OctetLength; SQLLEN convertedLength = 0; SQLRETURN rc = Cursor_->GetData( - static_cast(number), col->Type, binding.Data, col->OctetLength, + row, static_cast(number), col->Type, + binding.Data, col->OctetLength, &convertedLength); if (convertedLength == SQL_NULL_DATA) { if (!indicator) { @@ -419,16 +468,17 @@ void TStatement::FillBoundColumns() { } if (rc == SQL_SUCCESS_WITH_INFO) { AddError("01004", 0, "String data, right truncated", SQL_SUCCESS_WITH_INFO); - if (LastFetchRc_ == SQL_SUCCESS) { - LastFetchRc_ = SQL_SUCCESS_WITH_INFO; + if (result == SQL_SUCCESS) { + result = SQL_SUCCESS_WITH_INFO; } - } else if (rc != SQL_SUCCESS && LastFetchRc_ == SQL_SUCCESS) { + } else if (rc != SQL_SUCCESS) { if (const char* sqlState = ConsumeLastConvertSqlState()) { AddError(sqlState, 0, std::strcmp(sqlState, "22003") == 0 ? "Numeric value out of range" : "Conversion error"); } - LastFetchRc_ = rc; + result = rc; } } + return result; } SQLRETURN TStatement::BindCol(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd) { @@ -469,6 +519,9 @@ SQLRETURN TStatement::BindParameter(SQLUSMALLINT paramNumber, if (inputOutputType != SQL_PARAM_INPUT) { throw TOdbcException("HYC00", 0, "Only input parameters are supported"); } + if (paramNumber < 1) { + throw TOdbcException("07009", 0, "Invalid descriptor index"); + } const bool atExec = strLenOrIndPtr && (*strLenOrIndPtr == SQL_DATA_AT_EXEC @@ -480,6 +533,7 @@ SQLRETURN TStatement::BindParameter(SQLUSMALLINT paramNumber, if (AtExecValues_.size() > paramNumber) { AtExecValues_[paramNumber] = {}; } + InvalidatePreparedColumnMeta(); return SQL_SUCCESS; } TDescRecord& app = CurrentAppParamDesc_->Record(static_cast(paramNumber)); @@ -504,6 +558,7 @@ SQLRETURN TStatement::BindParameter(SQLUSMALLINT paramNumber, imp.Scale = decimalDigits; imp.Nullable = SQL_NULLABLE; imp.ParameterType = inputOutputType; + InvalidatePreparedColumnMeta(); return SQL_SUCCESS; } @@ -519,10 +574,16 @@ std::vector TStatement::GetBoundParams(SQLULEN paramSet) const { SQLLEN* lengthOrIndicator = app->IndicatorPtr == app->OctetLengthPtr ? binding.Indicator : binding.OctetLength; + const bool isNullData = binding.Indicator + && *binding.Indicator == SQL_NULL_DATA; + const bool atExecNullData = app->AtExec + && AtExecValues_.size() > static_cast(number) + && AtExecValues_[number].Complete + && AtExecValues_[number].Indicator == SQL_NULL_DATA; TBoundParam param{ static_cast(number), app->Type, imp->Type, static_cast(imp->Length), imp->Scale, binding.Data, app->OctetLength, - lengthOrIndicator, app->AtExec}; + lengthOrIndicator, app->AtExec, isNullData || atExecNullData}; if (binding.Indicator && *binding.Indicator == SQL_NULL_DATA) { param.StrLenOrIndPtr = binding.Indicator; } @@ -558,13 +619,19 @@ SQLRETURN TStatement::BuildParams(NYdb::TParams& out, SQLULEN paramSet) { TBoundParam tmp = param; tmp.ParameterValuePtr = value.Data.data(); tmp.StrLenOrIndPtr = &indicator; - const SQLRETURN convRc = ConvertParam(tmp, paramsBuilder.AddParam(paramName)); + const bool optional = BoundParamIsNull(tmp) + || GetDeclaredParamOptionality(PreparedQuery_, param.ParamNumber).value_or(false); + const SQLRETURN convRc = ConvertParam( + tmp, paramsBuilder.AddParam(paramName), optional); if (convRc != SQL_SUCCESS) { return conversionError(param); } continue; } - const SQLRETURN convRc = ConvertParam(param, paramsBuilder.AddParam(paramName)); + const bool optional = BoundParamIsNull(param) + || GetDeclaredParamOptionality(PreparedQuery_, param.ParamNumber).value_or(false); + const SQLRETURN convRc = ConvertParam( + param, paramsBuilder.AddParam(paramName), optional); if (convRc != SQL_SUCCESS) { return conversionError(param); } @@ -587,8 +654,11 @@ SQLRETURN TStatement::NumParams(SQLSMALLINT* paramCount) { void TStatement::ResetForMetadata() { ClearErrors(); - RowsFetched_ = 0; RowCount_ = -1; + PreparedQuery_.clear(); + IsPrepared_ = false; + ParamCount_ = 0; + PreparedColumnMeta_.reset(); SetCursor(nullptr); } @@ -695,7 +765,6 @@ SQLRETURN TStatement::Cancel() { NeedDataParam_ = 0; NeedDataTokenDelivered_ = false; AtExecValues_.clear(); - RowsFetched_ = 0; return SQL_SUCCESS; } @@ -715,7 +784,6 @@ SQLRETURN TStatement::Close(bool force) { } SetCursor(nullptr); - RowsFetched_ = 0; ClearErrors(); return SQL_SUCCESS; } @@ -743,28 +811,132 @@ SQLRETURN TStatement::NumResultCols(SQLSMALLINT* colCount) { if (!colCount) { throw TOdbcException("HY000", 0, "Invalid parameter"); } - if (!Cursor_) { - *colCount = 0; - return SQL_SUCCESS; - } - *colCount = static_cast(Cursor_->GetColumnMeta().size()); + const auto& columns = GetColumnMeta(); + *colCount = static_cast(columns.size()); return SQL_SUCCESS; } -const std::vector& TStatement::GetColumnMeta() const { - static const std::vector EmptyColumns; - return Cursor_ ? Cursor_->GetColumnMeta() : EmptyColumns; +const std::vector& TStatement::GetColumnMeta() { + if (Cursor_) { + return Cursor_->GetColumnMeta(); + } + EnsurePreparedColumnMeta(); + return *PreparedColumnMeta_; } -void TStatement::SetCursor(std::unique_ptr cursor) { - Cursor_ = std::move(cursor); - GetDataOffsets_.clear(); - ImpRowDesc_.ClearRecords(); - if (!Cursor_) { +void TStatement::EnsurePreparedColumnMeta() { + if (PreparedColumnMeta_) { return; } + if (!IsPrepared_) { + throw TOdbcException("HY010", 0, "Function sequence error"); + } + if (HasMultipleSqlStatements(PreparedQuery_)) { + throw TOdbcException( + "HYC00", 0, + "Result metadata before execution is unavailable for multiple statements"); + } + + if (!StartsWithSqlStatement(PreparedQuery_, {"SELECT"})) { + if (StartsWithSqlStatement( + PreparedQuery_, + {"INSERT", "UPDATE", "DELETE", "UPSERT", "REPLACE", "MERGE", + "CREATE", "DROP", "ALTER", "GRANT", "REVOKE", "COMMIT", "ROLLBACK"})) { + PreparedColumnMeta_.emplace(); + SetImpRowDesc(*PreparedColumnMeta_); + return; + } + throw TOdbcException( + "HYC00", 0, + "Result metadata before execution is supported only for SELECT statements"); + } + + // Inferring parameter result types from application buffers would make + // metadata value-dependent and could read data-at-execution values before + // SQLExecute. Keep this fallback deliberately limited to parameterless + // SELECT statements until YDB exposes a compile-only result-schema API. + if (ParamCount_ != 0) { + throw TOdbcException( + "HYC00", 0, + "Result metadata before execution is unavailable for parameterized statements"); + } + + auto client = Conn_->GetClient(); + if (!client) { + throw TOdbcException("HY000", 0, "No client connection"); + } + + const NYdb::TParams params = NYdb::TParamsBuilder().Build(); + const std::vector activeParams; + const TParamRewriteResult rewritten = RewriteOdbcSql( + BuildResultMetadataQuery(PreparedQuery_), activeParams, + Attributes_.GetNoScanMode() != SQL_NOSCAN_ON); + if (!rewritten.Success) { + throw TOdbcException(rewritten.SqlState, 0, rewritten.Message); + } + const std::string queryText = Conn_->WrapQueryForCurrentCatalog(rewritten.Sql); + + NYdb::NRetry::TRetryOperationSettings retrySettings; + retrySettings.Idempotent(true); + const SQLUINTEGER queryTimeoutSec = Attributes_.GetQueryTimeoutSec(); + if (queryTimeoutSec > 0) { + const TDuration deadline = TDuration::Seconds(queryTimeoutSec); + retrySettings.MaxTimeout(deadline).GetSessionClientTimeout(deadline); + } + + std::optional> columns; + const NYdb::TStatus execStatus = client->RetryQuerySync( + [&queryText, ¶ms, &columns, queryTimeoutSec]( + NQuery::TSession session) -> NYdb::TStatus { + NQuery::TExecuteQuerySettings execSettings; + execSettings.SchemaInclusionMode(NQuery::ESchemaInclusionMode::Always); + if (queryTimeoutSec > 0) { + execSettings.ClientTimeout(TDuration::Seconds(queryTimeoutSec)); + } + NQuery::TExecuteQueryResult result = session.ExecuteQuery( + queryText, + NQuery::TTxControl::NoTx(), + params, + execSettings).ExtractValueSync(); + if (!result.IsSuccess()) { + return StatusFrom(result); + } + if (result.GetResultSets().size() != 1) { + return NYdb::TStatus( + EStatus::BAD_REQUEST, + NYdb::NIssue::TIssues{NYdb::NIssue::TIssue( + "Result metadata query did not return exactly one result set")}); + } + const auto metadataCursor = CreateExecCursor(result.GetResultSet(0), false); + columns = metadataCursor->GetColumnMeta(); + return NYdb::TStatus(EStatus::SUCCESS, NYdb::NIssue::TIssues()); + }, + retrySettings); + NStatusHelpers::ThrowOnError(execStatus); + if (!columns) { + throw TOdbcException("HY000", 0, "Result metadata is unavailable"); + } + PreparedColumnMeta_ = std::move(*columns); + SetImpRowDesc(*PreparedColumnMeta_); +} + +void TStatement::InvalidatePreparedColumnMeta() { + PreparedColumnMeta_.reset(); + if (!Cursor_) { + ImpRowDesc_.ClearRecords(); + } +} + +void TStatement::DescriptorChanged(const TDescriptor* descriptor) { + if (descriptor == CurrentAppParamDesc_ || descriptor == &ImpParamDesc_) { + InvalidatePreparedColumnMeta(); + } +} + +void TStatement::SetImpRowDesc(const std::vector& columns) { + ImpRowDesc_.ClearRecords(); SQLSMALLINT number = 0; - for (const TColumnMeta& column : Cursor_->GetColumnMeta()) { + for (const TColumnMeta& column : columns) { TDescRecord& record = ImpRowDesc_.Record(++number); record.Name = column.Name; record.Type = column.SqlType; @@ -776,6 +948,17 @@ void TStatement::SetCursor(std::unique_ptr cursor) { } } +void TStatement::SetCursor(std::unique_ptr cursor) { + if (cursor && IsPrepared_) { + PreparedColumnMeta_ = cursor->GetColumnMeta(); + } + Cursor_ = std::move(cursor); + GetDataOffsets_.clear(); + static const std::vector EmptyColumns; + SetImpRowDesc(Cursor_ ? Cursor_->GetColumnMeta() + : PreparedColumnMeta_.value_or(EmptyColumns)); +} + std::optional TStatement::ResolveDescriptorAttribute( SQLINTEGER attr) { switch (attr) { @@ -829,6 +1012,7 @@ SQLRETURN TStatement::SetStmtAttr( current->Attach(this); if (attr == SQL_ATTR_APP_PARAM_DESC) { AtExecValues_.clear(); + InvalidatePreparedColumnMeta(); } if (CurrentAppRowDesc_ != previous && CurrentAppParamDesc_ != previous) { previous->Detach(this); @@ -845,12 +1029,20 @@ SQLRETURN TStatement::SetStmtAttr( if (auto descriptorAttr = ResolveDescriptorAttribute(attr)) { return descriptorAttr->Descriptor->SetDescField(0, descriptorAttr->Field, value, 0); } - const auto setForwardOnly = [&](bool forwardOnly, std::string_view name) -> SQLRETURN { - if (forwardOnly) { + const auto setCursorType = [&](SQLULEN cursorType, std::string_view name) -> SQLRETURN { + if (Cursor_) { + return AddError("24000", 0, std::string(name) + " cannot be changed while a cursor is open"); + } + if (cursorType == SQL_CURSOR_FORWARD_ONLY || cursorType == SQL_CURSOR_STATIC) { + Attributes_.CursorType = cursorType; return SQL_SUCCESS; } - return AddError("01S02", 0, std::string(name) + " was changed to forward-only", - SQL_SUCCESS_WITH_INFO); + if (cursorType == SQL_CURSOR_KEYSET_DRIVEN || cursorType == SQL_CURSOR_DYNAMIC) { + Attributes_.CursorType = SQL_CURSOR_STATIC; + return AddError("01S02", 0, std::string(name) + " was changed to static", + SQL_SUCCESS_WITH_INFO); + } + return Diag::AddInvalidAttrValue(*this, name); }; switch (attr) { case SQL_ATTR_QUERY_TIMEOUT: @@ -861,27 +1053,87 @@ SQLRETURN TStatement::SetStmtAttr( return SetCheckedAttribute( value, Attributes_.MaxRows, *this, "SQL_ATTR_MAX_ROWS", [](SQLLEN input) { return input >= 0; }); - case SQL_ATTR_NOSCAN: - return SetCheckedAttribute( + case SQL_ATTR_NOSCAN: { + const SQLULEN previous = Attributes_.NoScan; + const SQLRETURN result = SetCheckedAttribute( value, Attributes_.NoScan, *this, "SQL_ATTR_NOSCAN", [](SQLULEN input) { return input == SQL_NOSCAN_OFF || input == SQL_NOSCAN_ON; }); + if (result == SQL_SUCCESS && Attributes_.NoScan != previous) { + InvalidatePreparedColumnMeta(); + } + return result; + } case SQL_ATTR_METADATA_ID: return SetCheckedAttribute( value, Attributes_.MetadataId, *this, "SQL_ATTR_METADATA_ID", [](SQLULEN input) { return input == SQL_FALSE || input == SQL_TRUE; }); + case SQL_ATTR_CONCURRENCY: { + if (Cursor_) { + return AddError( + "24000", 0, + "SQL_ATTR_CONCURRENCY cannot be changed while a cursor is open"); + } + const SQLULEN concurrency = ReadIntegerAttr(value); + if (concurrency == SQL_CONCUR_READ_ONLY) { + Attributes_.Concurrency = concurrency; + return SQL_SUCCESS; + } + if (concurrency == SQL_CONCUR_LOCK + || concurrency == SQL_CONCUR_ROWVER + || concurrency == SQL_CONCUR_VALUES) { + Attributes_.Concurrency = SQL_CONCUR_READ_ONLY; + return AddError( + "01S02", 0, + "SQL_ATTR_CONCURRENCY was changed to read-only", + SQL_SUCCESS_WITH_INFO); + } + return Diag::AddInvalidAttrValue(*this, "SQL_ATTR_CONCURRENCY"); + } case SQL_ATTR_CURSOR_TYPE: - return setForwardOnly( - ReadIntegerAttr(value) == SQL_CURSOR_FORWARD_ONLY, - "SQL_ATTR_CURSOR_TYPE"); + return setCursorType( + ReadIntegerAttr(value), "SQL_ATTR_CURSOR_TYPE"); case SQL_ATTR_CURSOR_SCROLLABLE: { const SQLULEN scrollable = ReadIntegerAttr(value); if (scrollable != SQL_NONSCROLLABLE && scrollable != SQL_SCROLLABLE) { return Diag::AddInvalidAttrValue(*this, "SQL_ATTR_CURSOR_SCROLLABLE"); } - return setForwardOnly( - scrollable == SQL_NONSCROLLABLE, "SQL_ATTR_CURSOR_SCROLLABLE"); + return setCursorType( + scrollable == SQL_NONSCROLLABLE + ? SQL_CURSOR_FORWARD_ONLY + : SQL_CURSOR_STATIC, + "SQL_ATTR_CURSOR_SCROLLABLE"); + } + case SQL_ATTR_CURSOR_SENSITIVITY: { + const SQLULEN sensitivity = ReadIntegerAttr(value); + if (sensitivity == SQL_UNSPECIFIED) { + return SQL_SUCCESS; + } + if (sensitivity == SQL_INSENSITIVE) { + return setCursorType(SQL_CURSOR_STATIC, "SQL_ATTR_CURSOR_SENSITIVITY"); + } + if (sensitivity == SQL_SENSITIVE) { + const SQLRETURN result = setCursorType( + SQL_CURSOR_STATIC, "SQL_ATTR_CURSOR_SENSITIVITY"); + return result == SQL_SUCCESS + ? AddError("01S02", 0, + "SQL_ATTR_CURSOR_SENSITIVITY was changed to insensitive", + SQL_SUCCESS_WITH_INFO) + : result; + } + return Diag::AddInvalidAttrValue(*this, "SQL_ATTR_CURSOR_SENSITIVITY"); + } + case SQL_ATTR_USE_BOOKMARKS: { + const SQLULEN bookmarks = ReadIntegerAttr(value); + if (bookmarks == SQL_UB_OFF) { + return SQL_SUCCESS; + } + if (bookmarks == SQL_UB_ON || bookmarks == SQL_UB_VARIABLE) { + return AddError("01S02", 0, "SQL_ATTR_USE_BOOKMARKS was changed to off", + SQL_SUCCESS_WITH_INFO); + } + return Diag::AddInvalidAttrValue(*this, "SQL_ATTR_USE_BOOKMARKS"); } default: return Diag::AddNotImplemented(*this); @@ -919,12 +1171,26 @@ SQLRETURN TStatement::GetStmtAttr( if (stringLengthPtr) { *stringLengthPtr = 0; } + if (attr == SQL_ATTR_ROW_NUMBER) { + *static_cast(value) = Cursor_ ? Cursor_->GetRowNumber() : 0; + return SQL_SUCCESS; + } if (attr == SQL_ATTR_CURSOR_SCROLLABLE) { *static_cast(value) = Attributes_.CursorType == SQL_CURSOR_FORWARD_ONLY ? SQL_NONSCROLLABLE : SQL_SCROLLABLE; return SQL_SUCCESS; } + if (attr == SQL_ATTR_CURSOR_SENSITIVITY) { + *static_cast(value) = Attributes_.CursorType == SQL_CURSOR_FORWARD_ONLY + ? SQL_UNSPECIFIED + : SQL_INSENSITIVE; + return SQL_SUCCESS; + } + if (attr == SQL_ATTR_USE_BOOKMARKS) { + *static_cast(value) = SQL_UB_OFF; + return SQL_SUCCESS; + } if (auto result = TDirectAttributes::Get(attr, Attributes_, value)) { return *result; } diff --git a/odbc/src/statement.h b/odbc/src/statement.h index 239d8afa5f..c469e638d1 100644 --- a/odbc/src/statement.h +++ b/odbc/src/statement.h @@ -8,8 +8,7 @@ #include -#include -#include +#include "odbc_compat.h" #include #include @@ -29,6 +28,7 @@ class TStatement : public TErrorManager { SQLRETURN ExecuteInternal(); SQLRETURN Fetch(); + SQLRETURN FetchScroll(SQLSMALLINT orientation, SQLLEN offset); SQLRETURN GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd); @@ -69,6 +69,10 @@ class TStatement : public TErrorManager { const std::string& fkCatalogName, const std::string& fkSchemaName, const std::string& fkTableName); + SQLRETURN ColumnPrivileges(const std::string& catalogName, + const std::string& schemaName, + const std::string& tableName, + const std::string& columnName); SQLRETURN NumParams(SQLSMALLINT* paramCount); SQLRETURN DescribeParam(SQLUSMALLINT paramNumber, SQLSMALLINT* dataTypePtr, SQLULEN* paramSizePtr, SQLSMALLINT* decimalDigitsPtr, SQLSMALLINT* nullablePtr); @@ -82,7 +86,7 @@ class TStatement : public TErrorManager { SQLRETURN RowCount(SQLLEN* rowCount); SQLRETURN NumResultCols(SQLSMALLINT* colCount); - const std::vector& GetColumnMeta() const; + const std::vector& GetColumnMeta(); SQLRETURN SetStmtAttr(SQLINTEGER attr, SQLPOINTER value, SQLINTEGER stringLength); SQLRETURN GetStmtAttr(SQLINTEGER attr, SQLPOINTER value, SQLINTEGER bufferLength, SQLINTEGER* stringLengthPtr); @@ -90,12 +94,16 @@ class TStatement : public TErrorManager { SQLSMALLINT* stringLengthPtr) override; private: + friend class TConnection; + friend class TDescriptor; + struct TAttributes { SQLUINTEGER QueryTimeoutSec = 0; SQLULEN MaxRows = 0; SQLULEN NoScan = SQL_NOSCAN_OFF; SQLULEN MetadataId = SQL_FALSE; SQLULEN CursorType = SQL_CURSOR_FORWARD_ONLY; + SQLULEN Concurrency = SQL_CONCUR_READ_ONLY; SQLUINTEGER GetQueryTimeoutSec() const noexcept { return QueryTimeoutSec; } SQLULEN GetMaxRows() const noexcept { return MaxRows; } @@ -108,7 +116,8 @@ class TStatement : public TErrorManager { TScalarProperty, TScalarProperty, TScalarProperty, - TScalarProperty>; + TScalarProperty, + TScalarProperty>; struct TAtExecValue { std::string Data; @@ -118,11 +127,11 @@ class TStatement : public TErrorManager { TConnection* Conn_; std::unique_ptr Cursor_; + std::optional> PreparedColumnMeta_; std::string PreparedQuery_; bool IsPrepared_ = false; SQLSMALLINT ParamCount_ = 0; - SQLULEN RowsFetched_ = 0; SQLLEN RowCount_ = -1; TAttributes Attributes_; std::string CursorName_; @@ -136,14 +145,16 @@ class TStatement : public TErrorManager { bool InAtExec_ = false; bool NeedDataTokenDelivered_ = false; std::vector AtExecValues_; // indexed by parameter number - SQLRETURN LastFetchRc_ = SQL_SUCCESS; - SQLULEN BindingRow_ = 0; std::vector GetDataOffsets_; SQLRETURN BuildParams(NYdb::TParams& out, SQLULEN paramSet); SQLRETURN ExecuteParamSet(SQLULEN paramSet, std::optional& affectedRows); - void FillBoundColumns(); + SQLRETURN FillBoundColumns(SQLULEN row); std::vector GetBoundParams(SQLULEN paramSet) const; + void EnsurePreparedColumnMeta(); + void InvalidatePreparedColumnMeta(); + void DescriptorChanged(const TDescriptor* descriptor); + void SetImpRowDesc(const std::vector& columns); void SetCursor(std::unique_ptr cursor); void ResetForMetadata(); @@ -158,7 +169,10 @@ class TStatement : public TErrorManager { std::string GetMetadataTableName(const std::string& path) const; bool MetadataNamespaceMatches(const std::string& catalog, const std::string& schema) const; - NQuery::TExecuteQueryResult ExecuteQuery(NQuery::TSession& session, const NYdb::TParams& params); + NQuery::TExecuteQueryResult ExecuteQuery( + NQuery::TSession& session, + const NYdb::TParams& params, + SQLULEN paramSet); NYdb::NRetry::TRetryOperationSettings MakeAutocommitRetrySettings(); std::vector GetPatternEntries(const std::string& pattern); diff --git a/odbc/src/statement_metadata.cpp b/odbc/src/statement_metadata.cpp index 84acbb7744..6e6050e077 100644 --- a/odbc/src/statement_metadata.cpp +++ b/odbc/src/statement_metadata.cpp @@ -67,9 +67,9 @@ const TColumnMeta kTypeInfoSchema[] = { N("NULLABLE", SQL_SMALLINT, SQL_NO_NULLS), N("CASE_SENSITIVE", SQL_SMALLINT, SQL_NO_NULLS), N("SEARCHABLE", SQL_SMALLINT, SQL_NO_NULLS), - C("UNSIGNED_ATTRIBUTE", SQL_CHAR, 1), + N("UNSIGNED_ATTRIBUTE", SQL_SMALLINT), N("FIXED_PREC_SCALE", SQL_SMALLINT, SQL_NO_NULLS), - N("AUTO_UNIQUE_VALUE", SQL_SMALLINT, SQL_NO_NULLS), + N("AUTO_UNIQUE_VALUE", SQL_SMALLINT), V("LOCAL_TYPE_NAME"), N("MINIMUM_SCALE", SQL_SMALLINT), N("MAXIMUM_SCALE", SQL_SMALLINT), @@ -83,7 +83,7 @@ const TColumnMeta kStatisticsSchema[] = { V("TABLE_CAT"), V("TABLE_SCHEM"), V("TABLE_NAME", 128, SQL_NO_NULLS), - C("NON_UNIQUE", SQL_CHAR, 1, SQL_NO_NULLS), + N("NON_UNIQUE", SQL_SMALLINT, SQL_NO_NULLS), V("INDEX_QUALIFIER"), V("INDEX_NAME"), N("TYPE", SQL_SMALLINT, SQL_NO_NULLS), @@ -132,6 +132,17 @@ const TColumnMeta kForeignKeysSchema[] = { N("DEFERRABILITY", SQL_SMALLINT), }; +const TColumnMeta kColumnPrivilegesSchema[] = { + V("TABLE_CAT"), + V("TABLE_SCHEM"), + V("TABLE_NAME", 128, SQL_NO_NULLS), + V("COLUMN_NAME", 128, SQL_NO_NULLS), + V("GRANTOR"), + V("GRANTEE", 128, SQL_NO_NULLS), + V("PRIVILEGE", 128, SQL_NO_NULLS), + V("IS_GRANTABLE"), +}; + TOdbcScalar Null() { return std::monostate{}; } @@ -146,6 +157,16 @@ TOdbcScalar Maybe(const std::optional& value) { return value ? I(*value) : Null(); } +std::string GetMetadataCatalogName(TConnection* connection) { + std::string catalog = connection->GetCatalogBinding().Catalog; + // TABLE_CAT is an identifier. The leading slash belongs to YDB's absolute + // path syntax and is supplied separately as SQL_CATALOG_NAME_SEPARATOR. + if (catalog.starts_with('/')) { + catalog.erase(0, 1); + } + return catalog; +} + template void DescribeTable(TConnection* connection, const std::string& path, Visitor&& visitor) { auto client = connection->GetTableClient(); @@ -204,10 +225,7 @@ TTable BuildTypeInfoRows(SQLSMALLINT dataType) { || (dataType != SQL_ALL_TYPES && spec.Type != dataType)) { continue; } - std::string typeName(spec.Name); - std::ranges::transform(typeName, typeName.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); + const std::string typeName(spec.YqlType); table.push_back({ typeName, I(spec.Type), I(static_cast(spec.ColumnSize)), Null(), Null(), Null(), I(SQL_NULLABLE), @@ -228,7 +246,7 @@ SQLRETURN TStatement::Columns(const std::string& catalogName, const std::string& return SQL_SUCCESS; } - const std::string catalog = Conn_->GetCatalogBinding().Catalog; + const std::string catalog = GetMetadataCatalogName(Conn_); TTable table; for (const auto& entry : GetPatternEntries(tableName)) { if (entry.Type != NScheme::ESchemeEntryType::Table @@ -237,6 +255,7 @@ SQLRETURN TStatement::Columns(const std::string& catalogName, const std::string& } DescribeTable(Conn_, entry.Name, [&](const auto& description) { const auto& columns = description.GetTableColumns(); + const auto& primaryKeyColumns = description.GetPrimaryKeyColumns(); for (size_t index = 0; index < columns.size(); ++index) { const auto& column = columns[index]; const bool matches = columnName.empty() @@ -248,10 +267,12 @@ SQLRETURN TStatement::Columns(const std::string& catalogName, const std::string& const TYdbTypeInfo type = DescribeYdbType(column.Type); const TOdbcScalar size = type.ColumnSize ? I(static_cast(type.ColumnSize)) : Null(); - const bool notNull = column.NotNull && *column.NotNull; + const bool notNull = type.Nullable == SQL_NO_NULLS + || (column.NotNull && *column.NotNull) + || std::ranges::find(primaryKeyColumns, column.Name) != primaryKeyColumns.end(); table.push_back({ catalog, Null(), GetMetadataTableName(entry.Name), column.Name, I(type.SqlType), - column.Type.ToString(), size, size, Maybe(type.DecimalDigits), Maybe(type.Radix), + type.TypeName, size, size, Maybe(type.DecimalDigits), Maybe(type.Radix), I(notNull ? SQL_NO_NULLS : SQL_NULLABLE), Null(), Null(), I(type.SqlType), Null(), size, I(static_cast(index + 1)), std::string(notNull ? "NO" : "YES"), }); @@ -270,7 +291,7 @@ SQLRETURN TStatement::Tables(const std::string& catalogName, const std::string& return SQL_SUCCESS; } - const std::string catalog = Conn_->GetCatalogBinding().Catalog; + const std::string catalog = GetMetadataCatalogName(Conn_); TTable table; for (const auto& entry : GetPatternEntries(tableName)) { const auto type = GetTableType(entry.Type); @@ -324,7 +345,7 @@ SQLRETURN TStatement::SpecialColumns(const std::string& catalogName, const std:: const TYdbTypeInfo type = DescribeYdbType(column->Type); const TOdbcScalar size = type.ColumnSize ? I(static_cast(type.ColumnSize)) : Null(); - table.push_back({I(SQL_SCOPE_SESSION), pkName, I(type.SqlType), column->Type.ToString(), + table.push_back({I(SQL_SCOPE_SESSION), pkName, I(type.SqlType), type.TypeName, size, size, Maybe(type.DecimalDigits), I(SQL_PC_NOT_PSEUDO)}); } }); @@ -347,7 +368,7 @@ SQLRETURN TStatement::PrimaryKeys(const std::string& catalogName, const std::str } TTable table; if (!entries.empty()) { - const std::string catalog = Conn_->GetCatalogBinding().Catalog; + const std::string catalog = GetMetadataCatalogName(Conn_); DescribeTable(Conn_, entries.front().Name, [&](const auto& description) { SQLSMALLINT sequence = 1; for (const auto& name : description.GetPrimaryKeyColumns()) { @@ -367,6 +388,13 @@ SQLRETURN TStatement::ForeignKeys(const std::string&, const std::string&, const return SQL_SUCCESS; } +SQLRETURN TStatement::ColumnPrivileges(const std::string&, const std::string&, + const std::string&, const std::string&) { + ResetForMetadata(); + SetCursor(CreateVirtualCursor(kColumnPrivilegesSchema)); + return SQL_SUCCESS; +} + std::string TStatement::GetTraversalRoot(const std::string& pattern) const { const size_t slash = pattern.rfind('/', pattern.find_first_of("%_")); return slash == std::string::npos ? "" : pattern.substr(0, slash); @@ -397,7 +425,11 @@ bool TStatement::MetadataNamespaceMatches(const std::string& catalog, const std: return pattern.empty() || (Attributes_.GetMetadataId() == SQL_TRUE ? value == pattern : SqlLikeMatch(value, pattern)); }; - return matches(Conn_->GetCatalogBinding().Catalog, catalog) && matches("", schema); + std::string normalizedCatalog = catalog; + if (!normalizedCatalog.empty() && normalizedCatalog.front() != '/') { + normalizedCatalog.insert(normalizedCatalog.begin(), '/'); + } + return matches(Conn_->GetCatalogBinding().Catalog, normalizedCatalog) && matches("", schema); } SQLRETURN TStatement::VisitEntry(const std::string& path, const std::string& pattern, diff --git a/odbc/src/utils/attr.h b/odbc/src/utils/attr.h index dbf7cf2279..f14145c8c1 100644 --- a/odbc/src/utils/attr.h +++ b/odbc/src/utils/attr.h @@ -9,8 +9,7 @@ #include #include -#include -#include +#include "odbc_compat.h" namespace NYdb::NOdbc { diff --git a/odbc/src/utils/bindings.h b/odbc/src/utils/bindings.h index eee7473f8d..4eb406fe7f 100644 --- a/odbc/src/utils/bindings.h +++ b/odbc/src/utils/bindings.h @@ -1,7 +1,6 @@ #pragma once -#include -#include +#include "odbc_compat.h" namespace NYdb::NOdbc { @@ -15,6 +14,12 @@ struct TBoundParam { SQLLEN BufferLength; SQLLEN* StrLenOrIndPtr; bool AtExec = false; + bool IsNullData = false; }; +inline bool BoundParamIsNull(const TBoundParam& param) noexcept { + return param.IsNullData + || (param.StrLenOrIndPtr && *param.StrLenOrIndPtr == SQL_NULL_DATA); +} + } // namespace NYdb::NOdbc diff --git a/odbc/src/utils/convert.cpp b/odbc/src/utils/convert.cpp index 653e88cc3b..82c493ccc3 100644 --- a/odbc/src/utils/convert.cpp +++ b/odbc/src/utils/convert.cpp @@ -407,9 +407,32 @@ SQLRETURN WriteText(std::string_view text, SQLSMALLINT type, SQLPOINTER target, } try { const TUtf16String wide = UTF8ToWide(text); - static_assert(sizeof(TUtf16String::value_type) == sizeof(SQLWCHAR)); - return CopyVariable(wide.data(), wide.size() * sizeof(SQLWCHAR), sizeof(SQLWCHAR), - sizeof(SQLWCHAR), target, bufferLength, indicator, offset); + static_assert(sizeof(SQLWCHAR) == 2 || sizeof(SQLWCHAR) == 4); + if constexpr (sizeof(TUtf16String::value_type) == sizeof(SQLWCHAR)) { + return CopyVariable(wide.data(), wide.size() * sizeof(SQLWCHAR), sizeof(SQLWCHAR), + sizeof(SQLWCHAR), target, bufferLength, indicator, offset); + } else { + std::basic_string odbcWide; + odbcWide.reserve(wide.size()); + for (size_t i = 0; i < wide.size(); ++i) { + uint32_t codePoint = wide[i]; + if (codePoint >= 0xd800 && codePoint <= 0xdbff) { + if (i + 1 >= wide.size() + || wide[i + 1] < 0xdc00 || wide[i + 1] > 0xdfff) { + return Error("22018"); + } + codePoint = 0x10000 + + ((codePoint - 0xd800) << 10) + + (wide[++i] - 0xdc00); + } else if (codePoint >= 0xdc00 && codePoint <= 0xdfff) { + return Error("22018"); + } + odbcWide.push_back(static_cast(codePoint)); + } + return CopyVariable(odbcWide.data(), odbcWide.size() * sizeof(SQLWCHAR), + sizeof(SQLWCHAR), sizeof(SQLWCHAR), target, bufferLength, + indicator, offset); + } } catch (...) { return Error("22018"); } @@ -484,14 +507,26 @@ std::optional ReadScalar(const TBoundParam& param) { } } +template +void PutParamValue(TParamValueBuilder& builder, bool optional, Put&& put) { + if (optional) { + builder.BeginOptional(); + } + put(builder); + if (optional) { + builder.EndOptional(); + } +} + template std::optional VisitScalar(ScalarType type, Fn&& fn) { #define ODBC_VISIT_SCALAR(name, cppType, sqlType, isUnsigned) \ case ScalarType::name: \ return fn.template operator()( \ [](TValueParser& parser) { return parser.Get##name(); }, \ - [](TParamValueBuilder& builder, cppType value) { \ - builder.Optional##name(value); \ + [](TParamValueBuilder& builder, cppType value, bool optional) { \ + PutParamValue(builder, optional, \ + [&](TParamValueBuilder& item) { item.name(value); }); \ }); switch (type) { YDB_ODBC_SCALAR_TYPES(ODBC_VISIT_SCALAR) @@ -534,17 +569,23 @@ std::optional PrimitiveScalar(TValueParser& parser, EPrimitiveType } switch (type) { case EPrimitiveType::Bool: return TOdbcScalar{int64_t(parser.GetBool())}; - case EPrimitiveType::Utf8: return TOdbcScalar{parser.GetUtf8()}; - case EPrimitiveType::String: return TOdbcScalar{parser.GetString()}; - case EPrimitiveType::Yson: return TOdbcScalar{parser.GetYson()}; - case EPrimitiveType::Json: return TOdbcScalar{parser.GetJson()}; - case EPrimitiveType::JsonDocument: return TOdbcScalar{parser.GetJsonDocument()}; - case EPrimitiveType::DyNumber: return TOdbcScalar{parser.GetDyNumber()}; case EPrimitiveType::Uuid: return TOdbcScalar{parser.GetUuid().ToString()}; default: return std::nullopt; } } +std::optional PrimitiveText(TValueParser& parser, EPrimitiveType type) { + switch (type) { + case EPrimitiveType::Utf8: return parser.GetUtf8(); + case EPrimitiveType::String: return parser.GetString(); + case EPrimitiveType::Yson: return parser.GetYson(); + case EPrimitiveType::Json: return parser.GetJson(); + case EPrimitiveType::JsonDocument: return parser.GetJsonDocument(); + case EPrimitiveType::DyNumber: return parser.GetDyNumber(); + default: return std::nullopt; + } +} + std::string FormatInstant(TInstant value, const char* format, bool fraction) { const TString formatted = value.FormatGmTime(format); std::string text(formatted.data(), formatted.size()); @@ -598,10 +639,15 @@ bool PutValue(std::optional value, Put put) { return true; } -bool ConvertParamValue(const TBoundParam& param, EParamType type, TParamValueBuilder& builder) { +bool ConvertParamValue( + const TBoundParam& param, + EParamType type, + TParamValueBuilder& builder, + bool optional) +{ if (auto converted = VisitScalar(type, [&](auto, auto put) { return PutValue(ReadScalar(param), [&](T value) { - put(builder, value); + put(builder, value, optional); }); })) { return *converted; @@ -609,20 +655,25 @@ bool ConvertParamValue(const TBoundParam& param, EParamType type, TParamValueBui switch (type) { case EParamType::Bool: if (const auto value = ReadInteger(param); value && *value >= 0 && *value <= 1) { - builder.OptionalBool(*value != 0); + PutParamValue(builder, optional, + [&](TParamValueBuilder& item) { item.Bool(*value != 0); }); return true; } Error("22003"); return false; case EParamType::Utf8: - return PutValue(ReadText(param), [&](const auto& v) { builder.OptionalUtf8(v); }); + return PutValue(ReadText(param), [&](const auto& v) { + PutParamValue(builder, optional, + [&](TParamValueBuilder& item) { item.Utf8(v); }); + }); case EParamType::String: { if (param.ValueType != SQL_C_BINARY) { return false; } const auto value = ReadBytes(param); if (value) { - builder.OptionalString(*value); + PutParamValue(builder, optional, + [&](TParamValueBuilder& item) { item.String(*value); }); } return value.has_value(); } @@ -633,13 +684,15 @@ bool ConvertParamValue(const TBoundParam& param, EParamType type, TParamValueBui if (!value) { return false; } - if (type == EParamType::Date) { - builder.OptionalDate(*value); - } else if (type == EParamType::Datetime) { - builder.OptionalDatetime(*value); - } else { - builder.OptionalTimestamp(*value); - } + PutParamValue(builder, optional, [&](TParamValueBuilder& item) { + if (type == EParamType::Date) { + item.Date(*value); + } else if (type == EParamType::Datetime) { + item.Datetime(*value); + } else { + item.Timestamp(*value); + } + }); return true; } default: return false; @@ -648,13 +701,17 @@ bool ConvertParamValue(const TBoundParam& param, EParamType type, TParamValueBui } // namespace -SQLRETURN ConvertParam(const TBoundParam& param, TParamValueBuilder& builder) { +SQLRETURN ConvertParam( + const TBoundParam& param, + TParamValueBuilder& builder, + bool optional) +{ LastConvertSqlState = nullptr; const auto type = ResolveParamType(param); if (!type) { return SQL_ERROR; } - if (param.StrLenOrIndPtr && *param.StrLenOrIndPtr == SQL_NULL_DATA) { + if (BoundParamIsNull(param)) { TTypeBuilder itemType; if (type->Type == EParamType::Decimal) { itemType.Decimal(TDecimalType(static_cast(type->Precision), @@ -675,17 +732,17 @@ SQLRETURN ConvertParam(const TBoundParam& param, TParamValueBuilder& builder) { return SQL_ERROR; } try { - builder.BeginOptional() - .Decimal(TDecimalValue(*text, static_cast(type->Precision), - static_cast(type->Scale))) - .EndOptional(); + const TDecimalValue value(*text, static_cast(type->Precision), + static_cast(type->Scale)); + PutParamValue(builder, optional, + [&](TParamValueBuilder& item) { item.Decimal(value); }); } catch (...) { return Error("22018"); } builder.Build(); return SQL_SUCCESS; } - if (!ConvertParamValue(param, type->Type, builder)) { + if (!ConvertParamValue(param, type->Type, builder, optional)) { return SQL_ERROR; } builder.Build(); @@ -772,6 +829,9 @@ SQLRETURN ConvertColumn(TValueParser& parser, SQLSMALLINT targetType, SQLPOINTER return CopyVariable(bytes.data(), bytes.size(), 0, 1, targetValue, bufferLength, strLenOrInd, offset); } + if (const auto text = PrimitiveText(parser, type)) { + return WriteText(*text, targetType, targetValue, bufferLength, strLenOrInd, offset); + } if (const auto scalar = PrimitiveScalar(parser, type)) { return ConvertColumn(*scalar, targetType, targetValue, bufferLength, strLenOrInd, offset); } diff --git a/odbc/src/utils/convert.h b/odbc/src/utils/convert.h index 563f807a84..2050068576 100644 --- a/odbc/src/utils/convert.h +++ b/odbc/src/utils/convert.h @@ -4,8 +4,7 @@ #include -#include -#include +#include "odbc_compat.h" #include #include @@ -14,7 +13,10 @@ namespace NYdb::NOdbc { using TOdbcScalar = std::variant; -SQLRETURN ConvertParam(const TBoundParam& param, TParamValueBuilder& builder); +SQLRETURN ConvertParam( + const TBoundParam& param, + TParamValueBuilder& builder, + bool optional = false); SQLRETURN ConvertColumn(const TOdbcScalar& value, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd, SQLLEN* offset = nullptr); SQLRETURN ConvertColumn(TValueParser& parser, SQLSMALLINT targetType, SQLPOINTER targetValue, diff --git a/odbc/src/utils/cursor.cpp b/odbc/src/utils/cursor.cpp index b8d1248be2..aeddc42bc4 100644 --- a/odbc/src/utils/cursor.cpp +++ b/odbc/src/utils/cursor.cpp @@ -1,73 +1,372 @@ #include "cursor.h" +#include "error_manager.h" #include "types.h" -#include +#include +#include +#include +#include +#include namespace NYdb::NOdbc { +namespace { -class TExecCursor : public ICursor { +using TResultRow = std::vector; +using TResultRows = std::vector; + +std::vector MakeColumnMeta(const TResultSet& resultSet) { + std::vector columns; + columns.reserve(resultSet.GetColumnsMeta().size()); + for (const auto& column : resultSet.GetColumnsMeta()) { + const TYdbTypeInfo type = DescribeYdbType(column.Type); + columns.push_back({column.Name, type.SqlType, type.ColumnSize, type.Nullable, + type.DecimalDigits.value_or(0), type.Unsigned}); + } + return columns; +} + +TResultRow MaterializeRow(TResultSetParser& parser) { + TResultRow row; + row.reserve(parser.ColumnsCount()); + for (size_t column = 0; column < parser.ColumnsCount(); ++column) { + row.push_back(parser.GetValue(column)); + } + return row; +} + +size_t ToSize(SQLULEN value) { + const uintmax_t wide = static_cast(value); + return wide > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(wide); +} + +uintmax_t NegativeMagnitude(SQLLEN value) { + return static_cast(-(value + 1)) + 1; +} + +class TForwardCursor final : public ICursor { public: - explicit TExecCursor(TResultSet resultSet) - : Parser_(resultSet) { - for (const auto& col : resultSet.GetColumnsMeta()) { - const TYdbTypeInfo type = DescribeYdbType(col.Type); - Columns_.push_back({col.Name, type.SqlType, type.ColumnSize, type.Nullable, - type.DecimalDigits.value_or(0), type.Unsigned}); + explicit TForwardCursor(const TResultSet& resultSet) + : ICursor(MakeColumnMeta(resultSet)) + , Parser_(resultSet) + {} + + TFetchResult Fetch( + SQLSMALLINT orientation, + SQLLEN, + SQLULEN rowsetSize, + SQLULEN maxRows) override + { + CurrentRows_.clear(); + if (orientation != SQL_FETCH_NEXT) { + return {}; + } + + const size_t wanted = ToSize(rowsetSize); + const size_t limit = maxRows == 0 + ? Parser_.RowsCount() + : std::min(Parser_.RowsCount(), ToSize(maxRows)); + const size_t remaining = limit - std::min(RowsRead_, limit); + CurrentRows_.reserve(std::min(wanted, remaining)); + while (CurrentRows_.size() < wanted && RowsRead_ < limit + && Parser_.TryNextRow()) { + CurrentRows_.push_back(MaterializeRow(Parser_)); + ++RowsRead_; } + return {static_cast(CurrentRows_.size()), false}; } - bool Fetch() override { - return Parser_.TryNextRow(); + SQLULEN GetRowNumber() const override { + return CurrentRows_.empty() + ? 0 + : static_cast(RowsRead_ - CurrentRows_.size() + 1); } - SQLRETURN GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, + SQLRETURN GetData(SQLULEN row, SQLUSMALLINT columnNumber, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd, SQLLEN* offset) override { - if (columnNumber < 1 || columnNumber > Parser_.ColumnsCount()) { + const size_t rowIndex = ToSize(row); + if (rowIndex >= CurrentRows_.size()) { + return SQL_NO_DATA; + } + if (columnNumber < 1 || columnNumber > Columns_.size()) { return SQL_ERROR; } + TValueParser parser(CurrentRows_[rowIndex][columnNumber - 1]); return ConvertColumn( - Parser_.ColumnParser(columnNumber - 1), targetType, targetValue, bufferLength, strLenOrInd, - offset); + parser, targetType, targetValue, bufferLength, strLenOrInd, offset); } private: TResultSetParser Parser_; + TResultRows CurrentRows_; + size_t RowsRead_ = 0; }; -class TVirtualCursor : public ICursor { +class TStaticCursor final : public ICursor { public: - TVirtualCursor(TColumnSchema columns, TTable table) - : Table_(std::move(table)) { - Columns_.assign(columns.begin(), columns.end()); + explicit TStaticCursor(const TResultSet& resultSet) + : ICursor(MakeColumnMeta(resultSet), resultSet.RowsCount()) + , Parser_(resultSet) + {} + + SQLRETURN GetData(SQLULEN row, SQLUSMALLINT columnNumber, SQLSMALLINT targetType, + SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd, + SQLLEN* offset) override { + const auto rowIndex = CurrentRow(row); + if (!rowIndex) { + return SQL_NO_DATA; + } + if (columnNumber < 1 || columnNumber > Columns_.size()) { + return SQL_ERROR; + } + EnsureRow(*rowIndex); + TValueParser parser(Rows_[*rowIndex][columnNumber - 1]); + return ConvertColumn( + parser, targetType, targetValue, bufferLength, strLenOrInd, offset); } - bool Fetch() override { - return ++Cursor_ < static_cast(Table_.size()); +private: + void EnsureRow(size_t rowIndex) { + while (Rows_.size() <= rowIndex) { + if (!Parser_.TryNextRow()) { + throw TOdbcException( + "HY000", 0, + "ODBC cursor result ended before its declared row count"); + } + Rows_.push_back(MaterializeRow(Parser_)); + } } - SQLRETURN GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, + TResultSetParser Parser_; + TResultRows Rows_; +}; + +class TVirtualCursor final : public ICursor { +public: + TVirtualCursor(TColumnSchema columns, TTable table) + : ICursor(std::vector(columns.begin(), columns.end()), table.size()) + , Table_(std::move(table)) + {} + + SQLRETURN GetData(SQLULEN row, SQLUSMALLINT columnNumber, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd, SQLLEN* offset) override { - if (Cursor_ >= static_cast(Table_.size())) { + const auto rowIndex = CurrentRow(row); + if (!rowIndex) { return SQL_NO_DATA; } - if (Cursor_ < 0 || columnNumber < 1 || columnNumber > Columns_.size()) { + if (columnNumber < 1 || columnNumber > Columns_.size()) { return SQL_ERROR; } - return ConvertColumn(Table_[Cursor_][columnNumber - 1], targetType, + return ConvertColumn(Table_[*rowIndex][columnNumber - 1], targetType, targetValue, bufferLength, strLenOrInd, offset); } private: TTable Table_; - int64_t Cursor_ = -1; }; -std::unique_ptr CreateExecCursor(const NQuery::TExecuteQueryResult& result) { - return result.GetResultSets().empty() - ? nullptr - : std::make_unique(result.GetResultSet(0)); +} // namespace + +TCursorWindow::TCursorWindow(size_t totalRows) + : TotalRows_(totalRows) +{} + +class TCursorWindow::TPositionResolver { +public: + struct TTarget { + EPosition Position; + size_t Start = 0; + bool OverlappedStart = false; + }; + + TPositionResolver( + const TCursorWindow& window, + size_t rowsetSize, + SQLULEN requestedRowsetSize, + size_t visibleRows) + : Window_(window) + , RowsetSize_(rowsetSize) + , RequestedRowsetSize_(static_cast(requestedRowsetSize)) + , VisibleRows_(visibleRows) + {} + + TTarget Resolve(SQLSMALLINT orientation, SQLLEN offset) const { + switch (orientation) { + case SQL_FETCH_NEXT: { + if (Window_.Position_ == EPosition::Before) { + return Rowset(0); + } + return Window_.Position_ == EPosition::After + ? Boundary(EPosition::After) + : Forward(Window_.Start_, Window_.PreviousRowsetSize_); + } + case SQL_FETCH_PRIOR: { + if (Window_.Position_ == EPosition::Before) { + return Boundary(EPosition::Before); + } + const size_t origin = Window_.Position_ == EPosition::After + ? VisibleRows_ + : Window_.Start_; + return Backward(origin, RowsetSize_, origin > 0); + } + case SQL_FETCH_FIRST: + return Rowset(0); + case SQL_FETCH_LAST: + return VisibleRows_ == 0 + ? Boundary(EPosition::After) + : Rowset(VisibleRows_ > RowsetSize_ + ? VisibleRows_ - RowsetSize_ + : 0); + case SQL_FETCH_ABSOLUTE: + return Absolute(offset); + case SQL_FETCH_RELATIVE: { + if (Window_.Position_ == EPosition::Before) { + return offset > 0 ? Absolute(offset) : Boundary(EPosition::Before); + } + if (Window_.Position_ == EPosition::After) { + return offset < 0 ? Absolute(offset) : Boundary(EPosition::After); + } + if (offset >= 0) { + return Forward(Window_.Start_, static_cast(offset)); + } + return Backward( + Window_.Start_, NegativeMagnitude(offset), Window_.Start_ > 0); + } + default: + return Boundary(Window_.Position_ == EPosition::After + ? EPosition::After + : EPosition::Before); + } + } + +private: + static TTarget Boundary(EPosition position) { + return {position}; + } + + static TTarget Rowset(size_t start, bool overlappedStart = false) { + return {EPosition::Rowset, start, overlappedStart}; + } + + TTarget Positive(uintmax_t start) const { + return start > std::numeric_limits::max() + ? Boundary(EPosition::After) + : Rowset(static_cast(start)); + } + + TTarget Forward(size_t origin, uintmax_t distance) const { + return distance > std::numeric_limits::max() - origin + ? Boundary(EPosition::After) + : Positive(static_cast(origin) + distance); + } + + TTarget Backward(size_t origin, uintmax_t distance, bool allowOverlap) const { + if (distance <= origin) { + return Rowset(origin - static_cast(distance)); + } + const uintmax_t rowsBeforeStart = distance - origin; + return allowOverlap && rowsBeforeStart < RequestedRowsetSize_ + ? Rowset(0, true) + : Boundary(EPosition::Before); + } + + TTarget Absolute(SQLLEN offset) const { + if (offset > 0) { + return Positive(static_cast(offset) - 1); + } + if (offset == 0) { + return Boundary(EPosition::Before); + } + return Backward( + VisibleRows_, NegativeMagnitude(offset), VisibleRows_ > 0); + } + + const TCursorWindow& Window_; + size_t RowsetSize_; + uintmax_t RequestedRowsetSize_; + size_t VisibleRows_; +}; + +TFetchResult TCursorWindow::Fetch( + SQLSMALLINT orientation, + SQLLEN offset, + SQLULEN rowsetSize, + SQLULEN maxRows) +{ + if (rowsetSize == 0) { + SetBoundary(EPosition::Before); + return {}; + } + + const size_t rowset = ToSize(rowsetSize); + const size_t visibleRows = maxRows == 0 + ? TotalRows_ + : std::min(TotalRows_, ToSize(maxRows)); + const auto target = TPositionResolver(*this, rowset, rowsetSize, visibleRows) + .Resolve(orientation, offset); + + if (target.Position != EPosition::Rowset) { + SetBoundary(target.Position); + return {}; + } + if (target.Start >= visibleRows + || target.Start > static_cast(std::numeric_limits::max())) { + SetBoundary(EPosition::After); + return {}; + } + + Position_ = EPosition::Rowset; + Start_ = target.Start; + Size_ = std::min(rowset, visibleRows - target.Start); + PreviousRowsetSize_ = rowset; + return {static_cast(Size_), target.OverlappedStart}; +} + +std::optional TCursorWindow::Resolve(SQLULEN row) const { + if (Position_ != EPosition::Rowset + || static_cast(row) >= Size_) { + return std::nullopt; + } + return Start_ + static_cast(row); +} + +SQLULEN TCursorWindow::RowNumber() const { + return Position_ == EPosition::Rowset + ? static_cast(Start_) + 1 + : 0; +} + +void TCursorWindow::SetBoundary(EPosition position) { + Position_ = position; + Start_ = 0; + Size_ = 0; +} + +TFetchResult ICursor::Fetch( + SQLSMALLINT orientation, + SQLLEN offset, + SQLULEN rowsetSize, + SQLULEN maxRows) +{ + return Window_.Fetch(orientation, offset, rowsetSize, maxRows); +} + +SQLULEN ICursor::GetRowNumber() const { + return Window_.RowNumber(); +} + +std::optional ICursor::CurrentRow(SQLULEN row) const { + return Window_.Resolve(row); +} + +std::unique_ptr CreateExecCursor(TResultSet resultSet, bool scrollable) { + if (scrollable) { + return std::make_unique(resultSet); + } + return std::make_unique(resultSet); } std::unique_ptr CreateVirtualCursor(TColumnSchema columns, TTable table) { diff --git a/odbc/src/utils/cursor.h b/odbc/src/utils/cursor.h index 1a5a852017..de3d4312d1 100644 --- a/odbc/src/utils/cursor.h +++ b/odbc/src/utils/cursor.h @@ -1,13 +1,16 @@ #pragma once #include "convert.h" +#include "cursor_window.h" -#include -#include +#include +#include "odbc_compat.h" #include +#include #include #include +#include #include namespace NYdb::NOdbc { @@ -27,8 +30,13 @@ using TTable = std::vector>; class ICursor { public: virtual ~ICursor() = default; - virtual bool Fetch() = 0; - virtual SQLRETURN GetData(SQLUSMALLINT columnNumber, SQLSMALLINT targetType, + virtual TFetchResult Fetch( + SQLSMALLINT orientation, + SQLLEN offset, + SQLULEN rowsetSize, + SQLULEN maxRows); + virtual SQLULEN GetRowNumber() const; + virtual SQLRETURN GetData(SQLULEN row, SQLUSMALLINT columnNumber, SQLSMALLINT targetType, SQLPOINTER targetValue, SQLLEN bufferLength, SQLLEN* strLenOrInd, SQLLEN* offset = nullptr) = 0; const std::vector& GetColumnMeta() const { @@ -36,10 +44,20 @@ class ICursor { } protected: + explicit ICursor(std::vector columns = {}, size_t rows = 0) + : Columns_(std::move(columns)) + , Window_(rows) + {} + + std::optional CurrentRow(SQLULEN row) const; + std::vector Columns_; + +private: + TCursorWindow Window_; }; -std::unique_ptr CreateExecCursor(const NYdb::NQuery::TExecuteQueryResult& result); +std::unique_ptr CreateExecCursor(TResultSet resultSet, bool scrollable); std::unique_ptr CreateVirtualCursor( TColumnSchema columns, diff --git a/odbc/src/utils/cursor_window.h b/odbc/src/utils/cursor_window.h new file mode 100644 index 0000000000..14891cc421 --- /dev/null +++ b/odbc/src/utils/cursor_window.h @@ -0,0 +1,45 @@ +#pragma once + +#include "odbc_compat.h" + +#include +#include + +namespace NYdb::NOdbc { + +struct TFetchResult { + SQLULEN Rows = 0; + bool OverlappedStart = false; +}; + +class TCursorWindow { +public: + explicit TCursorWindow(size_t totalRows = 0); + + TFetchResult Fetch( + SQLSMALLINT orientation, + SQLLEN offset, + SQLULEN rowsetSize, + SQLULEN maxRows); + std::optional Resolve(SQLULEN row) const; + SQLULEN RowNumber() const; + +private: + enum class EPosition { + Before, + Rowset, + After, + }; + + class TPositionResolver; + + void SetBoundary(EPosition position); + + size_t TotalRows_ = 0; + EPosition Position_ = EPosition::Before; + size_t Start_ = 0; + size_t Size_ = 0; + size_t PreviousRowsetSize_ = 0; +}; + +} // namespace NYdb::NOdbc diff --git a/odbc/src/utils/error_manager.h b/odbc/src/utils/error_manager.h index ddb2ee8f35..567af3b4a3 100644 --- a/odbc/src/utils/error_manager.h +++ b/odbc/src/utils/error_manager.h @@ -1,7 +1,6 @@ #pragma once -#include -#include +#include "odbc_compat.h" #include #include #include diff --git a/odbc/src/utils/escape.cpp b/odbc/src/utils/escape.cpp index 9bd8baedd1..c66874ae83 100644 --- a/odbc/src/utils/escape.cpp +++ b/odbc/src/utils/escape.cpp @@ -15,6 +15,19 @@ bool EqualNoCase(std::string_view lhs, std::string_view rhs) { }); } +bool StartsWithKeyword(std::string_view sql, size_t pos, std::string_view keyword) { + if (pos > sql.size() + || (pos > 0 && (std::isalnum(static_cast(sql[pos - 1])) + || sql[pos - 1] == '_')) + || sql.size() - pos < keyword.size() + || !EqualNoCase(sql.substr(pos, keyword.size()), keyword)) { + return false; + } + const size_t end = pos + keyword.size(); + return end == sql.size() + || (!std::isalnum(static_cast(sql[end])) && sql[end] != '_'); +} + struct TSqlScanner { std::string_view Sql_; std::string* Output_; @@ -43,10 +56,15 @@ struct TSqlScanner { return pos; } - size_t SkipQuoted(size_t pos, size_t end) const { + size_t SkipQuoted(size_t pos, size_t end, bool backslashEscapes = true) const { const char quote = Sql_[pos++]; while (pos < end) { - if (Sql_[pos++] != quote) { + const char ch = Sql_[pos++]; + if (backslashEscapes && ch == '\\' && pos < end) { + ++pos; + continue; + } + if (ch != quote) { continue; } if (pos < end && Sql_[pos] == quote) { @@ -73,7 +91,89 @@ struct TSqlScanner { return pos; } - size_t FindClose(size_t open, size_t end, char left, char right) const { + size_t SkipSqlTrivia(size_t pos, size_t end) const { + while (pos < end) { + pos = SkipTrivia(pos, end); + const size_t commentEnd = SkipComment(pos, end); + if (commentEnd == pos) { + break; + } + pos = commentEnd; + } + return pos; + } + + size_t FindStatementEnd(size_t pos, size_t end) const { + while (pos < end) { + const size_t commentEnd = SkipComment(pos, end); + if (commentEnd != pos) { + pos = commentEnd; + } else if (Sql_[pos] == '\'' || Sql_[pos] == '"' || Sql_[pos] == '`') { + pos = std::min(SkipQuoted(pos, end), end); + } else if (Sql_[pos] == ';') { + return pos; + } else { + ++pos; + } + } + return std::string_view::npos; + } + + size_t FindDefineEnd(size_t pos, size_t end) const { + while (pos < end) { + const size_t commentEnd = SkipComment(pos, end); + if (commentEnd != pos) { + pos = commentEnd; + continue; + } + if (Sql_[pos] == '\'' || Sql_[pos] == '"' || Sql_[pos] == '`') { + pos = std::min(SkipQuoted(pos, end), end); + continue; + } + if (StartsWithKeyword(Sql_, pos, "END")) { + const size_t define = SkipSqlTrivia(pos + 3, end); + if (StartsWithKeyword(Sql_, define, "DEFINE")) { + const size_t semicolon = SkipSqlTrivia(define + 6, end); + if (semicolon < end && Sql_[semicolon] == ';') { + return semicolon; + } + } + } + ++pos; + } + return std::string_view::npos; + } + + bool IsNamedExpressionAssignment(size_t pos, size_t end) const { + if (pos >= end || Sql_[pos++] != '$' || pos >= end + || (!std::isalpha(static_cast(Sql_[pos])) && Sql_[pos] != '_')) { + return false; + } + while (++pos < end + && (std::isalnum(static_cast(Sql_[pos])) || Sql_[pos] == '_')) { + } + pos = SkipSqlTrivia(pos, end); + return pos < end && Sql_[pos] == '='; + } + + size_t FindPrologueEnd(size_t pos, size_t end) const { + if (StartsWithKeyword(Sql_, pos, "DEFINE")) { + return FindDefineEnd(pos + 6, end); + } + if (StartsWithKeyword(Sql_, pos, "DECLARE") + || StartsWithKeyword(Sql_, pos, "PRAGMA") + || IsNamedExpressionAssignment(pos, end)) { + return FindStatementEnd(pos, end); + } + return std::string_view::npos; + } + + size_t FindClose( + size_t open, + size_t end, + char left, + char right, + bool backslashEscapes = true) const { size_t depth = 1; for (size_t pos = open + 1; pos < end;) { const size_t commentEnd = SkipComment(pos, end); @@ -82,7 +182,7 @@ struct TSqlScanner { continue; } if (Sql_[pos] == '\'' || Sql_[pos] == '"' || Sql_[pos] == '`') { - pos = SkipQuoted(pos, end); + pos = SkipQuoted(pos, end, backslashEscapes); continue; } if (Sql_[pos] == left) { @@ -104,13 +204,18 @@ struct TSqlScanner { return Sql_.substr(start, pos - start); } - bool ReadQuoted(size_t& pos, size_t end, size_t& valueBegin, size_t& valueEnd) const { + bool ReadQuoted( + size_t& pos, + size_t end, + size_t& valueBegin, + size_t& valueEnd, + bool backslashEscapes = true) const { pos = SkipTrivia(pos, end); if (pos >= end || Sql_[pos] != '\'') { return false; } valueBegin = pos + 1; - pos = SkipQuoted(pos, end); + pos = SkipQuoted(pos, end, backslashEscapes); if (pos != std::string_view::npos) { valueEnd = pos - 1; return true; @@ -139,16 +244,17 @@ struct TSqlScanner { } bool RewriteBrace(size_t& pos, size_t end, bool parameters) { - const size_t close = FindClose(pos, end, '{', '}'); - if (close == std::string_view::npos) { - return false; - } - size_t inner = SkipTrivia(pos + 1, close); - const bool outputCall = inner + 1 < close && Sql_[inner] == '?' && Sql_[inner + 1] == '='; + size_t inner = SkipTrivia(pos + 1, end); + const bool outputCall = inner + 1 < end && Sql_[inner] == '?' && Sql_[inner + 1] == '='; if (outputCall) { inner += 2; } - const std::string_view keyword = ReadIdent(inner, close); + const std::string_view keyword = ReadIdent(inner, end); + const bool escapeClause = EqualNoCase(keyword, "escape"); + const size_t close = FindClose(pos, end, '{', '}', !escapeClause); + if (close == std::string_view::npos || inner > close) { + return false; + } if (outputCall && !EqualNoCase(keyword, "call")) { return false; } @@ -166,7 +272,8 @@ struct TSqlScanner { size_t valueBegin = 0, valueEnd = 0; if ((!EqualNoCase(keyword, "d") && !EqualNoCase(keyword, "t") && !EqualNoCase(keyword, "ts") && !EqualNoCase(keyword, "escape")) - || !ReadQuoted(inner, close, valueBegin, valueEnd) || SkipTrivia(inner, close) != close) { + || !ReadQuoted(inner, close, valueBegin, valueEnd, !escapeClause) + || SkipTrivia(inner, close) != close) { return false; } if (EqualNoCase(keyword, "escape")) { @@ -320,7 +427,7 @@ TParamRewriteResult RewriteSql( } const auto index = static_cast(rawIndex); const std::string prefix = "DECLARE $p" + std::to_string(index) + " AS"; - if (sql.find(prefix) != std::string_view::npos) { + if (GetDeclaredParamOptionality(sql, index).has_value()) { continue; } const auto bound = std::ranges::find(boundParams, index, &TBoundParam::ParamNumber); @@ -331,7 +438,8 @@ TParamRewriteResult RewriteSql( if (!type) { return {.Success = false, .SqlState = "07006", .Message = "Restricted data type attribute violation"}; } - declarations += prefix + " " + type->YqlType + "?;\n"; + declarations += prefix + " " + type->YqlType + + (BoundParamIsNull(*bound) ? "?;\n" : ";\n"); } return {.Sql = declarations.empty() ? std::move(body) : declarations + body}; } @@ -353,6 +461,87 @@ TParamRewriteResult RewriteOdbcSql( return RewriteSql(sql, boundParams, rewriteEscapes); } +std::optional GetDeclaredParamOptionality( + std::string_view sql, + SQLUSMALLINT paramNumber) +{ + const std::string param = "$p" + std::to_string(paramNumber); + const TSqlScanner scanner{sql, nullptr, false}; + for (size_t pos = scanner.SkipSqlTrivia(0, sql.size()); pos < sql.size();) { + const bool isDeclare = StartsWithKeyword(sql, pos, "DECLARE"); + if (!isDeclare && !StartsWithKeyword(sql, pos, "PRAGMA")) { + break; + } + const size_t semicolon = scanner.FindStatementEnd(pos, sql.size()); + if (semicolon == std::string_view::npos) { + break; + } + size_t token = scanner.SkipSqlTrivia(pos + (isDeclare ? 7 : 6), semicolon); + if (isDeclare && semicolon - token >= param.size() + && sql.substr(token, param.size()) == param + && (token + param.size() == semicolon + || (!std::isalnum(static_cast(sql[token + param.size()])) + && sql[token + param.size()] != '_'))) { + token = scanner.SkipSqlTrivia(token + param.size(), semicolon); + if (StartsWithKeyword(sql, token, "AS")) { + const std::string_view type = TrimTrailingSqlTrivia( + sql.substr(token + 2, semicolon - token - 2)); + return !type.empty() && type.back() == '?'; + } + } + pos = scanner.SkipSqlTrivia(semicolon + 1, sql.size()); + } + return std::nullopt; +} + +std::string_view TrimTrailingSqlTrivia(std::string_view sql) { + const TSqlScanner scanner{sql, nullptr, false}; + size_t codeEnd = 0; + for (size_t pos = 0; pos < sql.size();) { + const size_t commentEnd = scanner.SkipComment(pos, sql.size()); + if (commentEnd != pos) { + if (sql[pos] == '/' && sql.find("*/", pos + 2) == std::string_view::npos) { + return sql; + } + pos = commentEnd; + } else if (std::isspace(static_cast(sql[pos]))) { + ++pos; + } else if (sql[pos] == '\'' || sql[pos] == '"' || sql[pos] == '`') { + pos = std::min(scanner.SkipQuoted(pos, sql.size()), sql.size()); + codeEnd = pos; + } else { + codeEnd = ++pos; + } + } + return sql.substr(0, codeEnd); +} + +std::string_view GetSqlStatement(std::string_view sql) { + const TSqlScanner scanner{sql, nullptr, false}; + size_t statement = scanner.SkipSqlTrivia(0, sql.size()); + while (statement < sql.size()) { + const size_t prologueEnd = scanner.FindPrologueEnd(statement, sql.size()); + if (prologueEnd == std::string_view::npos) { + break; + } + const size_t next = scanner.SkipSqlTrivia(prologueEnd + 1, sql.size()); + if (next == sql.size()) { + break; + } + statement = next; + } + return sql.substr(statement); +} + +bool HasMultipleSqlStatements(std::string_view sql) { + const TSqlScanner scanner{sql, nullptr, false}; + const std::string_view statement = GetSqlStatement(sql); + const size_t statementBegin = sql.size() - statement.size(); + const size_t statementEnd = scanner.FindStatementEnd(statementBegin, sql.size()); + return statementEnd != std::string_view::npos + && scanner.SkipSqlTrivia(statementEnd + 1, sql.size()) < sql.size(); +} + SQLSMALLINT CountOdbcParams(std::string_view sql) { TSqlScanner scanner{sql, nullptr, false}; scanner.Scan(0, sql.size(), true); @@ -364,23 +553,9 @@ SQLSMALLINT CountOdbcParams(std::string_view sql) { bool StartsWithSqlStatement( std::string_view sql, std::initializer_list keywords) { - size_t pos = 0; - while (pos < sql.size()) { - if (std::isspace(static_cast(sql[pos]))) { - ++pos; - } else if (pos + 1 < sql.size() && sql[pos] == '-' && sql[pos + 1] == '-') { - const size_t newline = sql.find('\n', pos + 2); - pos = newline == std::string_view::npos ? sql.size() : newline + 1; - } else if (pos + 1 < sql.size() && sql[pos] == '/' && sql[pos + 1] == '*') { - const size_t close = sql.find("*/", pos + 2); - pos = close == std::string_view::npos ? sql.size() : close + 2; - } else { - break; - } - } + sql = GetSqlStatement(sql); return std::ranges::any_of(keywords, [&](std::string_view keyword) { - return sql.size() - pos >= keyword.size() - && EqualNoCase(sql.substr(pos, keyword.size()), keyword); + return StartsWithKeyword(sql, 0, keyword); }); } diff --git a/odbc/src/utils/param_rewrite.h b/odbc/src/utils/param_rewrite.h index 78d8dd8b69..44004ad1e9 100644 --- a/odbc/src/utils/param_rewrite.h +++ b/odbc/src/utils/param_rewrite.h @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace NYdb::NOdbc { @@ -23,6 +24,14 @@ TParamRewriteResult RewriteOdbcSql( const std::vector& boundParams, bool rewriteEscapes); +std::optional GetDeclaredParamOptionality( + std::string_view sql, + SQLUSMALLINT paramNumber); + +std::string_view TrimTrailingSqlTrivia(std::string_view sql); +std::string_view GetSqlStatement(std::string_view sql); +bool HasMultipleSqlStatements(std::string_view sql); + SQLSMALLINT CountOdbcParams(std::string_view sql); bool StartsWithSqlStatement( diff --git a/odbc/src/utils/sql_type_map.h b/odbc/src/utils/sql_type_map.h index c36ab4f081..6d1343578c 100644 --- a/odbc/src/utils/sql_type_map.h +++ b/odbc/src/utils/sql_type_map.h @@ -2,8 +2,7 @@ #include "bindings.h" -#include -#include +#include "odbc_compat.h" #include #include diff --git a/odbc/src/utils/types.cpp b/odbc/src/utils/types.cpp index 48575c72f8..cfbf922ba9 100644 --- a/odbc/src/utils/types.cpp +++ b/odbc/src/utils/types.cpp @@ -49,6 +49,7 @@ SQLULEN GetColumnSize(SQLSMALLINT sqlType) { TYdbTypeInfo DescribeYdbType(const TType& type) { TYdbTypeInfo info; + info.TypeName = type.ToString(); TTypeParser parser(type); info.Nullable = parser.GetKind() == TTypeParser::ETypeKind::Optional || parser.GetKind() == TTypeParser::ETypeKind::Null @@ -59,6 +60,9 @@ TYdbTypeInfo DescribeYdbType(const TType& type) { parser.OpenOptional(); ++optionals; } + for (size_t optional = 0; optional < optionals && info.TypeName.ends_with('?'); ++optional) { + info.TypeName.pop_back(); + } if (parser.GetKind() == TTypeParser::ETypeKind::Decimal) { const TDecimalType decimal = parser.GetDecimal(); info.SqlType = SQL_DECIMAL; diff --git a/odbc/src/utils/types.h b/odbc/src/utils/types.h index 2c897193d8..fd1b34680f 100644 --- a/odbc/src/utils/types.h +++ b/odbc/src/utils/types.h @@ -2,13 +2,14 @@ #include -#include -#include +#include "odbc_compat.h" #include +#include namespace NYdb::NOdbc { struct TYdbTypeInfo { + std::string TypeName; SQLSMALLINT SqlType = SQL_UNKNOWN_TYPE; SQLULEN ColumnSize = 4096; SQLSMALLINT Nullable = SQL_NO_NULLS; diff --git a/odbc/src/utils/util.h b/odbc/src/utils/util.h index adb5d5d490..deacd84fb8 100644 --- a/odbc/src/utils/util.h +++ b/odbc/src/utils/util.h @@ -2,8 +2,7 @@ #include -#include -#include +#include "odbc_compat.h" #include #include diff --git a/odbc/tests/CMakeLists.txt b/odbc/tests/CMakeLists.txt index 916fa4a0b8..e015b28664 100644 --- a/odbc/tests/CMakeLists.txt +++ b/odbc/tests/CMakeLists.txt @@ -1,17 +1,22 @@ set(YDB_ODBC_TEST_CONFIG_DIR "${CMAKE_BINARY_DIR}/odbc") file(MAKE_DIRECTORY "${YDB_ODBC_TEST_CONFIG_DIR}") +if (CMAKE_CONFIGURATION_TYPES) + set(YDB_ODBC_TEST_INI "${YDB_ODBC_TEST_CONFIG_DIR}/$/odbc.ini") +else() + set(YDB_ODBC_TEST_INI "${YDB_ODBC_TEST_CONFIG_DIR}/odbc.ini") +endif() set(YDB_ODBC_DSN_SERVER "localhost:2136" CACHE STRING "YDB endpoint in odbc.ini generated for ODBC integration tests") set(YDB_ODBC_DSN_DATABASE "/local" CACHE STRING "YDB database path in odbc.ini generated for ODBC integration tests") -file(WRITE "${YDB_ODBC_TEST_CONFIG_DIR}/odbc.ini" +file(GENERATE OUTPUT "${YDB_ODBC_TEST_INI}" CONTENT "[ODBC Data Sources] YDB=YDB ODBC Driver [YDB] -Driver=YDB +Driver=$ Description=YDB Database Connection Server=${YDB_ODBC_DSN_SERVER} Database=${YDB_ODBC_DSN_DATABASE} diff --git a/odbc/tests/frameworks/README.md b/odbc/tests/frameworks/README.md index 576b05d4e9..1d5751d941 100644 --- a/odbc/tests/frameworks/README.md +++ b/odbc/tests/frameworks/README.md @@ -4,5 +4,22 @@ Adapters declare commands; custom runners and checksum-locked archive frameworks Qt source is downloaded only in CI under its GPL-3.0 Qt exception option; it is neither vendored nor included in SDK artifacts. The adapter changes fixture SQL only and reports every upstream database test function as passed or explicitly unsupported. + +For discovered suites, `required` patterns define tests that must pass. Expected outcomes +are declared in top-level `classifications` groups in `registry.yaml`: `outcome` is either +`unsupported` or `skipped`, `reason` is the manually maintained text shown in Allure, and +`tests` maps consumer names to test patterns. The raw framework error remains in the Allure +trace. New tests covered by `required` need no registry update. Every exception pattern must +match a result, conflicting outcomes are rejected, and an entire suite cannot be classified +as an exception with a trailing `.*` pattern. + +```yaml +classifications: + - outcome: unsupported + reason: Manually maintained Allure reason. + tests: + consumer-name: [consumer-name.*.suite.test] +``` + Validate with `python3 odbc/tests/frameworks/harness.py registry` and `python3 -m unittest odbc.tests.test_integration_harness`. diff --git a/odbc/tests/frameworks/harness.py b/odbc/tests/frameworks/harness.py index 5e903e4a09..da481213e3 100755 --- a/odbc/tests/frameworks/harness.py +++ b/odbc/tests/frameworks/harness.py @@ -26,24 +26,55 @@ class HarnessError(ValueError): def require(condition, message): if not condition: raise HarnessError(message) -def glob_ids(names, pattern): +def expand_pattern(pattern): match = re.search(r"\{([^{}]+)\}", pattern) - patterns = [pattern] if not match else [pattern[:match.start()] + value + pattern[match.end():] - for value in match.group(1).split(",")] - return {name for name in names if any(fnmatch.fnmatch(name, item) for item in patterns)} + if not match: + return [pattern] + return [expanded for value in match.group(1).split(",") + for expanded in expand_pattern(pattern[:match.start()] + value + pattern[match.end():])] +def glob_ids(names, pattern): + return {name for name in names if any(fnmatch.fnmatch(name, item) for item in expand_pattern(pattern))} def read_yaml(path): try: return yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as error: raise HarnessError(f"cannot read {path}: {error}") from error +def load_classifications(data, consumers): + consumer_ids = {item.get("id") for item in consumers if isinstance(item, dict)} + grouped = {consumer: {"unsupported": {}, "skipped": {}} for consumer in consumer_ids} + classifications = data.get("classifications", []) + require(isinstance(classifications, list), "classifications must be a list") + for index, group in enumerate(classifications): + require(isinstance(group, dict), f"classification {index}: must be a mapping") + outcome, reason, tests = group.get("outcome"), group.get("reason"), group.get("tests") + require(outcome in {"unsupported", "skipped"}, + f"classification {index}: invalid outcome") + require(isinstance(reason, str) and reason.strip(), + f"classification {index}: reason must be a non-empty string") + require(isinstance(tests, dict) and tests, + f"classification {index}: tests must be a non-empty consumer mapping") + for consumer, patterns in tests.items(): + require(consumer in consumer_ids, f"classification {index}: unknown consumer: {consumer}") + require(isinstance(patterns, list) and patterns + and all(isinstance(pattern, str) and pattern for pattern in patterns), + f"classification {index}: {consumer} patterns must be non-empty strings") + for pattern in patterns: + reasons = grouped[consumer][outcome].setdefault(pattern, []) + if reason not in reasons: + reasons.append(reason) + return {consumer: {outcome: {pattern: "; ".join(reasons) + for pattern, reasons in patterns.items()} + for outcome, patterns in outcomes.items()} + for consumer, outcomes in grouped.items()} def load_registry(path=REGISTRY): - data = read_yaml(path); require(isinstance(data, dict) and data.get("schema_version") == 1, + data = read_yaml(path); require(isinstance(data, dict) and data.get("schema_version") == 2, "unsupported registry schema") ydb = data.get("ydb", {}); consumers = data.get("consumers") require(IMAGE_RE.match(str(ydb.get("image", ""))), "YDB image must be digest-pinned") require(ydb.get("endpoint") and str(ydb.get("database", "")).startswith("/"), "YDB endpoint and absolute database are required") require(isinstance(consumers, list) and consumers, "registry has no consumers") + classifications = load_classifications(data, consumers) seen = set() for item in consumers: consumer = item.get("id") if isinstance(item, dict) else None; adapter = path.parent / str(consumer) @@ -69,17 +100,31 @@ def load_registry(path=REGISTRY): f"{consumer}: invalid declarative test") if test.get("output_regex"): re.compile(test["output_regex"]) expected = item.get("expected", {}) + require(isinstance(expected, dict), f"{consumer}: expected results must be a mapping") + require(not ({"unsupported", "skipped"} & set(expected)), + f"{consumer}: classifications must be declared in the top-level classification list") + expected.update(classifications[consumer]) + discovered = expected.get("discovered", False) required = expected.get("required", []) unsupported = expected.get("unsupported", {}) skipped = expected.get("skipped", {}) - require(isinstance(required, list) and (required or expected.get("discovered")), + require(isinstance(discovered, bool), f"{consumer}: discovered must be boolean") + require(isinstance(required, list) and (required or discovered), f"{consumer}: required tests are empty") + require(all(isinstance(pattern, str) and pattern for pattern in required) + and len(required) == len(set(required)), + f"{consumer}: required tests contain invalid or duplicate patterns") require(isinstance(unsupported, dict), f"{consumer}: unsupported tests must be a mapping") require(isinstance(skipped, dict), f"{consumer}: skipped tests must be a mapping") - require(not (set(unsupported) & set(skipped)) and all(unsupported.values()) and all(skipped.values()), + require(all(isinstance(pattern, str) and pattern and isinstance(reason, str) and reason.strip() + for patterns in (unsupported, skipped) for pattern, reason in patterns.items()), + f"{consumer}: expected-result patterns and reasons must be non-empty strings") + require(not (set(unsupported) & set(skipped)), f"{consumer}: duplicate or unexplained expectations") - require(not any(re.fullmatch(r"qt\.\*\.[^.]+\.\*", pattern) for pattern in unsupported), - f"{consumer}: suite-wide Qt exclusions are forbidden") + broad = [pattern for patterns in (unsupported, skipped) for pattern in patterns + if pattern.endswith(".*")] + require(not discovered or not broad, + f"{consumer}: suite-wide expected-result patterns are forbidden: {', '.join(broad)}") require(not tests or {test["id"] for test in tests} == set(required), f"{consumer}: declarative tests differ from required tests") modes = item.get("modes", []) @@ -302,7 +347,73 @@ def convert_allure(native, output, metadata): ("consumer", "connection_mode", "run_id", "runtime_version", "driver_commit", "package_sha256", "ydb_image", "endpoint", "database", "upstream_revision", "upstream_sha256")} (output / "environment.properties").write_text("".join(f"{key}={value}\n" for key, value in sorted(properties.items()))) -def validate_results(consumer, native, allure): +def validate_classified_results(actual, expectations): + errors = [] + patterns = { + "required": expectations.get("required", []), + "unsupported": expectations.get("unsupported", {}), + "skipped": expectations.get("skipped", {}), + } + matches = {classification: {pattern: glob_ids(actual, pattern) for pattern in values} + for classification, values in patterns.items()} + for classification, classified in matches.items(): + for pattern, test_ids in classified.items(): + if not test_ids and (expectations.get("discovered") or classification != "required"): + errors.append(f"{classification} expectation pattern matched no tests: {pattern}") + for test_id, test in actual.items(): + exceptions = [(classification, pattern) + for classification in ("unsupported", "skipped") + for pattern, test_ids in matches[classification].items() if test_id in test_ids] + outcomes = {classification for classification, _ in exceptions} + if len(outcomes) > 1: + labels = ", ".join(f"{classification}:{pattern}" for classification, pattern in exceptions) + errors.append(f"{test_id}: ambiguous expected result: {labels}") + continue + if exceptions: + classification = exceptions[0][0] + elif any(test_id in test_ids for test_ids in matches["required"].values()): + classification = "required" + else: + errors.append(f"{test_id}: missing result expectation") + continue + allowed = ({"passed"} if classification == "required" else {"skipped"} + if classification == "skipped" else {"broken", "failed", "skipped"}) + if test.get("status") not in allowed: + errors.append(f"{test_id}: unexpected status {test.get('status')} for {classification}") + if classification != "required" and not test.get("message"): + errors.append(f"{test_id}: expected {classification} result has no reason") + return errors +def annotate_expected_results(consumer, native): + document = json.loads(native.read_text()) + expectations = consumer["expected"] + for test in document.get("tests", []): + if not isinstance(test, dict) or not test.get("id"): + continue + matches = [(classification, reason) + for classification in ("unsupported", "skipped") + for pattern, reason in expectations.get(classification, {}).items() + if glob_ids({test["id"]}, pattern)] + outcomes = {classification for classification, _ in matches} + if len(outcomes) != 1: + continue + classification = matches[0][0] + reasons = list(dict.fromkeys(reason for _, reason in matches)) + status = test.get("status") + allowed = {"skipped"} if classification == "skipped" else {"broken", "failed", "skipped"} + if status not in allowed: + continue + details = [] + if test.get("message"): + details.append(f"Reported {status.upper()}: {test['message']}") + if test.get("trace"): + details.append(test["trace"]) + if details: + test["trace"] = "\n\n".join(details) + test["original_status"] = status + prefix = "Expected upstream skip" if classification == "skipped" else "Expected unsupported" + test["message"] = f"{prefix}: {'; '.join(reasons)}" + native.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") +def validate_results(consumer, native, allure, test_rc=0): errors, tests = [], [] try: tests = json.loads(native.read_text())["tests"] @@ -315,37 +426,18 @@ def validate_results(consumer, native, allure): if not test_id or test_id in actual: errors.append(f"missing or duplicate test id: {test_id}") else: actual[test_id] = test expectations = consumer["expected"]; required = set(expectations["required"]) - unsupported = set(expectations.get("unsupported", {})) - skipped = set(expectations.get("skipped", {})); expected = required | unsupported | skipped infrastructure = [test for test_id, test in actual.items() if test_id.endswith(".infrastructure")] if expectations.get("discovered") and infrastructure: for test in infrastructure: errors.append(f"{test['id']}: {test.get('message', 'infrastructure failure')}") - expected = set(actual) - elif expectations.get("discovered"): - required_matches = {pattern: glob_ids(actual, pattern) for pattern in required} - matched = {pattern: glob_ids(actual, pattern) for pattern in unsupported} - skipped_matches = {pattern: glob_ids(actual, pattern) for pattern in skipped} - for pattern, ids in list(required_matches.items()) + list(matched.items()) + list(skipped_matches.items()): - if not ids: errors.append(f"expectation pattern matched no tests: {pattern}") - for test_id, test in actual.items(): - is_required = any(test_id in ids for ids in required_matches.values()) - is_unsupported = any(test_id in ids for ids in matched.values()) - is_skipped = any(test_id in ids for ids in skipped_matches.values()) - wanted = {"skipped"} if is_skipped else {"broken", "failed", "skipped"} if is_unsupported else {"passed"} if is_required else None - if wanted is None: - errors.append(f"{test_id}: missing discovered expectation"); continue - if test.get("status") not in wanted: errors.append(f"{test_id}: unexpected status {test.get('status')}") - if wanted != {"passed"} and not test.get("message"): errors.append(f"{test_id}: no unsupported reason") - expected = set(actual) - for test_id in sorted(expected - set(actual)): errors.append(f"missing test: {test_id}") - for test_id in sorted(set(actual) - expected): errors.append(f"unexpected test: {test_id}") - for test_id in sorted(expected & set(actual)): - status = actual[test_id].get("status") - if ((test_id in required and status != "passed") - or (test_id in unsupported and status not in {"broken", "failed", "skipped"}) - or (test_id in skipped and status != "skipped")): - errors.append(f"{test_id}: unexpected status {status}") + else: + errors += validate_classified_results(actual, expectations) + if not expectations.get("discovered"): + for test_id in sorted(required - set(actual)): errors.append(f"missing test: {test_id}") + for test_id in sorted(set(actual) - required): errors.append(f"unexpected test: {test_id}") + if test_rc and tests and all(test.get("status") == "passed" for test in tests + if isinstance(test, dict)): + errors.append(f"test command exited {test_rc} although every reported test passed") if len(list(allure.glob("*-result.json"))) != len(tests): errors.append("Allure/native result count differs") validation = {"consumer": consumer["id"], "connection_mode": os.environ.get("ODBC_TEST_MODE", "all"), "ok": not errors, "test_count": len(tests), "errors": errors} @@ -409,11 +501,12 @@ def run_consumer(consumer_id, mode): append_result(results_file, infrastructure_test_id(consumer_id, mode), f"{consumer_id} test infrastructure", "broken", now, now, error or f"test command exited {test_rc} without results") try: + annotate_expected_results(consumer, results_file) convert_allure(results_file, allure, metadata) - validation_errors = validate_results(consumer, results_file, allure) + validation_errors = validate_results(consumer, results_file, allure, test_rc) except Exception as exception: print(f"result finalization failed: {exception}", file=sys.stderr); return 1 - return int(bool(error or test_rc or validation_errors)) + return int(bool(error or validation_errors)) def aggregate(source, output): expected = {item["run_id"] for item in consumer_runs(load_registry())}; output.mkdir(parents=True, exist_ok=True) native_out, allure_out = output / "native", output / "allure-results" diff --git a/odbc/tests/frameworks/qt/run-tests b/odbc/tests/frameworks/qt/run-tests index 8d96f9c4d9..902d9c9104 100755 --- a/odbc/tests/frameworks/qt/run-tests +++ b/odbc/tests/frameworks/qt/run-tests @@ -1,9 +1,8 @@ #!/usr/bin/env python3 """Build and execute Qt's upstream database-backed QtSql tests against QODBC.""" -import fnmatch, json, os, re, shutil, subprocess, time +import json, os, shutil, subprocess, time import xml.etree.ElementTree as ET from pathlib import Path -import yaml ADAPTER = Path(os.environ["ODBC_ADAPTER_DIR"]) UPSTREAM = Path(os.environ["ODBC_UPSTREAM_DIR"]) NATIVE = Path(os.environ["ODBC_NATIVE_RESULTS_DIR"]) @@ -38,18 +37,10 @@ def prepare_source(): (NATIVE / "fixture-patch.log").write_text(applied.stdout or "") if applied.returncode: raise RuntimeError(f"fixture patch exited {applied.returncode}:\n{(applied.stdout or '').strip()}") -def matches(name, pattern): - match = re.search(r"\{([^{}]+)\}", pattern) - patterns = [pattern] if not match else [pattern[:match.start()] + value + pattern[match.end():] for value in match.group(1).split(",")] - return any(fnmatch.fnmatch(name, item) for item in patterns) -def expectations(): - registry = yaml.safe_load((Path(os.environ["ODBC_HARNESS_DIR"]) / "registry.yaml").read_text()); qt = next(item for item in registry["consumers"] if item["id"] == "qt") - return (qt["expected"].get("required", []), qt["expected"].get("unsupported", {}), - qt["expected"].get("skipped", {})) def main(): if MODE not in DATABASES: raise RuntimeError(f"unsupported ODBC test mode: {MODE}") prepare_source(); run(["cmake", "-G", "Ninja", "-S", ADAPTER, "-B", BUILD, f"-DQT_SQL_SOURCE={SOURCE}"], check=True) - run(["cmake", "--build", BUILD, "--parallel"], check=True); required, unsupported, skipped = expectations(); results, bad = [], False + run(["cmake", "--build", BUILD, "--parallel"], check=True); results, bad = [], False for mode, database in ((MODE, DATABASES[MODE]),): config = Path(f"/work/qt-databases-{mode}.json"); config.write_text(json.dumps({"entries": [{"driver": "QODBC", "name": database}]}) + "\n") env = os.environ | {"QT_TEST_DATABASES_FILE": str(config), "QT_QPA_PLATFORM": "offscreen"} @@ -90,26 +81,12 @@ def main(): problem = (errors or failures or problems)[0] status = "broken" if errors else "failed" if failures else "skipped" message, trace = problem_details(problem) - required_match = any(matches(test_id, pattern) for pattern in required) - matched = [pattern for pattern in unsupported if matches(test_id, pattern)] - skipped_match = [pattern for pattern in skipped if matches(test_id, pattern)] - original_status = "" - if skipped_match and status == "skipped": - original_status, reason = status, skipped[skipped_match[0]] - trace = f"Qt reported SKIPPED: {message}" + (f"\n\n{trace}" if trace else "") - message = f"Expected upstream skip: {reason}" - elif matched and status in {"broken", "failed", "skipped"}: - original_status, reason = status, unsupported[matched[0]] - trace = f"Qt reported {status.upper()}: {message}" + (f"\n\n{trace}" if trace else "") - message = f"Expected unsupported: {reason}" - elif status != "passed" or not required_match or matched or skipped_match: - bad = True + if status != "passed": bad = True results.append({"id": test_id, "name": f"Qt {target}: {function} ({mode})", "status": status, "message": message, "start": started, "stop": stopped, "attachments": [{"name": log.name, "path": log.name, "type": "text/plain"}, {"name": xml.name, "path": xml.name, "type": "application/xml"}], - **({"trace": trace} if trace else {}), - **({"original_status": original_status} if original_status else {})}) + **({"trace": trace} if trace else {})}) (NATIVE / "results.json").write_text(json.dumps({"schema_version": 1, "tests": results}, indent=2, sort_keys=True) + "\n") return int(bad) if __name__ == "__main__": raise SystemExit(main()) diff --git a/odbc/tests/frameworks/qt/ydb.patch b/odbc/tests/frameworks/qt/ydb.patch index 53eb577830..e96875b424 100644 --- a/odbc/tests/frameworks/qt/ydb.patch +++ b/odbc/tests/frameworks/qt/ydb.patch @@ -3,22 +3,63 @@ # Applies to qtbase revision e3e40c44d3f998a433a6a1080297c5f28e9a768f. # # YDB requires table schemas and writes that are explicit enough for its -# distributed SQL model. The Qt test implementations and assertions remain -# upstream; this patch only adapts portable fixtures where QODBC exercises YDB: +# distributed SQL model. This patch keeps Qt's test intent intact while making +# the fixture SQL and backend-specific expectations valid for QODBC over YDB. +# Every edit below is covered by the following rationale map. # -# - SQL fixture types use YQL-compatible scalar names. -# - Every QODBC fixture table has an explicit primary key. -# - INSERT statements name their destination columns. -# - Nullable fixture columns do not use unsupported NOT NULL combinations. -# - Statements that mutate rows update non-key columns. -# - Strict YQL assignments use explicit casts and textual Decimal fixtures. -# - Explicit SELECT lists preserve the fixture's declared column order. -# - Aggregate metadata expectations use YDB's native result types. -# - Fixture cleanup does not depend on catalog metadata that the suite tests. -# - Whitespace identifiers remain an explicit unsupported capability. +# Cross-cutting fixture adaptations: # -# Keep changes here limited to fixture SQL required by YDB semantics. Do not -# change Qt assertions or convert product limitations into passing tests. +# - Replace vendor or loosely typed SQL names with the corresponding YQL types: +# Utf8/String, signed and unsigned integer widths, Float/Double, Bool, +# Date/Datetime/Timestamp, and explicit Decimal precision and scale. +# - Give every YDB table a physical primary key. Where a test inserts several +# rows, provide distinct key values; where it updates data, update a non-key +# column because YDB primary-key columns are immutable. +# - Name INSERT destinations explicitly so values do not depend on physical +# schema order, and use explicit SELECT lists where result order matters. +# - Use typed values or explicit CAST expressions where YQL deliberately does +# not apply the fixture's implicit cross-type assignment. +# - Preserve upstream column-name assertions when a YQL type spelling would +# otherwise change the generated fixture field name. +# - Use YDB spellings for built-in functions and backend-native aggregate +# result metadata where the upstream test already branches by database. +# - Normalize unquoted QODBC identifiers to YDB's lowercase catalog form. +# - Keep deliberately invalid or multi-statement fixtures invalid using syntax +# YDB can parse far enough to exercise the intended error path. +# +# Per-file rationale: +# +# - kernel/qsqldatabase/tst_databases.h: disable whitespace-name fixtures for +# QODBC, which cannot represent YDB table paths with that test convention; +# clean tables with DROP TABLE IF EXISTS so cleanup does not depend on the +# catalog metadata being tested. +# - kernel/qsqldatabase/tst_qsqldatabase.cpp: adapt the field/type matrix, +# primary keys, explicit DML columns, decimal casts, unsigned and boolean +# fixtures, temporal types, and identifier casing; field-name overrides keep +# the original metadata assertions meaningful after type substitution. +# - kernel/qsqldriver/tst_qsqldriver.cpp: make relational driver fixtures use +# YQL types, keys, and explicit columns, and normalize unquoted table names. +# - kernel/qsqlquery/tst_qsqlquery.cpp: adapt query fixtures for YQL types, +# keys, nullable columns, explicit columns, non-key updates, Decimal and array +# bindings, built-ins, deterministic SELECT order, native aggregate metadata, +# and the intended malformed-statement paths. +# - kernel/qsqlthread/tst_qsqlthread.cpp: give threaded fixtures keys and YQL +# types and make their inserts explicit; the threading assertions are +# unchanged. +# - models/qsqlquerymodel/tst_qsqlquerymodel.cpp: give model fixtures keys and +# YQL types and make seed inserts explicit; model behavior is unchanged. +# - models/qsqlrelationaldelegate/tst_qsqlrelationaldelegate.cpp and +# models/qsqlrelationaltablemodel/tst_qsqlrelationaltablemodel.cpp: give all +# relation tables YQL types and stable keys and make seed data explicit. +# - models/qsqltablemodel/tst_qsqltablemodel.cpp: adapt table-model schemas and +# seed data, normalize backend-specific key syntax, and represent the NULL in +# an Int32 field as a typed null. An invalid QVariant has no Qt type, so QODBC +# otherwise exposes it as SQL_VARBINARY even though this fixture knows the +# destination field is Int32. +# +# Keep changes limited to fixture compatibility and explicit backend branches. +# Do not weaken assertions or turn an unimplemented driver capability into a +# passing result. diff -ruN a/kernel/qsqldatabase/tst_databases.h b/kernel/qsqldatabase/tst_databases.h --- a/kernel/qsqldatabase/tst_databases.h 2022-12-12 13:23:31.000000000 +0300 @@ -1105,23 +1146,29 @@ diff -ruN a/kernel/qsqlquery/tst_qsqlquery.cpp b/kernel/qsqlquery/tst_qsqlquery. + QVERIFY_SQL(q, exec(QLatin1String("SELECT id, datefield FROM ") + tableName)); while (q.next()) QVERIFY(q.value(1).toDateTime().isValid()); -@@ -3558,11 +3571,11 @@ - const QString planets = qTableName("Planet", __FILE__, db); - - q.exec("drop table " + planets); +@@ -3561,5 +3574,5 @@ - QVERIFY_SQL(q, exec(QLatin1String("create table %1 (Name varchar(20))").arg(planets))); - QVERIFY_SQL(q, exec(QLatin1String("insert into %1 VALUES ('Mercury')").arg(planets))); - QVERIFY_SQL(q, exec(QLatin1String("insert into %1 VALUES ('Venus')").arg(planets))); - QVERIFY_SQL(q, exec(QLatin1String("insert into %1 VALUES ('Earth')").arg(planets))); - QVERIFY_SQL(q, exec(QLatin1String("insert into %1 VALUES ('Mars')").arg(planets))); -+ QVERIFY_SQL(q, exec(QLatin1String("create table %1 (Name Utf8, primary key (Name))").arg(planets))); -+ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Name) VALUES ('Mercury')").arg(planets))); -+ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Name) VALUES ('Venus')").arg(planets))); -+ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Name) VALUES ('Earth')").arg(planets))); -+ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Name) VALUES ('Mars')").arg(planets))); - - QVERIFY_SQL(q, exec("SELECT Name FROM " + planets)); ++ QVERIFY_SQL(q, exec(QLatin1String("create table %1 (Id integer, Name Utf8, primary key (Id))").arg(planets))); ++ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Id, Name) VALUES (1, 'Mercury')").arg(planets))); ++ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Id, Name) VALUES (2, 'Venus')").arg(planets))); ++ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Id, Name) VALUES (3, 'Earth')").arg(planets))); ++ QVERIFY_SQL(q, exec(QLatin1String("insert into %1 (Id, Name) VALUES (4, 'Mars')").arg(planets))); +@@ -3567,9 +3580,9 @@ +- QVERIFY_SQL(q, exec("SELECT Name FROM " + planets)); ++ QVERIFY_SQL(q, exec("SELECT Name FROM " + planets + " ORDER BY Id")); + QVERIFY_SQL(q, seek(3)); + QCOMPARE(q.value(0).toString(), u"Mars"); + QVERIFY_SQL(q, seek(1)); + QCOMPARE(q.value(0).toString(), u"Venus"); +- QVERIFY_SQL(q, exec("SELECT Name FROM " + planets)); ++ QVERIFY_SQL(q, exec("SELECT Name FROM " + planets + " ORDER BY Id")); QVERIFY_SQL(q, seek(3)); + QCOMPARE(q.value(0).toString(), u"Mars"); + QVERIFY_SQL(q, seek(0)); @@ -3587,7 +3600,7 @@ const QString tableName(qTableName("task_250026", __FILE__, db)); @@ -1659,6 +1706,10 @@ diff -ruN a/models/qsqltablemodel/tst_qsqltablemodel.cpp b/models/qsqltablemodel QSqlTableModel model(0, db); model.setTable(tbl); +@@ -1228 +1228,2 @@ +- model.setData(model.index(1,2), QVariant()); ++ // Preserve the Int32 field type; an invalid QVariant is bound by QODBC as SQL_VARBINARY. ++ model.setData(model.index(1,2), QVariant(QMetaType(QMetaType::Int))); @@ -1393,8 +1393,8 @@ q.exec("PRAGMA foreign_keys = ON;"); q.exec("DROP TABLE " + tblB); diff --git a/odbc/tests/frameworks/registry.yaml b/odbc/tests/frameworks/registry.yaml index 87079c0bd7..6de9a7f71d 100644 --- a/odbc/tests/frameworks/registry.yaml +++ b/odbc/tests/frameworks/registry.yaml @@ -1,8 +1,159 @@ -schema_version: 1 +schema_version: 2 ydb: image: ydbplatform/local-ydb:25.2.1@sha256:f65076231c056659f85d5399ae79e98ecc6278dc0f97dbbde3d2d0a1c79d5412 endpoint: localhost:2136 database: /local +classifications: + - outcome: unsupported + reason: Requires Qt's Oracle plugin and Oracle-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{oci_tables,recordOCI,oci_serverDetach,oci_xmltypeSupport,oci_fieldLength,oci_synonymstest} + - qt.*.qsqlquery.{oci_nullBlob,oci_rawField,oraOutValues,oraClob,oraClobBatch,oraLong,oraOCINumber,oraRowId,oraArrayBind,QTBUG_551,QTBUG_6421,QTBUG_14132,dateTime} + - outcome: unsupported + reason: Requires Qt's MySQL plugin and MySQL-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordMySQL,mysql_multiselect,mysql_savepointtest} + - qt.*.qsqlquery.{mysql_outValues,mysql_timeType,QTBUG_6852,QTBUG_5765,QTBUG_53969,gisPointDatatype,integralTypesMysql} + - outcome: unsupported + reason: Requires Qt's PostgreSQL plugin and PostgreSQL-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordPSQL,infinityAndNan,psql_schemas,psql_escapedIdentifiers,psql_escapeBytea,psql_bug249059} + - qt.*.qsqlquery.{psql_forwardOnlyQueryResultsLost,psql_bindWithDoubleColonCastOperator,psql_specialFloatValues,task_233829,QTBUG_12477,QTBUG_5251,QTBUG_36211} + - outcome: unsupported + reason: Exercises SQL Server or TDS-specific behavior outside the YDB binding contract. + tests: + qt: + - qt.*.qsqldatabase.{errorReporting,recordTDS,recordSQLServer,odbc_uniqueidentifier} + - qt.*.qsqlquery.{tds_bitField,sqlServerLongStrings,sqlServerReturn0,QTBUG_6618,QTBUG_18435} + - outcome: unsupported + reason: Requires Qt's DB2 plugin and DB2-specific behavior rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordDB2,db2_valueCacheUpdate} + - outcome: unsupported + reason: Requires Qt's DB2 plugin and DB2-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqlquery.outValuesDB2 + - outcome: unsupported + reason: Requires Qt's SQLite plugin and SQLite-specific behavior rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordSQLite,sqlite_bindAndFetchUInt,sqlite_enable_cache_mode,sqlite_enableRegexp,sqlite_openError,sqlite_check_json1} + - qt.*.qsqltablemodel.{insertWithAutoColumn,revert,primaryKeyOrder,sqlite_bigTable,sqlite_attachedDatabase,sqlite_selectFromIdentifierWithDot,modelInAnotherThread} + - outcome: unsupported + reason: Exercises Microsoft Access-specific behavior rather than YDB ODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordAccess,accessOdbc_strings} + - outcome: unsupported + reason: Requires Qt's InterBase plugin and InterBase-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqldatabase.{recordIBase,ibase_numericFields,ibase_fetchBlobs,ibase_useCustomCharset,ibase_procWithoutReturnValues,ibase_procWithReturnValues} + - qt.*.qsqlquery.{storedProceduresIBase,ibase_executeBlock,ibaseArray} + - outcome: unsupported + reason: Exercises the MySQL ODBC driver rather than the YDB ODBC driver. + tests: + qt: + - qt.*.qsqldatabase.mysqlOdbc_unsignedIntegers + - outcome: unsupported + reason: Database event notifications are outside the binding contract. + tests: + qt: + - qt.*.qsqldatabase.{eventNotification,eventNotificationIBase,eventNotificationPSQL,eventNotificationSQLite} + - outcome: unsupported + reason: YDB table paths are outside Qt's whitespace-delimited SQL identifier scenario. + tests: + qt: + - qt.*.qsqldatabase.whitespaceInIdentifiers + - qt.*.qsqlrelationaltablemodel.whiteSpaceInIdentifiers + - qt.*.qsqltablemodel.whitespaceInIdentifiers + - outcome: unsupported + reason: Requires Qt's SQLite plugin and SQLite-specific SQL rather than QODBC. + tests: + qt: + - qt.*.qsqlquery.{record_sqlite,sqlite_finish,sqliteVirtualTable,QTBUG_12186,QTBUG_21884,QTBUG_16967,QTBUG_23895,QTBUG_14904,sqlite_constraint,sqlite_real,QTBUG_57138} + - outcome: unsupported + reason: Exercises non-QODBC backend plugins selected explicitly by the upstream test. + tests: + qt: + - qt.*.qsqlquery.createQueryOnClosedDatabase + - outcome: unsupported + reason: Stored procedures and output parameters are optional ODBC features outside the binding contract. + tests: + qt: + - qt.*.qsqlquery.outValues + - outcome: unsupported + reason: Generated-key retrieval is outside the binding contract. + tests: + qt: + - qt.*.qsqlquery.lastInsertId + - outcome: unsupported + reason: Exercises generic ANSI join syntax instead of the YQL dialect accepted by the driver. + tests: + qt: + - qt.*.qsqlquery.joins + - outcome: unsupported + reason: Upstream permanently disables this query-level experiment; qsqldatabase.transaction exercises the required transaction contract. + tests: + qt: + - qt.*.qsqlquery.transaction + - outcome: unsupported + reason: YDB result-set metadata does not carry the base-table origin required for SQL_DESC_BASE_TABLE_NAME. + tests: + qt: + - qt.*.qsqlquery.record + - outcome: unsupported + reason: The fixture relies on implicit conversion of an ANSI floating-point literal to Decimal; the documented SQL-dialect limitation does not translate arbitrary ANSI coercions into YQL. + tests: + qt: + - qt.*.qsqlquery.precision + - outcome: unsupported + reason: Requires Qt's PostgreSQL plugin and PostgreSQL schema semantics rather than QODBC. + tests: + qt: + - qt.*.qsqlrelationaltablemodel.psqlSchemaTest + - qt.*.qsqltablemodel.tablesAndSchemas + - outcome: skipped + reason: Qt 6.4 QODBC exposes its BLOB feature only for MySQL; YDB binary binding is covered by native ODBC integration tests. + tests: + qt: + - qt.*.qsqlquery.{blob,blobsPreparedQuery} + - outcome: skipped + reason: Qt skips its DML assertions when a preceding SELECT returns the ODBC-defined unknown row count; YDB DML counts are covered by native ODBC integration tests. + tests: + qt: + - qt.*.qsqlquery.numRowsAffected + - outcome: unsupported + reason: The driver exposes one result set per executed statement, so Qt's SQLMoreResults scenarios are unavailable. + tests: + qt: + - qt.*.qsqlquery.{nextResult,forwardOnlyMultipleResultSet} + - outcome: unsupported + reason: Qt's relational models generate implicit comma joins, which YDB rejects unless implicit Cartesian products are enabled. + tests: + qt: + - qt.*.qsqlrelationaldelegate.comboBoxEditor + - qt.*.qsqlrelationaltablemodel.{data,setData,multipleRelation,insertRecord,setRecord,insertWithStrategies,removeColumn,filter,sort,revert,clearDisplayValuesCache,insertRecordDuplicateFieldNames,invalidData,relationModel,casing,escapedRelations,escapedTableName,selectAfterUpdate,relationOnFirstColumn,setRelation} + - outcome: unsupported + reason: Qt's fixture updates the id column in place, but YDB primary-key columns are immutable. + tests: + qt: + - qt.*.qsqltablemodel.setData + - outcome: unsupported + reason: Qt's fixture inserts the same id ten times into a table that must have a primary key, and YDB rejects duplicate keys. + tests: + qt: + - qt.*.qsqltablemodel.insertRecordsInLoop + - outcome: unsupported + reason: Qt removes the model's sole primary-key column before deleting a row, while YDB requires that key to identify the row. + tests: + qt: + - qt.*.qsqltablemodel.removeColumnAndRow consumers: - id: package-contract display_name: Debian package contract @@ -51,37 +202,3 @@ consumers: discovered: true required: - qt.*.*.* - unsupported: - qt.*.qsqldatabase.{oci_tables,recordOCI,oci_serverDetach,oci_xmltypeSupport,oci_fieldLength,oci_synonymstest}: Requires Qt's Oracle plugin and Oracle-specific SQL rather than QODBC. - qt.*.qsqldatabase.{recordMySQL,mysql_multiselect,mysql_savepointtest}: Requires Qt's MySQL plugin and MySQL-specific SQL rather than QODBC. - qt.*.qsqldatabase.{recordPSQL,infinityAndNan,psql_schemas,psql_escapedIdentifiers,psql_escapeBytea,psql_bug249059}: Requires Qt's PostgreSQL plugin and PostgreSQL-specific SQL rather than QODBC. - qt.*.qsqldatabase.{errorReporting,recordTDS,recordSQLServer,odbc_uniqueidentifier}: Exercises SQL Server or TDS-specific behavior outside the YDB binding contract. - qt.*.qsqldatabase.{recordDB2,db2_valueCacheUpdate}: Requires Qt's DB2 plugin and DB2-specific behavior rather than QODBC. - qt.*.qsqldatabase.{recordSQLite,sqlite_bindAndFetchUInt,sqlite_enable_cache_mode,sqlite_enableRegexp,sqlite_openError,sqlite_check_json1}: Requires Qt's SQLite plugin and SQLite-specific behavior rather than QODBC. - qt.*.qsqldatabase.{recordAccess,accessOdbc_strings}: Exercises Microsoft Access-specific behavior rather than YDB ODBC. - qt.*.qsqldatabase.{recordIBase,ibase_numericFields,ibase_fetchBlobs,ibase_useCustomCharset,ibase_procWithoutReturnValues,ibase_procWithReturnValues}: Requires Qt's InterBase plugin and InterBase-specific SQL rather than QODBC. - qt.*.qsqldatabase.mysqlOdbc_unsignedIntegers: Exercises the MySQL ODBC driver rather than the YDB ODBC driver. - qt.*.qsqldatabase.{eventNotification,eventNotificationIBase,eventNotificationPSQL,eventNotificationSQLite}: Database event notifications are outside the binding contract. - qt.*.qsqldatabase.whitespaceInIdentifiers: YDB table paths are outside Qt's whitespace-delimited SQL identifier scenario. - qt.*.qsqlquery.{record_sqlite,sqlite_finish,sqliteVirtualTable,QTBUG_12186,QTBUG_21884,QTBUG_16967,QTBUG_23895,QTBUG_14904,sqlite_constraint,sqlite_real,QTBUG_57138}: Requires Qt's SQLite plugin and SQLite-specific SQL rather than QODBC. - qt.*.qsqlquery.{psql_forwardOnlyQueryResultsLost,psql_bindWithDoubleColonCastOperator,psql_specialFloatValues,task_233829,QTBUG_12477,QTBUG_5251,QTBUG_36211}: Requires Qt's PostgreSQL plugin and PostgreSQL-specific SQL rather than QODBC. - qt.*.qsqlquery.{oci_nullBlob,oci_rawField,oraOutValues,oraClob,oraClobBatch,oraLong,oraOCINumber,oraRowId,oraArrayBind,QTBUG_551,QTBUG_6421,QTBUG_14132,dateTime}: Requires Qt's Oracle plugin and Oracle-specific SQL rather than QODBC. - qt.*.qsqlquery.{mysql_outValues,mysql_timeType,QTBUG_6852,QTBUG_5765,QTBUG_53969,gisPointDatatype,integralTypesMysql}: Requires Qt's MySQL plugin and MySQL-specific SQL rather than QODBC. - qt.*.qsqlquery.{storedProceduresIBase,ibase_executeBlock,ibaseArray}: Requires Qt's InterBase plugin and InterBase-specific SQL rather than QODBC. - qt.*.qsqlquery.{tds_bitField,sqlServerLongStrings,sqlServerReturn0,QTBUG_6618,QTBUG_18435}: Exercises SQL Server or TDS-specific behavior outside the YDB binding contract. - qt.*.qsqlquery.outValuesDB2: Requires Qt's DB2 plugin and DB2-specific SQL rather than QODBC. - qt.*.qsqlquery.createQueryOnClosedDatabase: Exercises non-QODBC backend plugins selected explicitly by the upstream test. - qt.*.qsqlquery.outValues: Stored procedures and output parameters are optional ODBC features outside the binding contract. - qt.*.qsqlquery.lastInsertId: Generated-key retrieval is outside the binding contract. - qt.*.qsqlquery.joins: Exercises generic ANSI join syntax instead of the YQL dialect accepted by the driver. - qt.*.qsqlquery.transaction: Upstream permanently disables this query-level experiment; qsqldatabase.transaction exercises the required transaction contract. - qt.*.qsqlquery.record: YDB result-set metadata does not carry the base-table origin required for SQL_DESC_BASE_TABLE_NAME. - qt.*.qsqlquery.precision: The fixture relies on implicit conversion of an ANSI floating-point literal to Decimal; the documented SQL-dialect limitation does not translate arbitrary ANSI coercions into YQL. - qt.*.qsqlrelationaltablemodel.whiteSpaceInIdentifiers: YDB table paths are outside Qt's whitespace-delimited SQL identifier scenario. - qt.*.qsqlrelationaltablemodel.psqlSchemaTest: Requires Qt's PostgreSQL plugin and PostgreSQL schema semantics rather than QODBC. - qt.*.qsqltablemodel.{insertWithAutoColumn,revert,primaryKeyOrder,sqlite_bigTable,sqlite_attachedDatabase,sqlite_selectFromIdentifierWithDot,modelInAnotherThread}: Requires Qt's SQLite plugin and SQLite-specific behavior rather than QODBC. - qt.*.qsqltablemodel.tablesAndSchemas: Requires Qt's PostgreSQL plugin and PostgreSQL schema semantics rather than QODBC. - qt.*.qsqltablemodel.whitespaceInIdentifiers: YDB table paths are outside Qt's whitespace-delimited SQL identifier scenario. - skipped: - qt.*.qsqlquery.{blob,blobsPreparedQuery}: Qt 6.4 QODBC exposes its BLOB feature only for MySQL; YDB binary binding is covered by native ODBC integration tests. - qt.*.qsqlquery.numRowsAffected: Qt skips its DML assertions when a preceding SELECT returns the ODBC-defined unknown row count; YDB DML counts are covered by native ODBC integration tests. diff --git a/odbc/tests/integration/connection_api_it.cpp b/odbc/tests/integration/connection_api_it.cpp index 2a4e4954b0..693e42b274 100644 --- a/odbc/tests/integration/connection_api_it.cpp +++ b/odbc/tests/integration/connection_api_it.cpp @@ -111,7 +111,23 @@ TEST(ConnectionApi, SQLDriverConnectIgnoresUnrecognizedAttributes) { const SQLRETURN rc = SQLDriverConnect( dbc, nullptr, connectionString, SQL_NTS, nullptr, 0, nullptr, SQL_DRIVER_NOPROMPT); ASSERT_EQ(rc, SQL_SUCCESS_WITH_INFO) << GetOdbcError(dbc, SQL_HANDLE_DBC); - EXPECT_TRUE(SqlStatePrefix(GetOdbcError(dbc, SQL_HANDLE_DBC), "01S00")); + SQLCHAR sqlState[6] = {}; + SQLCHAR message[256] = {}; + SQLINTEGER nativeError = 0; + SQLSMALLINT textLength = 0; + const SQLRETURN diagRc = SQLGetDiagRec( + SQL_HANDLE_DBC, dbc, 1, sqlState, &nativeError, + message, sizeof(message), &textLength); + if (diagRc == SQL_SUCCESS || diagRc == SQL_SUCCESS_WITH_INFO) { + EXPECT_STREQ(reinterpret_cast(sqlState), "01S00"); + } else { +#ifdef ODBC_TEST_IODBC + // iODBC preserves SQL_SUCCESS_WITH_INFO but drops the driver's diagnostic record. + EXPECT_EQ(diagRc, SQL_NO_DATA); +#else + FAIL() << "SQLGetDiagRec failed with return code " << diagRc; +#endif + } SQLHSTMT stmt; ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); @@ -134,7 +150,9 @@ TEST(ConnectionApi, SQLDriverConnectValidatesAuthenticationSettings) { const char* SqlState; } cases[] = { {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;AuthMode=None;", "28000"}, + {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;Token=;", "28000"}, {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;Token=a;UID=b;PWD=c;", "28000"}, + {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;AuthMode=Static;UID=;PWD=;", "28000"}, {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;AuthMode=Static;UID=b;", "28000"}, {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;AuthMode=Metadata;MetadataPort=70000;", "HY024"}, {"Driver=" ODBC_DRIVER_PATH ";Endpoint=localhost:2136;Database=/local;AuthMode=ServiceAccount;SaFile=/missing/sa.json;", "08001"}, @@ -182,6 +200,29 @@ TEST(ConnectionApi, SQLDriverConnectSupportsAliasesAndDsnOverlay) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(ConnectionApi, SQLDriverConnectKeepsAnonymousDsnWithBlankCredentials) { + SQLHENV env; + SQLHDBC dbc; + AllocEnv(&env); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc), SQL_SUCCESS); + + SQLCHAR connectionString[] = "DSN=YDB;UID=;PWD=;"; + CHECK_ODBC_OK(SQLDriverConnect( + dbc, nullptr, connectionString, SQL_NTS, + nullptr, 0, nullptr, SQL_DRIVER_NOPROMPT), + dbc, SQL_HANDLE_DBC); + + SQLHSTMT stmt; + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)"SELECT 1", SQL_NTS), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(ConnectionApi, SQLConnectMissingDSN) { SQLHENV env; SQLHDBC dbc; @@ -249,6 +290,32 @@ TEST(ConnectionApi, ConnAttrCurrentCatalog) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(ConnectionApi, LegacyLoginTimeoutBeforeConnect) { + SQLHENV env; + SQLHDBC dbc; + AllocEnv(&env); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc), SQL_SUCCESS); + + constexpr SQLULEN timeout = 15; + ASSERT_EQ(SQLSetConnectOption(dbc, SQL_LOGIN_TIMEOUT, timeout), SQL_SUCCESS); + + SQLCHAR outStr[1024] = {}; + SQLSMALLINT outLen = 0; + const SQLRETURN connectRc = SQLDriverConnect( + dbc, nullptr, (SQLCHAR*)kConnStr, SQL_NTS, + outStr, sizeof(outStr), &outLen, SQL_DRIVER_NOPROMPT); + ASSERT_EQ(connectRc, SQL_SUCCESS) << GetOdbcError(dbc, SQL_HANDLE_DBC); + + SQLUINTEGER actualTimeout = 0; + ASSERT_EQ(SQLGetConnectOption(dbc, SQL_LOGIN_TIMEOUT, &actualTimeout), SQL_SUCCESS) + << GetOdbcError(dbc, SQL_HANDLE_DBC); + EXPECT_EQ(actualTimeout, timeout); + + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(ConnectionApi, ConnAttrCurrentCatalogAffectsQueries) { SQLHENV env; SQLHDBC dbc; diff --git a/odbc/tests/integration/core_api_it.cpp b/odbc/tests/integration/core_api_it.cpp index 70b37cb924..ff46824e72 100644 --- a/odbc/tests/integration/core_api_it.cpp +++ b/odbc/tests/integration/core_api_it.cpp @@ -1,5 +1,6 @@ #include "test_utils.h" +#include #include #ifndef SQL_ODBC_INTERFACE_CONFORMANCE @@ -7,17 +8,125 @@ #endif TEST(CoreApi, SQLGetTypeInfoAll) { + struct TExpectedType { + SQLSMALLINT DataType; + const char* TypeName; + }; + constexpr std::array expected{{ + {SQL_BIGINT, "Int64"}, + {SQL_INTEGER, "Int32"}, + {SQL_SMALLINT, "Int16"}, + {SQL_DOUBLE, "Double"}, + {SQL_REAL, "Float"}, + {SQL_VARCHAR, "Utf8"}, + {SQL_CHAR, "Utf8"}, + }}; + SQLHENV env; SQLHDBC dbc; SQLHSTMT stmt; AllocEnvAndConnect(&env, &dbc); ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); CHECK_ODBC_OK(SQLGetTypeInfo(stmt, SQL_ALL_TYPES), stmt, SQL_HANDLE_STMT); + char typeName[64] = {}; - SQLLEN indicator = 0; - SQLBindCol(stmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), &indicator); - ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); - EXPECT_TRUE(std::strstr(typeName, "bigint") != nullptr); + SQLSMALLINT dataType = 0; + SQLLEN typeNameIndicator = 0; + SQLLEN dataTypeIndicator = 0; + CHECK_ODBC_OK(SQLBindCol(stmt, 1, SQL_C_CHAR, typeName, sizeof(typeName), + &typeNameIndicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindCol(stmt, 2, SQL_C_SSHORT, &dataType, 0, &dataTypeIndicator), + stmt, SQL_HANDLE_STMT); + + std::array seen{}; + size_t rowCount = 0; + SQLRETURN fetchResult; + while ((fetchResult = SQLFetch(stmt)) == SQL_SUCCESS) { + ++rowCount; + bool matched = false; + for (size_t index = 0; index < expected.size(); ++index) { + if (dataType == expected[index].DataType + && std::strcmp(typeName, expected[index].TypeName) == 0) { + EXPECT_FALSE(seen[index]) << "duplicate type row " << typeName; + seen[index] = true; + matched = true; + break; + } + } + EXPECT_TRUE(matched) << "unexpected advertised type " << typeName + << " (" << dataType << ")"; + } + EXPECT_EQ(fetchResult, SQL_NO_DATA); + EXPECT_EQ(rowCount, expected.size()); + for (size_t index = 0; index < expected.size(); ++index) { + EXPECT_TRUE(seen[index]) << "missing " << expected[index].TypeName + << " for SQL type " << expected[index].DataType; + } + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(CoreApi, SQLGetTypeInfoSchema) { + struct TExpectedColumn { + const char* Name; + SQLSMALLINT Type; + SQLSMALLINT Nullable; + }; + constexpr std::array expected{{ + {"TYPE_NAME", SQL_VARCHAR, SQL_NO_NULLS}, + {"DATA_TYPE", SQL_SMALLINT, SQL_NO_NULLS}, + {"COLUMN_SIZE", SQL_INTEGER, SQL_NULLABLE}, + {"LITERAL_PREFIX", SQL_VARCHAR, SQL_NULLABLE}, + {"LITERAL_SUFFIX", SQL_VARCHAR, SQL_NULLABLE}, + {"CREATE_PARAMS", SQL_VARCHAR, SQL_NULLABLE}, + {"NULLABLE", SQL_SMALLINT, SQL_NO_NULLS}, + {"CASE_SENSITIVE", SQL_SMALLINT, SQL_NO_NULLS}, + {"SEARCHABLE", SQL_SMALLINT, SQL_NO_NULLS}, + {"UNSIGNED_ATTRIBUTE", SQL_SMALLINT, SQL_NULLABLE}, + {"FIXED_PREC_SCALE", SQL_SMALLINT, SQL_NO_NULLS}, + {"AUTO_UNIQUE_VALUE", SQL_SMALLINT, SQL_NULLABLE}, + {"LOCAL_TYPE_NAME", SQL_VARCHAR, SQL_NULLABLE}, + {"MINIMUM_SCALE", SQL_SMALLINT, SQL_NULLABLE}, + {"MAXIMUM_SCALE", SQL_SMALLINT, SQL_NULLABLE}, + {"SQL_DATA_TYPE", SQL_SMALLINT, SQL_NO_NULLS}, + {"SQL_DATETIME_SUB", SQL_SMALLINT, SQL_NULLABLE}, + {"NUM_PREC_RADIX", SQL_INTEGER, SQL_NULLABLE}, + {"INTERVAL_PRECISION", SQL_SMALLINT, SQL_NULLABLE}, + }}; + + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetTypeInfo(stmt, SQL_ALL_TYPES), stmt, SQL_HANDLE_STMT); + + SQLSMALLINT columnCount = 0; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, static_cast(expected.size())); + for (size_t index = 0; index < expected.size(); ++index) { + const SQLUSMALLINT column = static_cast(index + 1); + SCOPED_TRACE(column); + char name[64] = {}; + SQLSMALLINT nameLength = 0; + SQLSMALLINT type = 0; + SQLULEN size = 0; + SQLSMALLINT scale = 0; + SQLSMALLINT nullable = 0; + CHECK_ODBC_OK(SQLDescribeCol( + stmt, column, reinterpret_cast(name), sizeof(name), + &nameLength, &type, &size, &scale, &nullable), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(name, expected[index].Name); + EXPECT_EQ(nameLength, static_cast(std::strlen(expected[index].Name))); + EXPECT_EQ(type, expected[index].Type); + EXPECT_EQ(nullable, expected[index].Nullable); + } + SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); @@ -213,6 +322,17 @@ TEST(CoreApi, SQLStatisticsEmpty) { ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); CHECK_ODBC_OK(SQLStatistics(stmt, nullptr, 0, nullptr, 0, (SQLCHAR*)"%", SQL_NTS, SQL_INDEX_ALL, SQL_ENSURE), stmt, SQL_HANDLE_STMT); + SQLCHAR columnName[32] = {}; + SQLSMALLINT nameLength = 0; + SQLSMALLINT dataType = 0; + SQLULEN columnSize = 0; + SQLSMALLINT decimalDigits = 0; + SQLSMALLINT nullable = 0; + CHECK_ODBC_OK(SQLDescribeCol(stmt, 4, columnName, sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(reinterpret_cast(columnName), "NON_UNIQUE"); + EXPECT_EQ(dataType, SQL_SMALLINT); ASSERT_EQ(SQLFetch(stmt), SQL_NO_DATA); SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); @@ -256,6 +376,152 @@ TEST(CoreApi, SQLGetInfoInterfaceConformance) { CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_ODBC_INTERFACE_CONFORMANCE, &conformance, 0, &outLen), dbc, SQL_HANDLE_DBC); EXPECT_EQ(conformance, SQL_OIC_CORE); + char userName[8] = {'x'}; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_USER_NAME, userName, sizeof(userName), &outLen), + dbc, SQL_HANDLE_DBC); + EXPECT_STREQ(userName, ""); + EXPECT_EQ(outLen, 0); + char databaseName[64] = {}; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_DATABASE_NAME, databaseName, sizeof(databaseName), &outLen), + dbc, SQL_HANDLE_DBC); + EXPECT_STREQ(databaseName, "/local"); + EXPECT_EQ(outLen, 6); + char serverName[64] = {}; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_SERVER_NAME, serverName, sizeof(serverName), &outLen), + dbc, SQL_HANDLE_DBC); + EXPECT_STREQ(serverName, "localhost:2136"); + EXPECT_EQ(outLen, 14); + char dbmsVersion[64] = {}; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_DBMS_VER, dbmsVersion, sizeof(dbmsVersion), &outLen), + dbc, SQL_HANDLE_DBC); + EXPECT_GT(outLen, 0); + EXPECT_NE(dbmsVersion[0], '\0'); + char driverVersion[64] = {}; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_DRIVER_VER, driverVersion, sizeof(driverVersion), &outLen), + dbc, SQL_HANDLE_DBC); + EXPECT_STREQ(driverVersion, ODBC_DRIVER_VERSION); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(CoreApi, SQLGetInfoScalarWidths) { + struct TU16Info { + SQLUSMALLINT InfoType; + SQLUSMALLINT Expected; + }; + struct TU32Info { + SQLUSMALLINT InfoType; + SQLUINTEGER Expected; + }; + constexpr std::array u16Info{{ + {SQL_MAX_DRIVER_CONNECTIONS, 0}, + {SQL_MAX_CONCURRENT_ACTIVITIES, 0}, + {SQL_MAX_COLUMNS_IN_GROUP_BY, 0}, + {SQL_MAX_COLUMNS_IN_ORDER_BY, 0}, + {SQL_MAX_COLUMNS_IN_INDEX, 20}, + {SQL_MAX_COLUMNS_IN_SELECT, 0}, + {SQL_MAX_COLUMNS_IN_TABLE, 200}, + {SQL_MAX_TABLES_IN_SELECT, 0}, + {SQL_CATALOG_LOCATION, SQL_CL_START}, + {SQL_GROUP_BY, SQL_GB_GROUP_BY_CONTAINS_SELECT}, + {SQL_NON_NULLABLE_COLUMNS, SQL_NNC_NON_NULL}, + }}; + constexpr std::array u32Info{{ + {SQL_ALTER_TABLE, 0}, + {SQL_CATALOG_USAGE, 0}, + {SQL_DYNAMIC_CURSOR_ATTRIBUTES1, 0}, + {SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2, SQL_CA2_READ_ONLY_CONCURRENCY}, + }}; + constexpr SQLUSMALLINT u16Canary = 0xA55A; + constexpr SQLUINTEGER u32Canary = 0xA55AA55A; + + SQLHENV env; + SQLHDBC dbc; + AllocEnvAndConnect(&env, &dbc); + + for (const auto& info : u16Info) { + SCOPED_TRACE(info.InfoType); + std::array guarded{0xFFFF, u16Canary}; + SQLSMALLINT outLength = -1; + CHECK_ODBC_OK(SQLGetInfo(dbc, info.InfoType, guarded.data(), sizeof(guarded[0]), &outLength), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(guarded[0], info.Expected); + EXPECT_EQ(guarded[1], u16Canary); + EXPECT_EQ(outLength, static_cast(sizeof(SQLUSMALLINT))); + } + + for (const auto& info : u32Info) { + SCOPED_TRACE(info.InfoType); + std::array guarded{0xFFFFFFFF, u32Canary}; + SQLSMALLINT outLength = -1; + CHECK_ODBC_OK(SQLGetInfo(dbc, info.InfoType, guarded.data(), sizeof(guarded[0]), &outLength), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(guarded[0], info.Expected); + EXPECT_EQ(guarded[1], u32Canary); + EXPECT_EQ(outLength, static_cast(sizeof(SQLUINTEGER))); + } + + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(CoreApi, SQLSetGetStmtAttrCursorCapabilities) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + SQLULEN value = static_cast(-1); + CHECK_ODBC_OK(SQLGetStmtAttr( + stmt, SQL_ATTR_CURSOR_SENSITIVITY, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_UNSPECIFIED); + CHECK_ODBC_OK(SQLGetStmtAttr( + stmt, SQL_ATTR_USE_BOOKMARKS, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_UB_OFF); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_SENSITIVITY, + (SQLPOINTER)SQL_INSENSITIVE, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_CURSOR_STATIC); + CHECK_ODBC_OK(SQLGetStmtAttr( + stmt, SQL_ATTR_CURSOR_SENSITIVITY, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_INSENSITIVE); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_SENSITIVITY, + (SQLPOINTER)SQL_SENSITIVE, 0), + SQL_SUCCESS_WITH_INFO); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S02")); + CHECK_ODBC_OK(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_CURSOR_STATIC); + CHECK_ODBC_OK(SQLGetStmtAttr( + stmt, SQL_ATTR_CURSOR_SENSITIVITY, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_INSENSITIVE); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_USE_BOOKMARKS, (SQLPOINTER)SQL_UB_ON, 0), + SQL_SUCCESS_WITH_INFO); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S02")); + CHECK_ODBC_OK(SQLGetStmtAttr( + stmt, SQL_ATTR_USE_BOOKMARKS, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, SQL_UB_OFF); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); @@ -280,6 +546,65 @@ TEST(CoreApi, SQLForeignKeysEmpty) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(CoreApi, SQLColumnPrivilegesEmpty) { + struct TExpectedColumn { + const char* Name; + SQLSMALLINT Type; + SQLSMALLINT Nullable; + }; + constexpr std::array expected{{ + {"TABLE_CAT", SQL_VARCHAR, SQL_NULLABLE}, + {"TABLE_SCHEM", SQL_VARCHAR, SQL_NULLABLE}, + {"TABLE_NAME", SQL_VARCHAR, SQL_NO_NULLS}, + {"COLUMN_NAME", SQL_VARCHAR, SQL_NO_NULLS}, + {"GRANTOR", SQL_VARCHAR, SQL_NULLABLE}, + {"GRANTEE", SQL_VARCHAR, SQL_NO_NULLS}, + {"PRIVILEGE", SQL_VARCHAR, SQL_NO_NULLS}, + {"IS_GRANTABLE", SQL_VARCHAR, SQL_NULLABLE}, + }}; + + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + SQLUSMALLINT supported = SQL_FALSE; + CHECK_ODBC_OK(SQLGetFunctions(dbc, SQL_API_SQLCOLUMNPRIVILEGES, &supported), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(supported, SQL_TRUE); + + CHECK_ODBC_OK(SQLColumnPrivileges(stmt, nullptr, 0, nullptr, 0, + (SQLCHAR*)"does_not_exist", SQL_NTS, + (SQLCHAR*)"%", SQL_NTS), + stmt, SQL_HANDLE_STMT); + SQLSMALLINT columnCount = 0; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, static_cast(expected.size())); + for (size_t index = 0; index < expected.size(); ++index) { + char name[32] = {}; + SQLSMALLINT nameLength = 0; + SQLSMALLINT type = 0; + SQLULEN size = 0; + SQLSMALLINT scale = 0; + SQLSMALLINT nullable = 0; + CHECK_ODBC_OK(SQLDescribeCol( + stmt, static_cast(index + 1), + reinterpret_cast(name), sizeof(name), &nameLength, + &type, &size, &scale, &nullable), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(name, expected[index].Name); + EXPECT_EQ(type, expected[index].Type); + EXPECT_EQ(nullable, expected[index].Nullable); + } + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(CoreApi, SQLPrimaryKeys) { SQLHENV env; SQLHDBC dbc; @@ -376,7 +701,7 @@ TEST(CoreApi, SQLParamDataPutData) { CHECK_ODBC_OK(SQLPutData(stmt, (SQLPOINTER)part1, sizeof(part1) - 1), stmt, SQL_HANDLE_STMT); const char part2[] = "lo"; CHECK_ODBC_OK(SQLPutData(stmt, (SQLPOINTER)part2, sizeof(part2) - 1), stmt, SQL_HANDLE_STMT); - CHECK_ODBC_OK(SQLPutData(stmt, nullptr, 0), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLPutData(stmt, (SQLPOINTER)"", 0), stmt, SQL_HANDLE_STMT); CHECK_ODBC_OK(SQLParamData(stmt, &token), stmt, SQL_HANDLE_STMT); EXPECT_EQ(SQLExecute(stmt), SQL_NEED_DATA); CHECK_ODBC_OK(SQLCancel(stmt), stmt, SQL_HANDLE_STMT); @@ -442,7 +767,7 @@ TEST(CoreApi, SQLParamDataPutDataNts) { CHECK_ODBC_OK(SQLPutData(first, (SQLPOINTER)"first", SQL_NTS), first, SQL_HANDLE_STMT); ASSERT_EQ(SQLExecute(second), SQL_NEED_DATA); ASSERT_EQ(SQLParamData(second, &token), SQL_NEED_DATA); - ASSERT_EQ(SQLPutData(second, nullptr, 0), SQL_SUCCESS); + ASSERT_EQ(SQLPutData(second, (SQLPOINTER)"", 0), SQL_SUCCESS); const SQLRETURN secondRc = SQLParamData(second, &token); CHECK_ODBC_OK(secondRc, second, SQL_HANDLE_STMT); const SQLRETURN firstRc = SQLParamData(first, &token); diff --git a/odbc/tests/integration/environment_api_it.cpp b/odbc/tests/integration/environment_api_it.cpp index b3395dc620..cdd3fad96a 100644 --- a/odbc/tests/integration/environment_api_it.cpp +++ b/odbc/tests/integration/environment_api_it.cpp @@ -13,6 +13,9 @@ TEST(EnvironmentApi, AllocEnvInvalidType) { } TEST(EnvironmentApi, FreeInvalidEnvHandle) { +#ifdef ODBC_TEST_IODBC + GTEST_SKIP() << "iODBC traps instead of rejecting a dangling environment handle"; +#endif SQLHENV env; ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env), SQL_SUCCESS); ASSERT_EQ(SQLFreeHandle(SQL_HANDLE_ENV, env), SQL_SUCCESS); @@ -21,6 +24,9 @@ TEST(EnvironmentApi, FreeInvalidEnvHandle) { } TEST(EnvironmentApi, DoubleFreeEnv) { +#ifdef ODBC_TEST_IODBC + GTEST_SKIP() << "iODBC traps when an environment handle is freed twice"; +#endif SQLHENV env; ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env), SQL_SUCCESS); ASSERT_EQ(SQLFreeHandle(SQL_HANDLE_ENV, env), SQL_SUCCESS); @@ -116,6 +122,12 @@ TEST(EnvironmentApi, MultipleConnectionsSequential) { char query[32]; snprintf(query, sizeof(query), "SELECT %d", i + 1); CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)query, SQL_NTS), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLINTEGER value = 0; + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_LONG, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, i + 1); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); diff --git a/odbc/tests/integration/error_handling_it.cpp b/odbc/tests/integration/error_handling_it.cpp index e47e13a033..e2c31f2b87 100644 --- a/odbc/tests/integration/error_handling_it.cpp +++ b/odbc/tests/integration/error_handling_it.cpp @@ -35,15 +35,15 @@ TEST(ErrorHandling, GetDiagRecMultipleErrors) { SQLExecDirect(stmt, (SQLCHAR*)"INVALID SYNTAX HERE", SQL_NTS); - SQLSMALLINT numRecs; + SQLINTEGER numRecs; SQLGetDiagField(SQL_HANDLE_STMT, stmt, 0, SQL_DIAG_NUMBER, &numRecs, 0, nullptr); - for (SQLSMALLINT i = 1; i <= numRecs; ++i) { + for (SQLINTEGER i = 1; i <= numRecs; ++i) { SQLCHAR sqlState[6]; SQLINTEGER nativeError; SQLCHAR msg[256]; SQLSMALLINT msgLen; - SQLRETURN rc = SQLGetDiagRec(SQL_HANDLE_STMT, stmt, i, sqlState, &nativeError, + SQLRETURN rc = SQLGetDiagRec(SQL_HANDLE_STMT, stmt, static_cast(i), sqlState, &nativeError, msg, sizeof(msg), &msgLen); ASSERT_TRUE(rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO); } @@ -99,9 +99,26 @@ TEST(ErrorHandling, SuccessWithInfo) { SQLRETURN rc = SQLDriverConnect(dbc, nullptr, (SQLCHAR*)kConnStr, SQL_NTS, outStr, sizeof(outStr), &outLen, SQL_DRIVER_NOPROMPT); ASSERT_EQ(rc, SQL_SUCCESS_WITH_INFO); +#ifdef ODBC_TEST_IODBC + // iODBC 3.52 overwrites the driver's required output length with the + // length of the truncated buffer and does not expose the driver's 01004 + // diagnostic record. The return code and truncated contents are retained. + EXPECT_EQ(outLen, static_cast(sizeof(outStr) - 1)); +#else EXPECT_EQ(outLen, static_cast(std::strlen(kConnStr))); +#endif EXPECT_EQ(std::string(reinterpret_cast(outStr)), std::string(kConnStr, sizeof(outStr) - 1)); +#ifdef ODBC_TEST_IODBC + SQLCHAR sqlState[6] = {}; + SQLINTEGER nativeError = 0; + SQLCHAR message[256] = {}; + SQLSMALLINT messageLength = 0; + EXPECT_EQ(SQLGetDiagRec(SQL_HANDLE_DBC, dbc, 1, sqlState, &nativeError, + message, sizeof(message), &messageLength), + SQL_NO_DATA); +#else EXPECT_TRUE(SqlStatePrefix(GetOdbcError(dbc, SQL_HANDLE_DBC), "01004")); +#endif SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); @@ -114,11 +131,11 @@ TEST(ErrorHandling, ClearErrors) { AllocEnvAndConnect(&env, &dbc); ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); SQLExecDirect(stmt, (SQLCHAR*)"SELECT * FROM nonexistent_table", SQL_NTS); - SQLSMALLINT numRecs1; + SQLINTEGER numRecs1; SQLGetDiagField(SQL_HANDLE_STMT, stmt, 0, SQL_DIAG_NUMBER, &numRecs1, 0, nullptr); ASSERT_GT(numRecs1, 0); SQLExecDirect(stmt, (SQLCHAR*)"SELECT 1", SQL_NTS); - SQLSMALLINT numRecs2; + SQLINTEGER numRecs2; SQLGetDiagField(SQL_HANDLE_STMT, stmt, 0, SQL_DIAG_NUMBER, &numRecs2, 0, nullptr); SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); diff --git a/odbc/tests/integration/metadata_api_it.cpp b/odbc/tests/integration/metadata_api_it.cpp index 12f2d9f7e2..e82f5d284c 100644 --- a/odbc/tests/integration/metadata_api_it.cpp +++ b/odbc/tests/integration/metadata_api_it.cpp @@ -81,6 +81,17 @@ TEST(MetadataApi, SQLTablesExactMatch) { } TEST(MetadataApi, RelativeTableMetadataUsesCurrentCatalog) { + struct TExpectedColumn { + const char* Name; + const char* TypeName; + SQLSMALLINT Nullable; + const char* IsNullable; + }; + const TExpectedColumn expectedColumns[] = { + {"id", "Int32", SQL_NO_NULLS, "NO"}, + {"value", "Utf8", SQL_NULLABLE, "YES"}, + }; + SQLHENV env; SQLHDBC dbc; SQLHSTMT stmt; @@ -103,7 +114,7 @@ TEST(MetadataApi, RelativeTableMetadataUsesCurrentCatalog) { SQLLEN indicator = 0; ASSERT_EQ(SQLGetData(stmt, 1, SQL_C_CHAR, catalog, sizeof(catalog), &indicator), SQL_SUCCESS); ASSERT_EQ(SQLGetData(stmt, 3, SQL_C_CHAR, tableName, sizeof(tableName), &indicator), SQL_SUCCESS); - EXPECT_STREQ(catalog, "/local"); + EXPECT_STREQ(catalog, "local"); EXPECT_STREQ(tableName, table); ASSERT_EQ(SQLFetch(stmt), SQL_NO_DATA); SQLFreeStmt(stmt, SQL_CLOSE); @@ -111,11 +122,33 @@ TEST(MetadataApi, RelativeTableMetadataUsesCurrentCatalog) { CHECK_ODBC_OK(SQLColumns(stmt, nullptr, 0, nullptr, 0, (SQLCHAR*)table, SQL_NTS, nullptr, 0), stmt, SQL_HANDLE_STMT); - int columnCount = 0; - while (SQLFetch(stmt) == SQL_SUCCESS) { - ++columnCount; + for (const auto& column : expectedColumns) { + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + char columnName[64] = {}; + char typeName[64] = {}; + char isNullable[8] = {}; + SQLSMALLINT nullable = -1; + CHECK_ODBC_OK(SQLGetData(stmt, 4, SQL_C_CHAR, columnName, + sizeof(columnName), &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_CHAR, catalog, + sizeof(catalog), &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetData(stmt, 6, SQL_C_CHAR, typeName, + sizeof(typeName), &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetData(stmt, 11, SQL_C_SSHORT, &nullable, 0, &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetData(stmt, 18, SQL_C_CHAR, isNullable, + sizeof(isNullable), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(columnName, column.Name); + EXPECT_STREQ(catalog, "local"); + EXPECT_STREQ(typeName, column.TypeName); + EXPECT_EQ(nullable, column.Nullable); + EXPECT_STREQ(isNullable, column.IsNullable); } - EXPECT_EQ(columnCount, 2); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); SQLFreeStmt(stmt, SQL_CLOSE); CHECK_ODBC_OK(SQLPrimaryKeys(stmt, nullptr, 0, nullptr, 0, @@ -124,8 +157,35 @@ TEST(MetadataApi, RelativeTableMetadataUsesCurrentCatalog) { ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); char columnName[64] = {}; ASSERT_EQ(SQLGetData(stmt, 4, SQL_C_CHAR, columnName, sizeof(columnName), &indicator), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_CHAR, catalog, sizeof(catalog), &indicator), + stmt, SQL_HANDLE_STMT); EXPECT_STREQ(columnName, "id"); + EXPECT_STREQ(catalog, "local"); ASSERT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + SQLCHAR catalogWithoutSlash[] = "local"; + CHECK_ODBC_OK(SQLTables(stmt, catalogWithoutSlash, SQL_NTS, nullptr, 0, + (SQLCHAR*)table, SQL_NTS, + (SQLCHAR*)"TABLE", SQL_NTS), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_CHAR, catalog, sizeof(catalog), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(catalog, "local"); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLPrimaryKeys(stmt, catalogWithoutSlash, SQL_NTS, nullptr, 0, + (SQLCHAR*)table, SQL_NTS), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 4, SQL_C_CHAR, columnName, + sizeof(columnName), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(columnName, "id"); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); diff --git a/odbc/tests/integration/statement_api_it.cpp b/odbc/tests/integration/statement_api_it.cpp index 6769da9611..8d68e65049 100644 --- a/odbc/tests/integration/statement_api_it.cpp +++ b/odbc/tests/integration/statement_api_it.cpp @@ -38,6 +38,37 @@ TEST(StatementApi, ExecDirectSimple) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(StatementApi, ExecDirectNestedQueryRepeated) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + const SQLULEN noScanModes[] = {SQL_NOSCAN_OFF, SQL_NOSCAN_ON}; + for (SQLULEN noScan : noScanModes) { + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_NOSCAN, + reinterpret_cast(noScan), 0), + stmt, SQL_HANDLE_STMT); + for (int pass = 0; pass < 3; ++pass) { + CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)"select * from (select 1);", SQL_NTS), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 1), SQL_SUCCESS); + SQLINTEGER value = 0; + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_LONG, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, 1); + EXPECT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 1), SQL_NO_DATA); + CHECK_ODBC_OK(SQLCloseCursor(stmt), stmt, SQL_HANDLE_STMT); + } + } + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(StatementApi, ExecDirectMultipleColumns) { SQLHENV env; SQLHDBC dbc; @@ -375,6 +406,335 @@ TEST(StatementApi, NumResultCols) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(StatementApi, PreparedResultMetadataBeforeExecute) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE IF EXISTS result_metadata_before_execute_test", SQL_NTS); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"PRAGMA TablePathPrefix = \"/local\";\n" + "CREATE TABLE result_metadata_before_execute_test (" + "id Int64 NOT NULL, name Utf8, PRIMARY KEY (id))", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"INSERT INTO result_metadata_before_execute_test (id, name) " + "VALUES (7, 'expected')", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + + // Inspecting a prepared data-modification statement must not execute it. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"UPSERT INTO result_metadata_before_execute_test (id, name) " + "VALUES (1, 'unexpected')", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLSMALLINT columnCount = -1; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 0); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"SELECT COUNT(*) FROM result_metadata_before_execute_test WHERE id = 1", + SQL_NTS), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLBIGINT rowCount = -1; + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_SBIGINT, &rowCount, sizeof(rowCount), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(rowCount, 0); + SQLFreeStmt(stmt, SQL_CLOSE); + + // Metadata inspection must not run an earlier executable statement in a batch. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"DELETE FROM result_metadata_before_execute_test WHERE id = 7; " + "SELECT * FROM result_metadata_before_execute_test", + SQL_NTS), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLNumResultCols(stmt, &columnCount), SQL_ERROR); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "HYC00")); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"SELECT COUNT(*) FROM result_metadata_before_execute_test WHERE id = 7", + SQL_NTS), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_SBIGINT, &rowCount, sizeof(rowCount), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(rowCount, 1); + SQLFreeStmt(stmt, SQL_CLOSE); + + // Applications commonly inspect a prepared zero-row query before execution. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"SELECT * FROM `result_metadata_before_execute_test` " + "WHERE ( 0 = 1 )", + SQL_NTS), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 2); + + char columnName[32] = {}; + SQLSMALLINT nameLength = 0; + SQLSMALLINT dataType = SQL_UNKNOWN_TYPE; + SQLULEN columnSize = 0; + SQLSMALLINT decimalDigits = 0; + SQLSMALLINT nullable = SQL_NULLABLE_UNKNOWN; + CHECK_ODBC_OK(SQLDescribeCol( + stmt, 1, reinterpret_cast(columnName), sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(columnName, "id"); + EXPECT_EQ(dataType, SQL_BIGINT); + EXPECT_EQ(nullable, SQL_NO_NULLS); + CHECK_ODBC_OK(SQLDescribeCol( + stmt, 2, reinterpret_cast(columnName), sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(columnName, "name"); + EXPECT_EQ(dataType, SQL_VARCHAR); + EXPECT_EQ(nullable, SQL_NULLABLE); + + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + // The same pre-execution metadata path must lead to real rows when the + // prepared physical-table query is executed. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"SELECT * FROM result_metadata_before_execute_test", SQL_NTS), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 2); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLBIGINT id = 0; + char name[32] = {}; + SQLLEN indicator = 0; + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_SBIGINT, &id, sizeof(id), &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLGetData(stmt, 2, SQL_C_CHAR, name, sizeof(name), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(id, 7); + EXPECT_STREQ(name, "expected"); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + // A terminal semicolon must be removed only from the metadata wrapper; + // the original nested SELECT is still executed unchanged afterwards. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"select * from (select 1 as value);", SQL_NTS), + stmt, SQL_HANDLE_STMT); + // iODBC caches the implicit descriptor handle at statement allocation and + // answers SQLGetStmtAttr from that proxy without calling the driver again. + // Discover the lazy schema through SQLNumResultCols before inspecting the + // same driver's implementation descriptor through the proxy. + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 1); + SQLHDESC ird = SQL_NULL_HDESC; + CHECK_ODBC_OK(SQLGetStmtAttr(stmt, SQL_ATTR_IMP_ROW_DESC, &ird, 0, nullptr), + stmt, SQL_HANDLE_STMT); + SQLSMALLINT descriptorCount = 0; + CHECK_ODBC_OK(SQLGetDescField( + ird, 0, SQL_DESC_COUNT, &descriptorCount, 0, nullptr), ird, SQL_HANDLE_DESC); + EXPECT_EQ(descriptorCount, 1); + std::memset(columnName, 0, sizeof(columnName)); + CHECK_ODBC_OK(SQLDescribeCol( + stmt, 1, reinterpret_cast(columnName), sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(columnName, "value"); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLINTEGER value = 0; + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_LONG, &value, sizeof(value), nullptr), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(value, 1); + SQLFreeStmt(stmt, SQL_CLOSE); + + for (const char* query : { + "SELECT 1 AS value; -- trailing line comment", + "SELECT 1 AS value; /* trailing block comment */", + "PRAGMA TablePathPrefix = \"/local\"; SELECT 1 AS value; -- prologue", + "$rows = (SELECT 1 AS value); SELECT * FROM $rows;", + "SELECT 'can\\'t; stop' AS value; -- escaped quote"}) { + CHECK_ODBC_OK(SQLPrepare(stmt, (SQLCHAR*)query, SQL_NTS), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLFreeStmt(stmt, SQL_CLOSE); + } + + // Metadata fallback must not consume application parameter values before + // execution. Parameterized SELECTs remain unsupported until YDB provides + // a compile-only result-schema API. + CHECK_ODBC_OK(SQLPrepare(stmt, (SQLCHAR*)"SELECT ? AS value", SQL_NTS), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLNumResultCols(stmt, &columnCount), SQL_ERROR); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "HYC00")); + SQLINTEGER parameter = 1; + CHECK_ODBC_OK(SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER, + 0, 0, ¶meter, 0, nullptr), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLCloseCursor(stmt), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + CHECK_ODBC_OK(SQLFreeStmt(stmt, SQL_RESET_PARAMS), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + EXPECT_EQ(SQLBindParameter(stmt, 1, SQL_PARAM_OUTPUT, SQL_C_LONG, SQL_INTEGER, + 0, 0, ¶meter, 0, nullptr), SQL_ERROR); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + SQLUSMALLINT paramStatus = SQL_PARAM_UNUSED; + SQLULEN paramsProcessed = 0; + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_PARAM_STATUS_PTR, ¶mStatus, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_PARAMS_PROCESSED_PTR, ¶msProcessed, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + SQLHDESC apd = SQL_NULL_HDESC; + CHECK_ODBC_OK(SQLGetStmtAttr(stmt, SQL_ATTR_APP_PARAM_DESC, &apd, 0, nullptr), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetDescField(apd, 1, SQL_DESC_CONCISE_TYPE, + (SQLPOINTER)SQL_C_ULONG, 0), apd, SQL_HANDLE_DESC); + EXPECT_EQ(SQLNumResultCols(stmt, &columnCount), SQL_ERROR); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "HYC00")); + + SQLUINTEGER unsignedParameter = 1; + CHECK_ODBC_OK(SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, SQL_C_ULONG, SQL_INTEGER, + 0, 0, &unsignedParameter, 0, nullptr), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLCloseCursor(stmt), stmt, SQL_HANDLE_STMT); + SQLHDESC explicitApd = SQL_NULL_HDESC; + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_DESC, dbc, &explicitApd), SQL_SUCCESS); + CHECK_ODBC_OK(SQLCopyDesc(apd, explicitApd), apd, SQL_HANDLE_DESC); + CHECK_ODBC_OK(SQLSetDescField(explicitApd, 1, SQL_DESC_CONCISE_TYPE, + (SQLPOINTER)SQL_C_LONG, 0), explicitApd, SQL_HANDLE_DESC); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_APP_PARAM_DESC, explicitApd, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLCloseCursor(stmt), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFreeHandle(SQL_HANDLE_DESC, explicitApd), SQL_SUCCESS); + EXPECT_EQ(SQLNumResultCols(stmt, &columnCount), SQL_ERROR); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "HYC00")); + + // Executing a catalog function cancels an older prepared statement and + // its cached schema instead of allowing stale SQLExecute state to survive. + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"SELECT * FROM result_metadata_before_execute_test", SQL_NTS), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 2); + CHECK_ODBC_OK(SQLTables(stmt, nullptr, 0, nullptr, 0, + (SQLCHAR*)"result_metadata_before_execute_test", SQL_NTS, + (SQLCHAR*)"TABLE", SQL_NTS), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + SQLFreeStmt(stmt, SQL_CLOSE); + EXPECT_EQ(SQLExecute(stmt), SQL_ERROR); + const std::string executeError = GetOdbcError(stmt, SQL_HANDLE_STMT); + EXPECT_TRUE(SqlStatePrefix(executeError, "HY007") || SqlStatePrefix(executeError, "HY010")) + << executeError; + + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE result_metadata_before_execute_test", SQL_NTS), + stmt, SQL_HANDLE_STMT); + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(StatementApi, PreparedResultMetadataTracksCurrentCatalog) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE IF EXISTS `/local/cat_a/prepared_catalog_metadata`", SQL_NTS); + SQLFreeStmt(stmt, SQL_CLOSE); + SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE IF EXISTS `/local/cat_b/prepared_catalog_metadata`", SQL_NTS); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"CREATE TABLE `/local/cat_a/prepared_catalog_metadata` (" + "id Int32 NOT NULL, a_value Utf8, PRIMARY KEY (id))", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"CREATE TABLE `/local/cat_b/prepared_catalog_metadata` (" + "id Int32 NOT NULL, b_value Int64, extra Bool, PRIMARY KEY (id))", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLSetConnectAttr( + dbc, SQL_ATTR_CURRENT_CATALOG, (SQLPOINTER)"/local/cat_a", SQL_NTS), + dbc, SQL_HANDLE_DBC); + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"SELECT * FROM prepared_catalog_metadata", SQL_NTS), + stmt, SQL_HANDLE_STMT); + + SQLSMALLINT columnCount = 0; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 2); + char columnName[32] = {}; + SQLSMALLINT nameLength = 0; + SQLSMALLINT dataType = SQL_UNKNOWN_TYPE; + SQLULEN columnSize = 0; + SQLSMALLINT decimalDigits = 0; + SQLSMALLINT nullable = SQL_NULLABLE_UNKNOWN; + bool sawAValue = false; + for (SQLUSMALLINT column = 1; column <= columnCount; ++column) { + std::memset(columnName, 0, sizeof(columnName)); + CHECK_ODBC_OK(SQLDescribeCol( + stmt, column, reinterpret_cast(columnName), sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), stmt, SQL_HANDLE_STMT); + if (std::strcmp(columnName, "a_value") == 0) { + sawAValue = true; + EXPECT_EQ(dataType, SQL_VARCHAR); + } + } + EXPECT_TRUE(sawAValue); + + CHECK_ODBC_OK(SQLSetConnectAttr( + dbc, SQL_ATTR_CURRENT_CATALOG, (SQLPOINTER)"/local/cat_b", SQL_NTS), + dbc, SQL_HANDLE_DBC); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + ASSERT_EQ(columnCount, 3); + bool sawBValue = false; + for (SQLUSMALLINT column = 1; column <= columnCount; ++column) { + std::memset(columnName, 0, sizeof(columnName)); + CHECK_ODBC_OK(SQLDescribeCol( + stmt, column, reinterpret_cast(columnName), sizeof(columnName), &nameLength, + &dataType, &columnSize, &decimalDigits, &nullable), stmt, SQL_HANDLE_STMT); + if (std::strcmp(columnName, "b_value") == 0) { + sawBValue = true; + EXPECT_EQ(dataType, SQL_BIGINT); + } + } + EXPECT_TRUE(sawBValue); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLSetConnectAttr( + dbc, SQL_ATTR_CURRENT_CATALOG, (SQLPOINTER)"/local", SQL_NTS), + dbc, SQL_HANDLE_DBC); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE `/local/cat_a/prepared_catalog_metadata`", SQL_NTS), + stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE `/local/cat_b/prepared_catalog_metadata`", SQL_NTS), + stmt, SQL_HANDLE_STMT); + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(StatementApi, RowCount) { SQLHENV env; SQLHDBC dbc; @@ -389,6 +749,9 @@ TEST(StatementApi, RowCount) { SQL_NTS), stmt, SQL_HANDLE_STMT); SQLLEN rowCount = -2; + SQLSMALLINT resultColumns = -1; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &resultColumns), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(resultColumns, 0); CHECK_ODBC_OK(SQLRowCount(stmt, &rowCount), stmt, SQL_HANDLE_STMT); EXPECT_EQ(rowCount, -1); SQLFreeStmt(stmt, SQL_CLOSE); @@ -399,6 +762,8 @@ TEST(StatementApi, RowCount) { SQLLEN diagRowCount = -2; CHECK_ODBC_OK(SQLGetDiagField(SQL_HANDLE_STMT, stmt, 0, SQL_DIAG_ROW_COUNT, &diagRowCount, 0, nullptr), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &resultColumns), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(resultColumns, 0); CHECK_ODBC_OK(SQLRowCount(stmt, &rowCount), stmt, SQL_HANDLE_STMT); EXPECT_EQ(rowCount, 3); EXPECT_EQ(diagRowCount, rowCount); @@ -434,7 +799,7 @@ TEST(StatementApi, RowCount) { SQLFreeStmt(stmt, SQL_CLOSE); CHECK_ODBC_OK(SQLPrepare(stmt, - (SQLCHAR*)"DECLARE $p1 AS Int32?;\n" + (SQLCHAR*)"DECLARE $p1 AS Int32? /* nullable */;\n" "UPDATE row_count_test SET value = value + 1 WHERE id = $p1", SQL_NTS), stmt, SQL_HANDLE_STMT); SQLINTEGER nativeId = 2; @@ -476,7 +841,9 @@ TEST(StatementApi, RowCountAggregatesParameterArrays) { SQLFreeStmt(stmt, SQL_CLOSE); CHECK_ODBC_OK(SQLPrepare(stmt, - (SQLCHAR*)"UPSERT INTO row_count_param_test (id, value) VALUES (?, ?)", + (SQLCHAR*)"declare /* key */ $p1 as Int32;\n" + "DECLARE $p2 AS Int32;\n" + "UPSERT INTO row_count_param_test (id, value) VALUES ($p1, $p2)", SQL_NTS), stmt, SQL_HANDLE_STMT); SQLINTEGER ids[] = {1, 2, 3}; SQLINTEGER values[] = {10, 20, 30}; @@ -516,6 +883,84 @@ TEST(StatementApi, RowCountAggregatesParameterArrays) { SQLFreeHandle(SQL_HANDLE_ENV, env); } +TEST(StatementApi, PreparedInsertUsesPerSetParameterNullability) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE IF EXISTS prepared_insert_nullability_test", SQL_NTS); + SQLFreeStmt(stmt, SQL_CLOSE); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"CREATE TABLE prepared_insert_nullability_test (" + "id Int64 NOT NULL, name Utf8, PRIMARY KEY (id))", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLPrepare(stmt, + (SQLCHAR*)"INSERT INTO prepared_insert_nullability_test (id, name) VALUES (?, ?)", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLBIGINT ids[] = {1, 2}; + char names[][16] = {"alpha", "ignored"}; + SQLLEN idLengths[] = {sizeof(SQLBIGINT), sizeof(SQLBIGINT)}; + SQLLEN nameLengths[] = {5, SQL_NULL_DATA}; + SQLUSMALLINT statuses[] = {SQL_PARAM_UNUSED, SQL_PARAM_UNUSED}; + SQLULEN processed = 0; + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_PARAMSET_SIZE, + reinterpret_cast(2), 0), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_PARAM_STATUS_PTR, + statuses, 0), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_PARAMS_PROCESSED_PTR, + &processed, 0), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, + SQL_C_SBIGINT, SQL_BIGINT, 19, 0, ids, sizeof(SQLBIGINT), idLengths), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindParameter(stmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 16, 0, names, sizeof(names[0]), nameLengths), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecute(stmt), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(processed, 2); + EXPECT_EQ(statuses[0], SQL_PARAM_SUCCESS); + EXPECT_EQ(statuses[1], SQL_PARAM_SUCCESS); + SQLFreeStmt(stmt, SQL_RESET_PARAMS); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"SELECT id, name FROM prepared_insert_nullability_test ORDER BY id", + SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLBIGINT id = 0; + char name[16] = {}; + SQLLEN indicator = 0; + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_SBIGINT, &id, sizeof(id), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(id, 1); + CHECK_ODBC_OK(SQLGetData(stmt, 2, SQL_C_CHAR, name, sizeof(name), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_STREQ(name, "alpha"); + + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLGetData(stmt, 1, SQL_C_SBIGINT, &id, sizeof(id), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(id, 2); + CHECK_ODBC_OK(SQLGetData(stmt, 2, SQL_C_CHAR, name, sizeof(name), &indicator), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(indicator, SQL_NULL_DATA); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + SQLFreeStmt(stmt, SQL_CLOSE); + + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"DROP TABLE prepared_insert_nullability_test", SQL_NTS), + stmt, SQL_HANDLE_STMT); + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + TEST(StatementApi, AttrQueryTimeout) { SQLHENV env; SQLHDBC dbc; @@ -577,6 +1022,17 @@ TEST(StatementApi, AttrNoScan) { SQLCHAR selectEscapeFnQuery[] = "SELECT {fn ABS(-12)} AS value"; CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_NOSCAN, (SQLPOINTER)SQL_NOSCAN_OFF, 0), stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLPrepare(stmt, selectEscapeFnQuery, SQL_NTS), stmt, SQL_HANDLE_STMT); + SQLSMALLINT columnCount = 0; + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_NOSCAN, (SQLPOINTER)SQL_NOSCAN_ON, 0), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLNumResultCols(stmt, &columnCount), SQL_ERROR); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_NOSCAN, (SQLPOINTER)SQL_NOSCAN_OFF, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLNumResultCols(stmt, &columnCount), stmt, SQL_HANDLE_STMT); + EXPECT_EQ(columnCount, 1); CHECK_ODBC_OK(SQLExecDirect(stmt, selectEscapeFnQuery, SQL_NTS), stmt, SQL_HANDLE_STMT); ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); SQLINTEGER valueInt = 0; @@ -859,8 +1315,21 @@ TEST(StatementApi, CoreBoundTypeConversions) { SQLHENV env; SQLHDBC dbc; SQLHSTMT stmt; +#ifdef ODBC_TEST_IODBC + AllocEnv(&env); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc), SQL_SUCCESS); + const SQLRETURN connectRc = SQLDriverConnect( + dbc, nullptr, + reinterpret_cast(const_cast(kIodbcWideInteropConnStr)), SQL_NTS, + nullptr, 0, nullptr, SQL_DRIVER_NOPROMPT); + CHECK_ODBC_OK(connectRc, dbc, SQL_HANDLE_DBC); +#else AllocEnvAndConnect(&env, &dbc); +#endif ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_STATIC, 0), + stmt, SQL_HANDLE_STMT); SQLExecDirect(stmt, (SQLCHAR*)"DROP TABLE IF EXISTS test_bound_types", SQL_NTS); SQLFreeStmt(stmt, SQL_CLOSE); // Keep this server-backed test on YDB's default decimal shape, which does @@ -959,13 +1428,21 @@ TEST(StatementApi, CoreBoundTypeConversions) { EXPECT_NEAR(fetchedDecimal, decimal, 1e-9); EXPECT_STREQ(fetchedDecimalText, "123.456000000"); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_FIRST, 0), SQL_SUCCESS); + std::memset(fetchedBytes, 0, sizeof(fetchedBytes)); + fetchedDecimal = 0; + ASSERT_EQ(SQLGetData(stmt, 3, SQL_C_BINARY, fetchedBytes, sizeof(fetchedBytes), &indicator), SQL_SUCCESS); + ASSERT_EQ(SQLGetData(stmt, 7, SQL_C_DOUBLE, &fetchedDecimal, sizeof(fetchedDecimal), &indicator), SQL_SUCCESS); + EXPECT_EQ(std::memcmp(fetchedBytes, bytes, sizeof(bytes)), 0); + EXPECT_NEAR(fetchedDecimal, decimal, 1e-9); + SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); } -TEST(StatementApi, ForwardOnlyCursorAttributes) { +TEST(StatementApi, CursorAttributesAndCapabilities) { SQLHENV env; SQLHDBC dbc; SQLHSTMT stmt; @@ -977,20 +1454,266 @@ TEST(StatementApi, ForwardOnlyCursorAttributes) { EXPECT_EQ(value, SQL_CURSOR_FORWARD_ONLY); ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_SCROLLABLE, &value, 0, nullptr), SQL_SUCCESS); EXPECT_EQ(value, SQL_NONSCROLLABLE); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CONCURRENCY, &value, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(value, SQL_CONCUR_READ_ONLY); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CONCURRENCY, + (SQLPOINTER)SQL_CONCUR_READ_ONLY, 0), + stmt, SQL_HANDLE_STMT); - EXPECT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_SCROLLABLE, - (SQLPOINTER)SQL_SCROLLABLE, 0), + CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)"SELECT 1", SQL_NTS), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLFetchScroll(stmt, SQL_FETCH_PRIOR, 0), SQL_ERROR); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "HY106")); + CHECK_ODBC_OK(SQLFreeStmt(stmt, SQL_CLOSE), stmt, SQL_HANDLE_STMT); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_SCROLLABLE, + (SQLPOINTER)SQL_SCROLLABLE, 0), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_SCROLLABLE, &value, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(value, SQL_SCROLLABLE); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, &value, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(value, SQL_CURSOR_STATIC); + + EXPECT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CONCURRENCY, + (SQLPOINTER)SQL_CONCUR_VALUES, 0), SQL_SUCCESS_WITH_INFO); EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S02")); - ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_SCROLLABLE, &value, 0, nullptr), SQL_SUCCESS); - EXPECT_EQ(value, SQL_NONSCROLLABLE); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CONCURRENCY, &value, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(value, SQL_CONCUR_READ_ONLY); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0), + stmt, SQL_HANDLE_STMT); EXPECT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, - (SQLPOINTER)SQL_CURSOR_STATIC, 0), + (SQLPOINTER)SQL_CURSOR_KEYSET_DRIVEN, 0), SQL_SUCCESS_WITH_INFO); EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S02")); ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, &value, 0, nullptr), SQL_SUCCESS); - EXPECT_EQ(value, SQL_CURSOR_FORWARD_ONLY); + EXPECT_EQ(value, SQL_CURSOR_STATIC); + + SQLUINTEGER info = 0; + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_STATIC_CURSOR_ATTRIBUTES1, &info, 0, nullptr), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(info & (SQL_CA1_NEXT | SQL_CA1_ABSOLUTE | SQL_CA1_RELATIVE), + SQL_CA1_NEXT | SQL_CA1_ABSOLUTE | SQL_CA1_RELATIVE); + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_SCROLL_OPTIONS, &info, 0, nullptr), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(info & (SQL_SO_FORWARD_ONLY | SQL_SO_STATIC), + SQL_SO_FORWARD_ONLY | SQL_SO_STATIC); + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_SCROLL_CONCURRENCY, &info, 0, nullptr), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(info & SQL_SCCO_READ_ONLY, SQL_SCCO_READ_ONLY); + CHECK_ODBC_OK(SQLGetInfo(dbc, SQL_STATIC_CURSOR_ATTRIBUTES2, &info, 0, nullptr), + dbc, SQL_HANDLE_DBC); + EXPECT_EQ(info & SQL_CA2_READ_ONLY_CONCURRENCY, SQL_CA2_READ_ONLY_CONCURRENCY); + + CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)"SELECT 1", SQL_NTS), + stmt, SQL_HANDLE_STMT); + EXPECT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0), + SQL_ERROR); + const std::string cursorTypeError = GetOdbcError(stmt, SQL_HANDLE_STMT); + EXPECT_TRUE(SqlStatePrefix(cursorTypeError, "24000")) << cursorTypeError; + EXPECT_EQ(SQLSetStmtAttr(stmt, SQL_ATTR_CONCURRENCY, + (SQLPOINTER)SQL_CONCUR_READ_ONLY, 0), + SQL_ERROR); + const std::string concurrencyError = GetOdbcError(stmt, SQL_HANDLE_STMT); + EXPECT_TRUE(SqlStatePrefix(concurrencyError, "24000")) << concurrencyError; + CHECK_ODBC_OK(SQLFreeStmt(stmt, SQL_CLOSE), stmt, SQL_HANDLE_STMT); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(StatementApi, ForwardCursorCapsRowsetReservationToAvailableRows) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + const SQLULEN rowArraySize = std::numeric_limits::max() / 2; + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROW_ARRAY_SIZE, + (SQLPOINTER)(uintptr_t)rowArraySize, 0), + stmt, SQL_HANDLE_STMT); + + SQLINTEGER value = 0; + SQLLEN indicator = 0; + SQLULEN fetched = 0; + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROWS_FETCHED_PTR, &fetched, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindCol(stmt, 1, SQL_C_LONG, &value, 0, &indicator), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecDirect(stmt, (SQLCHAR*)"SELECT 1", SQL_NTS), + stmt, SQL_HANDLE_STMT); + + ASSERT_EQ(SQLFetch(stmt), SQL_SUCCESS); + EXPECT_EQ(fetched, 1); + EXPECT_EQ(value, 1); + EXPECT_EQ(SQLFetch(stmt), SQL_NO_DATA); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(StatementApi, StaticCursorScrollsRowsets) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_STATIC, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROW_ARRAY_SIZE, + (SQLPOINTER)(uintptr_t)3, 0), + stmt, SQL_HANDLE_STMT); + + SQLINTEGER values[3] = {}; + SQLLEN indicators[3] = {}; + SQLUSMALLINT statuses[3] = {}; + SQLULEN fetched = 0; + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROW_STATUS_PTR, statuses, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROWS_FETCHED_PTR, &fetched, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindCol(stmt, 1, SQL_C_LONG, values, 0, indicators), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"SELECT * FROM AS_TABLE(ListMap(ListFromRange(1, 8), " + "($x) -> (AsStruct($x AS value)))) ORDER BY value", + SQL_NTS), stmt, SQL_HANDLE_STMT); + + SQLULEN rowNumber = 0; + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 0), SQL_SUCCESS); + EXPECT_EQ(fetched, 3); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + EXPECT_EQ(values[2], 3); + EXPECT_EQ(statuses[0], SQL_ROW_SUCCESS); + EXPECT_EQ(statuses[1], SQL_ROW_SUCCESS); + EXPECT_EQ(statuses[2], SQL_ROW_SUCCESS); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_ROW_NUMBER, &rowNumber, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(rowNumber, 1); + SQLINTEGER current = 0; + SQLLEN currentIndicator = 0; + ASSERT_EQ(SQLGetData(stmt, 1, SQL_C_LONG, ¤t, 0, ¤tIndicator), SQL_SUCCESS); + EXPECT_EQ(current, 1); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROW_ARRAY_SIZE, + (SQLPOINTER)(uintptr_t)2, 0), + stmt, SQL_HANDLE_STMT); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 0), SQL_SUCCESS); + EXPECT_EQ(fetched, 2); + EXPECT_EQ(values[0], 4); + EXPECT_EQ(values[1], 5); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_ROW_NUMBER, &rowNumber, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(rowNumber, 4); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_PRIOR, 0), SQL_SUCCESS); + EXPECT_EQ(values[0], 2); + EXPECT_EQ(values[1], 3); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_FIRST, 0), SQL_SUCCESS); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_RELATIVE, 3), SQL_SUCCESS); + EXPECT_EQ(values[0], 4); + EXPECT_EQ(values[1], 5); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_LAST, 0), SQL_SUCCESS); + EXPECT_EQ(values[0], 6); + EXPECT_EQ(values[1], 7); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_ROW_NUMBER, &rowNumber, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(rowNumber, 6); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, -3), SQL_SUCCESS); + EXPECT_EQ(values[0], 5); + EXPECT_EQ(values[1], 6); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, 0), SQL_NO_DATA); + EXPECT_EQ(fetched, 0); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 0), SQL_SUCCESS); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, 100), SQL_NO_DATA); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_PRIOR, 0), SQL_SUCCESS); + EXPECT_EQ(values[0], 6); + EXPECT_EQ(values[1], 7); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, 2), SQL_SUCCESS); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_PRIOR, 0), SQL_SUCCESS_WITH_INFO); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S06")); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, 6), SQL_SUCCESS); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_RELATIVE, -6), SQL_SUCCESS_WITH_INFO); + EXPECT_TRUE(SqlStatePrefix(GetOdbcError(stmt, SQL_HANDLE_STMT), "01S06")); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, -1), SQL_SUCCESS); + EXPECT_EQ(fetched, 1); + EXPECT_EQ(values[0], 7); + EXPECT_EQ(statuses[0], SQL_ROW_SUCCESS); + EXPECT_EQ(statuses[1], SQL_ROW_NOROW); + ASSERT_EQ(SQLGetStmtAttr(stmt, SQL_ATTR_ROW_NUMBER, &rowNumber, 0, nullptr), SQL_SUCCESS); + EXPECT_EQ(rowNumber, 7); + ASSERT_EQ(SQLGetData(stmt, 1, SQL_C_LONG, ¤t, 0, ¤tIndicator), SQL_SUCCESS); + EXPECT_EQ(current, 7); + + SQLFreeHandle(SQL_HANDLE_STMT, stmt); + SQLDisconnect(dbc); + SQLFreeHandle(SQL_HANDLE_DBC, dbc); + SQLFreeHandle(SQL_HANDLE_ENV, env); +} + +TEST(StatementApi, StaticCursorHonorsMaxRows) { + SQLHENV env; + SQLHDBC dbc; + SQLHSTMT stmt; + AllocEnvAndConnect(&env, &dbc); + ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt), SQL_SUCCESS); + + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_CURSOR_TYPE, + (SQLPOINTER)SQL_CURSOR_STATIC, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_MAX_ROWS, + (SQLPOINTER)(uintptr_t)5, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROW_ARRAY_SIZE, + (SQLPOINTER)(uintptr_t)3, 0), + stmt, SQL_HANDLE_STMT); + + SQLINTEGER values[3] = {}; + SQLLEN indicators[3] = {}; + SQLULEN fetched = 0; + CHECK_ODBC_OK(SQLSetStmtAttr(stmt, SQL_ATTR_ROWS_FETCHED_PTR, &fetched, 0), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLBindCol(stmt, 1, SQL_C_LONG, values, 0, indicators), + stmt, SQL_HANDLE_STMT); + CHECK_ODBC_OK(SQLExecDirect(stmt, + (SQLCHAR*)"SELECT * FROM AS_TABLE(ListMap(ListFromRange(1, 9), " + "($x) -> (AsStruct($x AS value)))) ORDER BY value", + SQL_NTS), stmt, SQL_HANDLE_STMT); + + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_LAST, 0), SQL_SUCCESS); + EXPECT_EQ(fetched, 3); + EXPECT_EQ(values[0], 3); + EXPECT_EQ(values[1], 4); + EXPECT_EQ(values[2], 5); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_ABSOLUTE, 6), SQL_NO_DATA); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_FIRST, 0), SQL_SUCCESS); + ASSERT_EQ(SQLFetchScroll(stmt, SQL_FETCH_NEXT, 0), SQL_SUCCESS); + EXPECT_EQ(fetched, 2); + EXPECT_EQ(values[0], 4); + EXPECT_EQ(values[1], 5); SQLFreeHandle(SQL_HANDLE_STMT, stmt); SQLDisconnect(dbc); diff --git a/odbc/tests/integration/test_utils.h b/odbc/tests/integration/test_utils.h index b0eae27334..21046e71de 100644 --- a/odbc/tests/integration/test_utils.h +++ b/odbc/tests/integration/test_utils.h @@ -4,6 +4,9 @@ #include #include +#ifdef ODBC_TEST_IODBC +#include +#endif #include #include @@ -27,6 +30,16 @@ inline std::string GetOdbcError(SQLHANDLE handle, SQLSMALLINT type) { inline const char* kConnStr = "Driver=" ODBC_DRIVER_PATH ";Server=localhost:2136;Database=/local;"; +#ifdef ODBC_TEST_IODBC +// Test-harness workaround for iODBC 3.52, not the driver's SQLWCHAR ABI. +// iODBC classifies this driver as ANSI because it does not export SQLConnectW. +// It then binds SQL_C_WCHAR as SQL_C_CHAR, but skips the required UCS-4-to-ANSI +// rebind when both code-page hints are UCS-4. A distinct driver hint forces +// that conversion; the driver receives SQL_C_CHAR, never UTF-16 code units. +inline const char* kIodbcWideInteropConnStr = "Driver=" ODBC_DRIVER_PATH + ";DriverUnicodeType=utf16;Server=localhost:2136;Database=/local;"; +#endif + inline bool SqlStatePrefix(std::string_view diag, std::string_view state) { return diag.starts_with(state); } @@ -40,6 +53,13 @@ inline void AllocEnv(SQLHENV* env) { } ASSERT_EQ(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, env), SQL_SUCCESS); ASSERT_EQ(SQLSetEnvAttr(*env, SQL_ATTR_ODBC_VERSION, (void*)SQL_OV_ODBC3, 0), SQL_SUCCESS); +#ifdef ODBC_TEST_IODBC + // iODBC permits the application Unicode code page to come from external + // configuration. Declare the integration binary's wchar_t (UCS-4) ABI + // explicitly before allocating a DBC, as iODBC's Unicode sample does. + ASSERT_EQ(SQLSetEnvAttr(*env, SQL_ATTR_APP_UNICODE_TYPE, + (SQLPOINTER)SQL_DM_CP_DEF, 0), SQL_SUCCESS); +#endif } inline void AllocEnvAndConnect(SQLHENV* env, SQLHDBC* dbc) { diff --git a/odbc/tests/test_integration_harness.py b/odbc/tests/test_integration_harness.py index 2bf7de84b8..43e408c465 100644 --- a/odbc/tests/test_integration_harness.py +++ b/odbc/tests/test_integration_harness.py @@ -11,91 +11,150 @@ def registry(self, change=lambda data, root: None): root = Path(context.name) / "frameworks"; shutil.copytree(FRAMEWORKS, root) path = root / "registry.yaml"; data = yaml.safe_load(path.read_text()) change(data, root); path.write_text(yaml.safe_dump(data, sort_keys=False)); return path - def validate(self, tests, expected): + def validate(self, tests, expected, test_rc=0): context = tempfile.TemporaryDirectory(); self.addCleanup(context.cleanup) root = Path(context.name); native, allure = root / "native", root / "allure" native.mkdir(); allure.mkdir(); (native / "results.json").write_text(json.dumps({"tests": tests})) for index in range(len(tests)): (allure / f"{index}-result.json").write_text("{}") with mock.patch.object(harness, "RESULTS", root): - return harness.validate_results({"id": "sample", "expected": expected}, native / "results.json", allure) + return harness.validate_results({"id": "sample", "expected": expected}, + native / "results.json", allure, test_rc) def test_registry_guards(self): registry = harness.load_registry() - self.assertEqual(len(registry["consumers"]), 3) - self.assertEqual(harness.infrastructure_test_id("qt", "dsn"), "qt.dsn.infrastructure") - self.assertEqual(harness.infrastructure_test_id("qt", "connection_string"), - "qt.connection_string.infrastructure") - self.assertEqual(harness.infrastructure_test_id("package-contract", "all"), - "package-contract.infrastructure") - self.assertEqual({run["run_id"] for run in harness.consumer_runs(registry)}, - {"package-contract", "isql-dsn", "qt-dsn", "qt-connection_string"}) - cases = [(lambda data, _: data["consumers"].append(dict(data["consumers"][0])), "duplicate"), - (lambda data, _: data["consumers"][0].update(runtime_image="ubuntu:24.04"), "digest-pinned"), - (lambda data, _: data["consumers"][2]["source"].update(sha256="0" * 64), "upstream.lock"), - (lambda data, _: data["consumers"][2]["expected"]["unsupported"].update( - {"qt.*.qsqlquery.*": "too broad"}), "suite-wide Qt exclusions")] + runs = list(harness.consumer_runs(registry)) + expected_run_ids = { + consumer["id"] if mode == "all" else f"{consumer['id']}-{mode}" + for consumer in registry["consumers"] for mode in (consumer.get("modes") or ["all"]) + } + self.assertEqual({run["run_id"] for run in runs}, expected_run_ids) + self.assertEqual(len(runs), len(expected_run_ids)) + self.assertEqual(harness.infrastructure_test_id("consumer", "dsn"), + "consumer.dsn.infrastructure") + self.assertEqual(harness.infrastructure_test_id("consumer", "all"), + "consumer.infrastructure") + def framework(data): + return next(item for item in data["consumers"] if item["kind"] == "framework") + def discovered(data): + return next(item for item in data["consumers"] + if item.get("expected", {}).get("discovered")) + def duplicate(data, _): + data["consumers"].append(dict(data["consumers"][0])) + def unpinned(data, _): + data["consumers"][0]["runtime_image"] = "ubuntu:24.04" + def changed_source(data, _): + framework(data)["source"]["sha256"] = "0" * 64 + def add_classification(data, outcome, reason, consumer, pattern): + data.setdefault("classifications", []).append({ + "outcome": outcome, "reason": reason, "tests": {consumer: [pattern]}, + }) + def broad_failure(data, _): + consumer = discovered(data) + add_classification(data, "unsupported", "too broad", consumer["id"], + f"{consumer['id']}.*.future_suite.*") + def duplicate_classification(data, _): + consumer = discovered(data) + pattern = f"{consumer['id']}.*.future_suite.case" + add_classification(data, "unsupported", "known failure", consumer["id"], pattern) + add_classification(data, "skipped", "also skipped", consumer["id"], pattern) + def empty_reason(data, _): + consumer = discovered(data) + add_classification(data, "unsupported", "", consumer["id"], + f"{consumer['id']}.*.future_suite.case") + cases = [(duplicate, "duplicate consumer"), + (unpinned, "digest-pinned"), + (changed_source, "upstream.lock"), + (broad_failure, "suite-wide expected-result patterns"), + (duplicate_classification, "duplicate or unexplained expectations"), + (empty_reason, "reason must be a non-empty string")] for change, message in cases: with self.subTest(message=message), self.assertRaisesRegex(harness.HarnessError, message): harness.load_registry(self.registry(change)) - unsupported = registry["consumers"][2]["expected"]["unsupported"] - required_cases = { - "qt.dsn.qsqldatabase.tables", - "qt.dsn.qsqldatabase.transaction", - "qt.dsn.qsqldatabase.bigIntField", - "qt.dsn.qsqldatabase.precisionPolicy", - "qt.dsn.qsqldatabase.formatValueTrimStrings", - "qt.dsn.qsqldriver.record", - "qt.dsn.qsqldriver.primaryIndex", - "qt.dsn.qsqldriver.formatValue", - "qt.dsn.qsqlquery.next", - "qt.dsn.qsqlquery.blob", - "qt.dsn.qsqlquery.char1SelectUnicode", - "qt.dsn.qsqlquery.writeNull", - "qt.dsn.qsqlquery.batchExec", - "qt.dsn.qsqlthread.simpleThreading", - } - for test_id in required_cases: - self.assertFalse(any(harness.glob_ids({test_id}, pattern) for pattern in unsupported), test_id) - cursor_cases = { - "qt.dsn.qsqlquery.first", - "qt.dsn.qsqlquery.nullResult", - "qt.dsn.qsqlquerymodel.fetchMore", - "qt.dsn.qsqlrelationaldelegate.comboBoxEditor", - "qt.dsn.qsqlrelationaltablemodel.data", - "qt.dsn.qsqltablemodel.select", - } - for test_id in cursor_cases: - reasons = [reason for pattern, reason in unsupported.items() - if harness.glob_ids({test_id}, pattern)] - self.assertEqual(len(reasons), 1, test_id) - self.assertIn("static result snapshot", reasons[0]) - self.assertIn("delivery step 4", reasons[0]) - skipped = registry["consumers"][2]["expected"]["skipped"] - self.assertEqual(set(skipped), { - "qt.*.qsqlquery.{blob,blobsPreparedQuery}", - "qt.*.qsqlquery.numRowsAffected", - }) def test_result_guards(self): required = {"required": ["sample.case"]} self.assertFalse(self.validate([{"id": "sample.case", "status": "passed"}], required)) for tests, message in (([], "empty"), ([{"id": "other", "status": "passed"}], "missing"), ([{"id": "sample.case", "status": "skipped"}], "status")): self.assertTrue(any(message in error for error in self.validate(tests, required))) - discovered = {"discovered": True, "required": ["sample.*"], - "unsupported": {"sample.unsupported": "known"}} - tests = [{"id": "sample.ok", "status": "passed"}, - {"id": "sample.unsupported", "status": "broken", "message": "known"}] - self.assertFalse(self.validate(tests, discovered)) - expected_skip = {"discovered": True, "required": ["sample.*"], - "skipped": {"sample.upstream-skip": "upstream gate"}} + fixed_failure = {"required": ["sample.case"], + "unsupported": {"sample.case": "known failure"}} self.assertFalse(self.validate( - [{"id": "sample.upstream-skip", "status": "skipped", - "message": "Expected upstream skip: upstream gate"}], expected_skip)) - self.assertTrue(self.validate( - [{"id": "sample.upstream-skip", "status": "passed"}], expected_skip)) + [{"id": "sample.case", "status": "failed", "message": "raw assertion"}], + fixed_failure, test_rc=1)) + discovered = { + "discovered": True, + "required": ["sample.*.*"], + "unsupported": {"sample.legacy.knownFailure": "known failure"}, + "skipped": {"sample.upstream.disabled": "upstream gate"}, + } + tests = [ + {"id": "sample.core.passes", "status": "passed"}, + {"id": "sample.future.newSuiteCase", "status": "passed"}, + {"id": "sample.legacy.knownFailure", "status": "failed", "message": "failed assertion"}, + {"id": "sample.upstream.disabled", "status": "skipped", "message": "disabled upstream"}, + ] + self.assertFalse(self.validate(tests, discovered, test_rc=1)) + passing_failure = [dict(test) for test in tests] + passing_failure[2] = {"id": "sample.legacy.knownFailure", "status": "passed"} + self.assertTrue(any("for unsupported" in error + for error in self.validate(passing_failure, discovered))) + stale = dict(discovered) + stale["unsupported"] = dict(discovered["unsupported"]) + stale["unsupported"]["sample.removed.case"] = "stale" + self.assertTrue(any("pattern matched no tests" in error + for error in self.validate(tests, stale, test_rc=1))) + ambiguous = { + "discovered": True, + "required": ["sample.*"], + "unsupported": {"sample.*.knownFailure": "known failure"}, + "skipped": {"sample.legacy.*": "upstream gate"}, + } + self.assertTrue(any("ambiguous expected result" in error for error in self.validate( + [{"id": "sample.legacy.knownFailure", "status": "failed", "message": "failure"}], + ambiguous, test_rc=1))) + self.assertTrue(any("although every reported test passed" in error for error in self.validate( + [{"id": "sample.core.passes", "status": "passed"}], + {"discovered": True, "required": ["sample.*"]}, test_rc=2))) + self.assertEqual(harness.glob_ids( + {"sample.one.alpha", "sample.two.beta", "sample.three.gamma"}, + "sample.{one,two}.{alpha,beta}"), + {"sample.one.alpha", "sample.two.beta"}) infrastructure = [{"id": "sample.dsn.infrastructure", "status": "broken", "message": "fixture patch exited 2"}] errors = self.validate(infrastructure, discovered) self.assertEqual(errors, ["sample.dsn.infrastructure: fixture patch exited 2"]) + def test_manual_expected_reason_reaches_allure(self): + context = tempfile.TemporaryDirectory(); self.addCleanup(context.cleanup) + root = Path(context.name); native, allure = root / "native" / "results.json", root / "allure" + native.parent.mkdir() + native.write_text(json.dumps({"tests": [{ + "id": "sample.suite.knownFailure", "name": "known failure", "status": "failed", + "message": "raw assertion", "trace": "raw stack", "start": 1, "stop": 2, + }]})) + config = {"classifications": [ + {"outcome": "unsupported", "reason": "manually classified reason", + "tests": {"sample": ["sample.suite.knownFailure"]}}, + {"outcome": "unsupported", "reason": "second reason", + "tests": {"sample": ["sample.suite.knownFailure"]}}, + ]} + expected = {"discovered": True, "required": ["sample.*.*"]} + expected.update(harness.load_classifications(config, [{"id": "sample"}])["sample"]) + consumer = {"id": "sample", "expected": expected} + harness.annotate_expected_results(consumer, native) + metadata = { + "consumer": "sample", "language": "C++", "tier": "core", + "driver_commit": "commit", "runtime_version": "runtime", "ydb_image": "ydb", + "connection_mode": "dsn", "run_id": "sample-dsn", "endpoint": "localhost:2136", + "database": "/local", "package_sha256": "package", + "upstream_revision": "revision", "upstream_sha256": "source", + } + harness.convert_allure(native, allure, metadata) + result = json.loads(next(allure.glob("*-result.json")).read_text()) + self.assertEqual(result["statusDetails"]["message"], + "Expected unsupported: manually classified reason; second reason") + self.assertTrue(result["statusDetails"]["known"]) + self.assertIn("Reported FAILED: raw assertion", result["statusDetails"]["trace"]) + self.assertIn("raw stack", result["statusDetails"]["trace"]) + self.assertIn({"name": "originalStatus", "value": "failed"}, result["labels"]) def test_allure_ids_include_connection_mode(self): context = tempfile.TemporaryDirectory(); self.addCleanup(context.cleanup) root = Path(context.name); identities = [] diff --git a/odbc/tests/unit/CMakeLists.txt b/odbc/tests/unit/CMakeLists.txt index bc43c7bb06..4a9beb4c23 100644 --- a/odbc/tests/unit/CMakeLists.txt +++ b/odbc/tests/unit/CMakeLists.txt @@ -1,3 +1,8 @@ +set(YDB_ODBC_UNIT_INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${ODBC_INCLUDE_DIRS} +) + add_ydb_test(NAME odbc-convert_ut GTEST SOURCES convert_ut.cpp @@ -5,7 +10,7 @@ add_ydb_test(NAME odbc-convert_ut GTEST ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/sql_type_map.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/util.cpp INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${YDB_ODBC_UNIT_INCLUDE_DIRS} LINK_LIBRARIES yutil YDB-CPP-SDK::Params @@ -20,7 +25,7 @@ add_ydb_test(NAME odbc-escape_ut GTEST ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/escape.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/sql_type_map.cpp INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${YDB_ODBC_UNIT_INCLUDE_DIRS} LINK_LIBRARIES yutil LABELS @@ -33,7 +38,7 @@ add_ydb_test(NAME odbc-param_rewrite_ut GTEST ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/escape.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/sql_type_map.cpp INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${YDB_ODBC_UNIT_INCLUDE_DIRS} LINK_LIBRARIES yutil LABELS @@ -45,7 +50,7 @@ add_ydb_test(NAME odbc-conn_string_ut GTEST conn_string_ut.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/utils/util.cpp INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${YDB_ODBC_UNIT_INCLUDE_DIRS} LINK_LIBRARIES yutil YDB-CPP-SDK::Params @@ -57,9 +62,20 @@ add_ydb_test(NAME odbc-sql_like_ut GTEST SOURCES sql_like_ut.cpp INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${YDB_ODBC_UNIT_INCLUDE_DIRS} LINK_LIBRARIES yutil LABELS unit ) + +add_ydb_test(NAME odbc-cursor_ut GTEST + SOURCES + cursor_ut.cpp + INCLUDE_DIRS + ${YDB_ODBC_UNIT_INCLUDE_DIRS} + LINK_LIBRARIES + ydb-odbc + LABELS + unit +) diff --git a/odbc/tests/unit/convert_ut.cpp b/odbc/tests/unit/convert_ut.cpp index b42ebe84e1..004c5e5373 100644 --- a/odbc/tests/unit/convert_ut.cpp +++ b/odbc/tests/unit/convert_ut.cpp @@ -36,7 +36,7 @@ TEST(OdbcConvert, Int64ToYdb) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: INT64\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: INT64\n"); CheckProto(value->GetProto(), "int64_value: 42\n"); } @@ -50,7 +50,7 @@ TEST(OdbcConvert, UnsignedCSelectsYdbUnsignedType) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: UINT64\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: UINT64\n"); CheckProto(value->GetProto(), "uint64_value: 123\n"); } @@ -65,7 +65,7 @@ TEST(OdbcConvert, WideStringToYdbUtf8) { ASSERT_EQ(ConvertParam(param, paramsBuilder.AddParam("$p1")), SQL_SUCCESS); const auto value = paramsBuilder.Build().GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: UTF8\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: UTF8\n"); CheckProto(value->GetProto(), "text_value: \"hello\"\n"); } @@ -79,7 +79,7 @@ TEST(OdbcConvert, TimestampStructToYdbTimestamp) { ASSERT_EQ(ConvertParam(param, paramsBuilder.AddParam("$p1")), SQL_SUCCESS); const auto value = paramsBuilder.Build().GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: TIMESTAMP\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: TIMESTAMP\n"); } TEST(OdbcConvert, DoubleToYdb) { @@ -92,7 +92,7 @@ TEST(OdbcConvert, DoubleToYdb) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: DOUBLE\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: DOUBLE\n"); CheckProto(value->GetProto(), "double_value: 3.14\n"); } @@ -107,11 +107,10 @@ TEST(OdbcConvert, DoubleToYdbDecimalPreservesPrecisionAndScale) { ASSERT_TRUE(value); TValueParser parser(*value); - const auto decimal = parser.GetOptionalDecimal(); - ASSERT_TRUE(decimal); - EXPECT_EQ(decimal->DecimalType_.Precision, 18); - EXPECT_EQ(decimal->DecimalType_.Scale, 5); - EXPECT_EQ(decimal->ToString(), "123.456"); + const auto decimal = parser.GetDecimal(); + EXPECT_EQ(decimal.DecimalType_.Precision, 18); + EXPECT_EQ(decimal.DecimalType_.Scale, 5); + EXPECT_EQ(decimal.ToString(), "123.456"); char text[16] = {}; SQLLEN textLength = 0; @@ -131,7 +130,7 @@ TEST(OdbcConvert, StringToYdbUtf8) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: UTF8\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: UTF8\n"); CheckProto(value->GetProto(), "text_value: \"hello\"\n"); } @@ -146,7 +145,7 @@ TEST(OdbcConvert, StringToYdbBinary) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: STRING\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: STRING\n"); CheckProto(value->GetProto(), "bytes_value: \"bin\\001\\002\"\n"); } @@ -190,7 +189,21 @@ TEST(OdbcConvert, Int32ToYdb) { auto params = paramsBuilder.Build(); auto value = params.GetValue("$p1"); ASSERT_TRUE(value); - CheckProto(value->GetType().GetProto(), "optional_type {\n item {\n type_id: INT32\n }\n}\n"); + CheckProto(value->GetType().GetProto(), "type_id: INT32\n"); + CheckProto(value->GetProto(), "int32_value: 42\n"); +} + +TEST(OdbcConvert, NonNullValueCanUseOptionalDeclaredType) { + SQLINTEGER v = 42; + TBoundParam param{ + 1, SQL_C_LONG, SQL_INTEGER, 0, 0, &v, sizeof(v), nullptr + }; + TParamsBuilder paramsBuilder; + ASSERT_EQ(ConvertParam(param, paramsBuilder.AddParam("$p1"), true), SQL_SUCCESS); + const auto value = paramsBuilder.Build().GetValue("$p1"); + ASSERT_TRUE(value); + CheckProto(value->GetType().GetProto(), + "optional_type {\n item {\n type_id: INT32\n }\n}\n"); CheckProto(value->GetProto(), "int32_value: 42\n"); } diff --git a/odbc/tests/unit/cursor_ut.cpp b/odbc/tests/unit/cursor_ut.cpp new file mode 100644 index 0000000000..32b51e8db9 --- /dev/null +++ b/odbc/tests/unit/cursor_ut.cpp @@ -0,0 +1,132 @@ +#include "utils/cursor_window.h" + +#include + +#include + +namespace NYdb::NOdbc { +namespace { + +TEST(CursorWindow, PreservesScrollableRowsetPositioning) { + TCursorWindow window(7); + EXPECT_EQ(window.RowNumber(), 0); + + auto fetch = window.Fetch(SQL_FETCH_NEXT, 0, 3, 0); + EXPECT_EQ(fetch.Rows, 3); + EXPECT_FALSE(fetch.OverlappedStart); + EXPECT_EQ(window.Resolve(0), 0); + EXPECT_EQ(window.Resolve(2), 2); + EXPECT_EQ(window.RowNumber(), 1); + + fetch = window.Fetch(SQL_FETCH_NEXT, 0, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_EQ(window.Resolve(0), 3); + EXPECT_EQ(window.RowNumber(), 4); + + fetch = window.Fetch(SQL_FETCH_PRIOR, 0, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_EQ(window.Resolve(0), 1); + EXPECT_EQ(window.RowNumber(), 2); + + fetch = window.Fetch(SQL_FETCH_LAST, 0, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_EQ(window.Resolve(0), 5); + EXPECT_EQ(window.RowNumber(), 6); + + fetch = window.Fetch(SQL_FETCH_ABSOLUTE, -3, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_EQ(window.Resolve(0), 4); + + EXPECT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 0, 2, 0).Rows, 0); + EXPECT_FALSE(window.Resolve(0)); + EXPECT_EQ(window.RowNumber(), 0); + EXPECT_EQ(window.Fetch(SQL_FETCH_NEXT, 0, 2, 0).Rows, 2); + EXPECT_EQ(window.Resolve(0), 0); + + EXPECT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 100, 2, 0).Rows, 0); + EXPECT_EQ(window.RowNumber(), 0); + EXPECT_EQ(window.Fetch(SQL_FETCH_PRIOR, 0, 2, 0).Rows, 2); + EXPECT_EQ(window.Resolve(0), 5); + + ASSERT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 2, 2, 0).Rows, 2); + fetch = window.Fetch(SQL_FETCH_PRIOR, 0, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_TRUE(fetch.OverlappedStart); + EXPECT_EQ(window.Resolve(0), 0); + + fetch = window.Fetch(SQL_FETCH_ABSOLUTE, -1, 2, 0); + EXPECT_EQ(fetch.Rows, 1); + EXPECT_EQ(window.Resolve(0), 6); + EXPECT_FALSE(window.Resolve(1)); +} + +TEST(CursorWindow, AppliesLimitBeforePositioning) { + TCursorWindow window(8); + auto fetch = window.Fetch(SQL_FETCH_LAST, 0, 3, 5); + EXPECT_EQ(fetch.Rows, 3); + EXPECT_EQ(window.Resolve(0), 2); + EXPECT_EQ(window.Resolve(2), 4); + + EXPECT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 6, 3, 5).Rows, 0); + EXPECT_EQ(window.Fetch(SQL_FETCH_PRIOR, 0, 3, 5).Rows, 3); + EXPECT_EQ(window.Resolve(0), 2); +} + +TEST(CursorWindow, ResolvesRelativePositionsAtBoundaries) { + TCursorWindow window(7); + + EXPECT_EQ(window.Fetch(SQL_FETCH_RELATIVE, -1, 2, 0).Rows, 0); + EXPECT_EQ(window.Fetch(SQL_FETCH_RELATIVE, 2, 2, 0).Rows, 2); + EXPECT_EQ(window.Resolve(0), 1); + + auto fetch = window.Fetch(SQL_FETCH_RELATIVE, -2, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_TRUE(fetch.OverlappedStart); + EXPECT_EQ(window.Resolve(0), 0); + + EXPECT_EQ(window.Fetch(SQL_FETCH_RELATIVE, 4, 2, 0).Rows, 2); + EXPECT_EQ(window.Resolve(0), 4); + + EXPECT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 100, 2, 0).Rows, 0); + EXPECT_EQ(window.Fetch(SQL_FETCH_RELATIVE, -2, 2, 0).Rows, 2); + EXPECT_EQ(window.Resolve(0), 5); +} + +TEST(CursorWindow, DetectsRelativeRowsetsOverlappingTheBeginning) { + TCursorWindow window(10); + + ASSERT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 6, 2, 0).Rows, 2); + auto fetch = window.Fetch(SQL_FETCH_RELATIVE, -6, 2, 0); + EXPECT_EQ(fetch.Rows, 2); + EXPECT_TRUE(fetch.OverlappedStart); + EXPECT_EQ(window.Resolve(0), 0); + EXPECT_EQ(window.Resolve(1), 1); + + ASSERT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 6, 2, 0).Rows, 2); + fetch = window.Fetch(SQL_FETCH_RELATIVE, -7, 2, 0); + EXPECT_EQ(fetch.Rows, 0); + EXPECT_FALSE(fetch.OverlappedStart); + EXPECT_FALSE(window.Resolve(0)); +} + +TEST(CursorWindow, HandlesEmptyAndExtremeOffsets) { + TCursorWindow empty(0); + EXPECT_EQ(empty.Fetch(SQL_FETCH_FIRST, 0, 1, 0).Rows, 0); + EXPECT_EQ(empty.Fetch(SQL_FETCH_LAST, 0, 1, 0).Rows, 0); + EXPECT_EQ(empty.Fetch(SQL_FETCH_ABSOLUTE, + std::numeric_limits::min(), 1, 0).Rows, + 0); + + TCursorWindow window(3); + EXPECT_EQ(window.Fetch(SQL_FETCH_NEXT, 0, 0, 0).Rows, 0); + EXPECT_FALSE(window.Resolve(0)); + + ASSERT_EQ(window.Fetch(SQL_FETCH_ABSOLUTE, 2, + std::numeric_limits::max(), 0).Rows, + 2); + EXPECT_EQ(window.Fetch(SQL_FETCH_NEXT, 0, 1, 0).Rows, 0); + EXPECT_FALSE(window.Resolve(0)); +} + +} // namespace +} // namespace NYdb::NOdbc diff --git a/odbc/tests/unit/escape_ut.cpp b/odbc/tests/unit/escape_ut.cpp index f4d352129d..634365401b 100644 --- a/odbc/tests/unit/escape_ut.cpp +++ b/odbc/tests/unit/escape_ut.cpp @@ -72,6 +72,12 @@ TEST(OdbcEscapeRewrite, LeavesQuotedEscapesAlone) { EXPECT_EQ(RewriteOdbcEscapes("SELECT '{fn ABS(1)}'"), "SELECT '{fn ABS(1)}'"); } +TEST(OdbcEscapeRewrite, LeavesBackslashEscapedQuoteAlone) { + EXPECT_EQ( + RewriteOdbcEscapes("SELECT 'can\\'{fn ABS(1)}'"), + "SELECT 'can\\'{fn ABS(1)}'"); +} + TEST(OdbcEscapeRewrite, LeavesCommentedEscapesAlone) { EXPECT_EQ( RewriteOdbcEscapes("-- {fn ABS(-1)}\nSELECT {fn ABS(-1)} /* {fn ABS(-2)} */"), diff --git a/odbc/tests/unit/param_rewrite_ut.cpp b/odbc/tests/unit/param_rewrite_ut.cpp index 94a69af404..99c09794f4 100644 --- a/odbc/tests/unit/param_rewrite_ut.cpp +++ b/odbc/tests/unit/param_rewrite_ut.cpp @@ -5,6 +5,8 @@ using NYdb::NOdbc::RewriteOdbcSql; using NYdb::NOdbc::CountOdbcParams; +using NYdb::NOdbc::GetDeclaredParamOptionality; +using NYdb::NOdbc::HasMultipleSqlStatements; using NYdb::NOdbc::StartsWithSqlStatement; using NYdb::NOdbc::TBoundParam; @@ -15,6 +17,12 @@ TBoundParam IntParam(SQLUSMALLINT n) { return {n, SQL_C_LONG, SQL_INTEGER, 0, 0, &value, 0, nullptr}; } +TBoundParam NullIntParam(SQLUSMALLINT n) { + static SQLINTEGER value = 0; + static SQLLEN indicator = SQL_NULL_DATA; + return {n, SQL_C_LONG, SQL_INTEGER, 0, 0, &value, 0, &indicator}; +} + NYdb::NOdbc::TParamRewriteResult RewriteParams( std::string_view sql, const std::vector& params) { @@ -28,8 +36,8 @@ TEST(OdbcParamRewrite, RewritesQuestionMarks) { const auto result = RewriteParams("SELECT ? + ? AS result", params); ASSERT_TRUE(result.Success); EXPECT_EQ(result.Sql, - "DECLARE $p1 AS Int32?;\n" - "DECLARE $p2 AS Int32?;\n" + "DECLARE $p1 AS Int32;\n" + "DECLARE $p2 AS Int32;\n" "SELECT $p1 + $p2 AS result"); } @@ -37,7 +45,7 @@ TEST(OdbcParamRewrite, RewritesEscapesAndParametersInOnePass) { const auto result = RewriteOdbcSql( "SELECT {fn CONVERT(?, SQL_INTEGER)}", {IntParam(1)}, true); ASSERT_TRUE(result.Success); - EXPECT_EQ(result.Sql, "DECLARE $p1 AS Int32?;\nSELECT CAST($p1 AS Int32)"); + EXPECT_EQ(result.Sql, "DECLARE $p1 AS Int32;\nSELECT CAST($p1 AS Int32)"); } TEST(OdbcParamRewrite, UsesBoundCTypeForYdbDeclaration) { @@ -47,7 +55,7 @@ TEST(OdbcParamRewrite, UsesBoundCTypeForYdbDeclaration) { &value, sizeof(value), nullptr }}; EXPECT_EQ(RewriteParams("SELECT ?", params).Sql, - "DECLARE $p1 AS Uint64?;\nSELECT $p1"); + "DECLARE $p1 AS Uint64;\nSELECT $p1"); } TEST(OdbcParamRewrite, PreservesTemporalAndDecimalTypes) { @@ -60,19 +68,33 @@ TEST(OdbcParamRewrite, PreservesTemporalAndDecimalTypes) { &decimal, sizeof(decimal), nullptr}, }; EXPECT_EQ(RewriteParams("SELECT ?, ?", params).Sql, - "DECLARE $p1 AS Timestamp?;\n" - "DECLARE $p2 AS Decimal(18, 5)?;\n" + "DECLARE $p1 AS Timestamp;\n" + "DECLARE $p2 AS Decimal(18, 5);\n" "SELECT $p1, $p2"); } TEST(OdbcParamRewrite, SkipsLiteralAndYqlOptionalSyntax) { const std::vector params = {IntParam(1)}; EXPECT_EQ(RewriteParams("SELECT '?', ?", params).Sql, - "DECLARE $p1 AS Int32?;\nSELECT '?', $p1"); + "DECLARE $p1 AS Int32;\nSELECT '?', $p1"); EXPECT_EQ(RewriteParams("DECLARE $p1 AS Int32?;\nSELECT $p1", params).Sql, "DECLARE $p1 AS Int32?;\nSELECT $p1"); EXPECT_EQ(RewriteParams("SELECT $p1 + 10", params).Sql, - "DECLARE $p1 AS Int32?;\nSELECT $p1 + 10"); + "DECLARE $p1 AS Int32;\nSELECT $p1 + 10"); +} + +TEST(OdbcParamRewrite, DetectsOptionalDeclarationsBeforeComments) { + EXPECT_EQ(GetDeclaredParamOptionality( + "DECLARE $p1 AS Int32? /* nullable */;\nSELECT $p1", 1), true); + EXPECT_EQ(GetDeclaredParamOptionality( + "DECLARE $p1 AS Int32? -- nullable\n;\nSELECT $p1", 1), true); + EXPECT_EQ(GetDeclaredParamOptionality( + "DECLARE $p1 AS Int32 /* not optional?; */;\nSELECT $p1", 1), false); + EXPECT_EQ(GetDeclaredParamOptionality( + "/* DECLARE $p1 AS Int32?; */\nSELECT $p1", 1), std::nullopt); + EXPECT_EQ(GetDeclaredParamOptionality( + "pragma TablePathPrefix = \"/local\"; declare /* p */ $p1 as Int32?; SELECT $p1", 1), + true); } TEST(OdbcParamRewrite, SkipsParameterMarkersInComments) { @@ -80,7 +102,7 @@ TEST(OdbcParamRewrite, SkipsParameterMarkersInComments) { const auto result = RewriteParams(sql, {IntParam(1)}); ASSERT_TRUE(result.Success); EXPECT_EQ(result.Sql, - "DECLARE $p1 AS Int32?;\n" + "DECLARE $p1 AS Int32;\n" "SELECT $p1 -- optional ? $p8\n/* disabled $p9 ? */"); EXPECT_EQ(CountOdbcParams(sql), 1); EXPECT_EQ(CountOdbcParams("SELECT 1 -- optional ? $p8"), 0); @@ -92,11 +114,21 @@ TEST(OdbcParamRewrite, PrependsDeclareForNativeDollarParams) { const auto result = RewriteParams("SELECT $p1 + $p2 AS result", params); ASSERT_TRUE(result.Success); EXPECT_EQ(result.Sql, - "DECLARE $p1 AS Int32?;\n" - "DECLARE $p2 AS Int32?;\n" + "DECLARE $p1 AS Int32;\n" + "DECLARE $p2 AS Int32;\n" "SELECT $p1 + $p2 AS result"); } +TEST(OdbcParamRewrite, DeclaresOnlyNullValuesOptional) { + const auto result = RewriteParams( + "SELECT ?, ?", {IntParam(1), NullIntParam(2)}); + ASSERT_TRUE(result.Success); + EXPECT_EQ(result.Sql, + "DECLARE $p1 AS Int32;\n" + "DECLARE $p2 AS Int32?;\n" + "SELECT $p1, $p2"); +} + TEST(OdbcParamRewrite, RejectsMismatchedBindCount) { const auto result = RewriteParams("SELECT ? + ?", {IntParam(1)}); ASSERT_FALSE(result.Success); @@ -112,5 +144,21 @@ TEST(OdbcParamRewrite, CountOdbcParams) { TEST(OdbcParamRewrite, ClassifiesAfterTrivia) { EXPECT_TRUE(StartsWithSqlStatement(" -- lead\n /* block */ INSERT INTO t VALUES (1)", {"INSERT"})); + EXPECT_TRUE(StartsWithSqlStatement( + "PRAGMA TablePathPrefix = \"/local\"; DECLARE $p1 AS Int32; UPSERT INTO t VALUES ($p1)", + {"UPSERT"})); + EXPECT_TRUE(StartsWithSqlStatement( + "$rows = (SELECT 1); SELECT * FROM $rows; -- trailing", {"SELECT"})); + EXPECT_TRUE(StartsWithSqlStatement( + "DEFINE ACTION $read() AS SELECT 1; END DEFINE; SELECT 2", {"SELECT"})); + EXPECT_TRUE(StartsWithSqlStatement( + "PRAGMA config = \"quoted\\\";value\"; CREATE TABLE t (id Int32)", {"CREATE"})); + EXPECT_TRUE(StartsWithSqlStatement("DELETE FROM t; SELECT * FROM t", {"DELETE"})); + EXPECT_FALSE(StartsWithSqlStatement("DELETE FROM t; SELECT * FROM t", {"SELECT"})); + EXPECT_TRUE(HasMultipleSqlStatements("DELETE FROM t; SELECT * FROM t")); + EXPECT_TRUE(HasMultipleSqlStatements("PRAGMA x; DELETE FROM t; SELECT * FROM t")); + EXPECT_FALSE(HasMultipleSqlStatements("PRAGMA x; SELECT 1; -- trailing")); + EXPECT_FALSE(HasMultipleSqlStatements( + "DEFINE ACTION $read() AS SELECT 1; END DEFINE; SELECT 2")); EXPECT_FALSE(StartsWithSqlStatement(" -- lead\n SELECT 1", {"INSERT", "UPDATE"})); } diff --git a/scripts/build_cpack_deb_packages.sh b/scripts/build_cpack_deb_packages.sh index a402a9f11d..f4f5290200 100755 --- a/scripts/build_cpack_deb_packages.sh +++ b/scripts/build_cpack_deb_packages.sh @@ -27,7 +27,7 @@ if [ "${YDB_DEB_INSTALL_DEPS:-1}" = "1" ]; then export DEBIAN_FRONTEND=noninteractive "${SUDO[@]}" apt-get update "${SUDO[@]}" apt-get install -y --no-install-recommends \ - build-essential ca-certificates ccache cmake ninja-build pkg-config git \ + build-essential ca-certificates ccache cmake file ninja-build pkg-config git \ libidn11-dev libssl-dev zlib1g-dev \ libprotobuf-dev protobuf-compiler libgrpc++-dev protobuf-compiler-grpc \ libabsl-dev libbrotli-dev liblz4-dev libzstd-dev libbz2-dev libxxhash-dev \