From 92d4e67ef2eb7c5476ff403bd369afcd7c516ef1 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 18:45:30 -0300 Subject: [PATCH 1/7] feat: ODBC-backed table driver behind the ACE ABI (builds on #18) Adds an optional, read-only ODBC table driver so a Harbour rddads app (USE / SKIP / SEEK / FieldGet) can talk to any data source that has an ODBC driver -- SQL Server, Oracle, Firebird, PostgreSQL, MariaDB, DB2, Access, ... -- without recompiling. Mirrors the existing SQL backends: a thin OdbcConnection/OdbcTable pair under src/sql_backend, HandleKind::Odbc*, and additive dispatch at the ACE border (get_odbc_table alongside get_remote_table). Off by default; enable with -DOPENADS_WITH_ODBC=ON, which links the system ODBC driver manager (odbc32 on Windows, unixODBC on Linux) -- no external dependency, the headers and import library ship with the platform SDK. Navigation uses an in-memory primary-key snapshot and only ever issues SELECT / WHERE / ORDER BY / COUNT -- no LIMIT/OFFSET/TOP and no scrollable-cursor dependency -- so it is portable across drivers. The key is discovered via SQLPrimaryKeys, falling back to the first UNIQUE index reported by SQLStatistics for drivers that do not implement SQLPrimaryKeys. Column metadata comes from SQLColumns; numeric literals are emitted unquoted (type-aware) so integer/double key comparisons work on strict engines. String literals use standard quote doubling and all identifiers are validated, so the dynamic SQL is injection-safe. Scope: read + seek + navigate. Write (append/update/delete) is a later slice; until then a write on an ODBC-backed table is rejected at the border rather than misrouted. Tests: odbc_uri_test (always built when the backend is on), plus abi_plus_odbc_{read,seek}_test which drive the ABI against an ODBC connection string in OPENADS_TEST_ODBC_CONNSTR and self-skip when it is unset. tools/scripts/run_odbc_tests.ps1 seeds a throwaway fixture and runs them; build_msvc_x64_odbc.bat configures an MSVC build. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 6 + src/CMakeLists.txt | 18 + src/abi/ace_exports.cpp | 454 ++++++++++++++++ src/session/handle_registry.h | 6 +- src/sql_backend/odbc_backend.cpp | 111 ++++ src/sql_backend/odbc_backend.h | 23 + src/sql_backend/odbc_connection.cpp | 701 +++++++++++++++++++++++++ src/sql_backend/odbc_connection.h | 67 +++ src/sql_backend/odbc_index.h | 18 + src/sql_backend/odbc_table.h | 55 ++ src/sql_backend/odbc_uri.cpp | 23 + src/sql_backend/odbc_uri.h | 18 + src/sql_backend/sql_common.cpp | 67 +++ src/sql_backend/sql_common.h | 24 + tests/CMakeLists.txt | 8 + tests/unit/abi_plus_odbc_read_test.cpp | 114 ++++ tests/unit/abi_plus_odbc_seek_test.cpp | 90 ++++ tests/unit/odbc_uri_test.cpp | 24 + tools/scripts/build_msvc_x64_odbc.bat | 13 + tools/scripts/run_odbc_tests.ps1 | 48 ++ 20 files changed, 1887 insertions(+), 1 deletion(-) create mode 100644 src/sql_backend/odbc_backend.cpp create mode 100644 src/sql_backend/odbc_backend.h create mode 100644 src/sql_backend/odbc_connection.cpp create mode 100644 src/sql_backend/odbc_connection.h create mode 100644 src/sql_backend/odbc_index.h create mode 100644 src/sql_backend/odbc_table.h create mode 100644 src/sql_backend/odbc_uri.cpp create mode 100644 src/sql_backend/odbc_uri.h create mode 100644 src/sql_backend/sql_common.cpp create mode 100644 src/sql_backend/sql_common.h create mode 100644 tests/unit/abi_plus_odbc_read_test.cpp create mode 100644 tests/unit/abi_plus_odbc_seek_test.cpp create mode 100644 tests/unit/odbc_uri_test.cpp create mode 100644 tools/scripts/build_msvc_x64_odbc.bat create mode 100644 tools/scripts/run_odbc_tests.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index d7f38db5..8a2b4b2d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,12 @@ option(OPENADS_WITH_TLS "Enable TLS transport (vendors mbedtls 3.6 LTS via Fetch # and nlohmann/json (MIT) at configure time via FetchContent. option(OPENADS_WITH_HTTP "Enable embedded HTTP web console (Studio) in openads_serverd / ace64.dll" ON) +# Optional ODBC table driver. Talks to any data source through the +# system ODBC driver manager (odbc32 on Windows, unixODBC on Linux); +# the headers and import library ship with the platform SDK, so no +# external dependency is downloaded. OFF by default. +option(OPENADS_WITH_ODBC "Enable ODBC-backed table driver (system ODBC driver manager)" OFF) + # Pull in mbedtls FIRST so our strict /WX -Werror flags don't bleed # into the upstream sources (C4200 zero-sized arrays etc). Each # OpenADS target adds the strict flags target-locally below via diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8c996c24..02804c40 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -58,6 +58,15 @@ add_library(openads_core STATIC network/mg_wire.cpp ) +if(OPENADS_WITH_ODBC) + target_sources(openads_core PRIVATE + sql_backend/sql_common.cpp + sql_backend/odbc_uri.cpp + sql_backend/odbc_backend.cpp + sql_backend/odbc_connection.cpp + ) +endif() + if(WIN32) target_sources(openads_core PRIVATE platform/file_win32.cpp @@ -98,6 +107,15 @@ if(OPENADS_WITH_TLS) MbedTLS::mbedtls MbedTLS::mbedx509 MbedTLS::mbedcrypto) endif() +if(OPENADS_WITH_ODBC) + target_compile_definitions(openads_core PUBLIC OPENADS_WITH_ODBC=1) + if(WIN32) + target_link_libraries(openads_core PUBLIC odbc32) + else() + target_link_libraries(openads_core PUBLIC odbc) + endif() +endif() + # ---------------------------------------------------------------------- # Drop-in replacement DLL: ace32.dll (x86) / ace64.dll (x64). # Harbour's contrib/rddads links against ace32.lib / ace64.lib import diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index bad7d68a..9f5063cf 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -36,6 +36,12 @@ #include "platform/time.h" #include "sql/parser.h" +#if defined(OPENADS_WITH_ODBC) +#include "sql_backend/odbc_connection.h" +#include "sql_backend/odbc_index.h" +#include "sql_backend/odbc_uri.h" +#endif + #include #include #include @@ -265,6 +271,79 @@ openads::network::RemoteIndex* get_remote_index(ADSHANDLE h) { h, HandleKind::RemoteIndex); } +#if defined(OPENADS_WITH_ODBC) +std::unordered_map>& +odbc_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +odbc_tables_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::OdbcTable* get_odbc_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::OdbcTable); +} + +std::unordered_map>& +odbc_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::OdbcIndex* get_odbc_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::OdbcIndex); +} + +std::size_t odbc_field_index(openads::sql_backend::OdbcTable* st, + UNSIGNED8* pucField) { + if (!st->fields_cached) { + if (st->conn == nullptr) { + return std::numeric_limits::max(); + } + auto r = st->conn->describe_table(st); + if (!r) return std::numeric_limits::max(); + } + { + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) { + std::size_t one_based = static_cast(p); + if (one_based >= 1 && one_based <= st->fields.size()) { + return one_based - 1; + } + return std::numeric_limits::max(); + } + } + std::string want = openads::abi::to_internal(pucField, 0); + for (auto& c : want) { + c = static_cast( + std::toupper(static_cast(c))); + } + for (std::size_t i = 0; i < st->fields.size(); ++i) { + std::string have = st->fields[i].name; + for (auto& c : have) { + c = static_cast( + std::toupper(static_cast(c))); + } + if (have == want) return i; + } + return std::numeric_limits::max(); +} +#endif // OPENADS_WITH_ODBC + // Latch set by AdsSeekLast. AdsSeek consults this to suppress its // empty-key always-found quirk when called as part of rddads' // AdsSeekLast retry chain. AdsSkip clears it. @@ -802,6 +881,39 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, return ok(); } } +#if defined(OPENADS_WITH_ODBC) + { + openads::sql_backend::OdbcUri ouri; + if (openads::sql_backend::parse_odbc_uri(path, ouri)) { + auto opened = openads::sql_backend::OdbcConnection::open(ouri); + if (!opened) return fail(opened.error()); + auto holder = + std::make_unique( + std::move(opened).value()); + openads::sql_backend::OdbcConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::OdbcConnection, raw); + odbc_conns_map().emplace(h, std::move(holder)); + *phConnect = h; + return ok(); + } + } +#else + { + static constexpr const char* kOdbcPrefixes[] = { + "odbc://", "odbc:", + }; + for (const char* prefix : kOdbcPrefixes) { + const auto plen = std::char_traits::length(prefix); + if (path.size() >= plen && path.compare(0, plen, prefix) == 0) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "odbc URI requires OPENADS_WITH_ODBC=ON"); + } + } + } +#endif auto opened = Connection::open(path); if (!opened) return fail(opened.error()); auto holder = std::make_unique(std::move(opened).value()); @@ -847,6 +959,21 @@ UNSIGNED32 AdsDisconnect(ADSHANDLE hConnect) { { auto& s_local = state(); std::lock_guard lk_local(s_local.mu); +#if defined(OPENADS_WITH_ODBC) + if (auto* sc = s_local.registry.lookup< + openads::sql_backend::OdbcConnection>( + hConnect, HandleKind::OdbcConnection)) { + for (auto& kv : odbc_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + odbc_conns_map().erase(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif if (auto* rc = s_local.registry.lookup( hConnect, HandleKind::RemoteConnection)) { // Null out rt->conn on any open SQL cursors that reference this @@ -933,6 +1060,21 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, *phTable = gh; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::OdbcConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::OdbcTable, st.get()); + odbc_tables_map().emplace(gh, std::move(st)); + *phTable = gh; + return ok(); + } +#endif auto* conn = s.registry.lookup(hConnect, HandleKind::Connection); if (conn == nullptr) { ADSHANDLE def = get_or_create_default_connection(); @@ -1915,6 +2057,16 @@ UNSIGNED32 AdsCloseAllTables(void) { UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) { { +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + odbc_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); + } +#endif if (auto* rt = get_remote_table(hTable)) { // conn is nulled out by AdsDisconnect before the RemoteConnection // is freed; skip the wire close op if the connection is already gone. @@ -1957,6 +2109,16 @@ UNSIGNED32 AdsGotoTop(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto r = st->conn->goto_top(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_top(); @@ -1971,6 +2133,16 @@ UNSIGNED32 AdsGotoBottom(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto r = st->conn->goto_bottom(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_bottom(); @@ -2000,6 +2172,16 @@ UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto r = st->conn->skip(st, lRows); + if (!r) return fail(r.error()); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->skip(lRows); @@ -2016,6 +2198,18 @@ UNSIGNED32 AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { *pbAtEnd = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto r = st->conn->at_eof(st); + if (!r) return fail(r.error()); + *pbAtEnd = r.value() ? 1 : 0; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t || pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtEnd = t->eof() ? 1 : 0; @@ -2030,6 +2224,17 @@ UNSIGNED32 AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { *pbAtBegin = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto r = st->conn->at_bof(st); + if (!r) return fail(r.error()); + *pbAtBegin = r.value() ? 1 : 0; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtBegin = t->bof() ? 1 : 0; @@ -2048,6 +2253,19 @@ UNSIGNED32 AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) { *pusFields = static_cast(rt->fields.size()); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + if (!st->fields_cached) { + auto r = st->conn->describe_table(st); + if (!r) return fail(r.error()); + } + *pusFields = static_cast(st->fields.size()); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); if (auto* p = projection_for(hTable); p != nullptr) { @@ -2074,6 +2292,23 @@ UNSIGNED32 AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum, rt->fields[usFieldNum - 1].name); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + if (!st->fields_cached) { + auto r = st->conn->describe_table(st); + if (!r) return fail(r.error()); + } + if (usFieldNum == 0 || usFieldNum > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, + st->fields[usFieldNum - 1].name); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto* p = projection_for(hTable); @@ -2151,6 +2386,16 @@ UNSIGNED32 AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField, *pusType = rt->fields[i].type; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + auto i = odbc_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = st->fields[i].type; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2172,6 +2417,16 @@ UNSIGNED32 AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField, *pulLen = rt->fields[i].length; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + auto i = odbc_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulLen = st->fields[i].length; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2214,6 +2469,16 @@ UNSIGNED32 AdsGetFieldDecimals(ADSHANDLE hTable, UNSIGNED8* pucField, *pusDec = rt->fields[i].decimals; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + auto i = odbc_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusDec = st->fields[i].decimals; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2371,6 +2636,15 @@ UNSIGNED32 AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, *pulRecordNum = r.value(); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRecordNum = static_cast(st->current_recno); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pulRecordNum = t->recno(); @@ -2397,6 +2671,24 @@ UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, *pulRecordCount = rt->cached_rec_count; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + if (st->rec_count_cached) { + *pulRecordCount = st->cached_rec_count; + return ok(); + } + auto r = st->conn->record_count(st); + if (!r) return fail(r.error()); + st->cached_rec_count = r.value(); + st->rec_count_cached = true; + *pulRecordCount = st->cached_rec_count; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t || pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); // M10.31 / M10.32 — when SQL has materialised a traversal sequence @@ -2467,6 +2759,27 @@ UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, openads::abi::copy_to_caller(pucBuf, pulLen, val); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto fname = openads::abi::to_internal(pucField, 0); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, fname, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + auto fi = odbc_field_index(st, pucField); + if (fi != std::numeric_limits::max() && + st->fields[fi].type == ADS_STRING) { + val = pad_char_field(std::move(val), st->fields[fi].length); + } + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -3237,6 +3550,12 @@ UNSIGNED32 AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { *pbDeleted = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbDeleted = t->is_deleted() ? 1 : 0; @@ -3923,6 +4242,38 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, if (ahIndex == nullptr) { return fail(openads::AE_INTERNAL_ERROR, "null out"); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (pu16ArrayLen != nullptr && *pu16ArrayLen < 1) { + return fail(openads::AE_INTERNAL_ERROR, "index array too small"); + } + std::string tag = openads::abi::to_internal(pucName, 0); + if (tag.empty()) { + return fail(openads::AE_INTERNAL_ERROR, "empty index tag"); + } + const auto dot = tag.find_last_of("./\\"); + if (dot != std::string::npos) { + tag = tag.substr(dot + 1); + } + const auto dot2 = tag.find('.'); + if (dot2 != std::string::npos) { + tag = tag.substr(0, dot2); + } + auto si = std::make_unique(); + si->parent = st; + si->column = tag; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object( + HandleKind::OdbcIndex, si.get()); + ahIndex[0] = gh; + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + odbc_indexes_map().emplace(gh, std::move(si)); + return ok(); + } +#endif if (auto* rt = get_remote_table(hTable)) { std::string path = openads::abi::to_internal(pucName, 0); auto r = rt->conn->open_index(rt->id, path); @@ -4078,6 +4429,16 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, } UNSIGNED32 AdsCloseIndex(ADSHANDLE hIndex) { +#if defined(OPENADS_WITH_ODBC) + if (auto* si = get_odbc_index(hIndex)) { + (void)si; + auto& s = state(); + std::lock_guard lk(s.mu); + odbc_indexes_map().erase(hIndex); + s.registry.release(hIndex); + return ok(); + } +#endif if (auto* ri = get_remote_index(hIndex)) { auto r = ri->conn->close_index(ri->id); if (!r) return fail(r.error()); @@ -4167,6 +4528,31 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, remote_indexes.emplace(gh, std::move(ri)); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + auto expr = openads::abi::to_internal(pucExpr, 0); + auto parsed = openads::sql_backend::parse_index_expr(expr); + if (!parsed) return fail(parsed.error()); + const auto& px = parsed.value(); + auto si = std::make_unique(); + si->parent = st; + si->column = px.column; + si->expr_kind = px.kind; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object( + HandleKind::OdbcIndex, si.get()); + *phIndex = gh; + odbc_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (void)pucIndexName; + (void)pucCondition; + (void)pucKeyFilter; + (void)ulOptions; + (void)usPageSize; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) { return fail(openads::AE_INTERNAL_ERROR, "unknown table"); @@ -6321,6 +6707,12 @@ UNSIGNED32 AdsSetIndexDirection(ADSHANDLE hIndex, UNSIGNED16 usDir) { // engine set inside seek_key. UNSIGNED32 AdsIsFound(ADSHANDLE hTable, UNSIGNED16* pbFound) { if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); + } +#endif if (auto* rt = get_remote_table(hTable)) { auto r = rt->conn->is_found(rt->id); if (!r) return fail(r.error()); @@ -6339,6 +6731,25 @@ UNSIGNED32 AdsSeek(ADSHANDLE hIndex, UNSIGNED16 u16KeyType, UNSIGNED16 u16SeekType, UNSIGNED16* pbFound) { +#if defined(OPENADS_WITH_ODBC) + if (auto* si = get_odbc_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "odbc index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + const bool soft = (u16SeekType & 1u) != 0; + si->parent->row_valid = false; + auto r = si->parent->conn->seek_index( + si->parent, si->column, si->expr_kind, key, soft, + /*last=*/false); + if (!r) return fail(r.error()); + const bool found = r.value(); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif if (auto* ri = get_remote_index(hIndex)) { std::string key(reinterpret_cast(pucKey), u16KeyLen); @@ -6438,6 +6849,24 @@ UNSIGNED32 AdsSeekLast(ADSHANDLE hIndex, UNSIGNED16 u16KeyLen, UNSIGNED16 u16KeyType, UNSIGNED16* pbFound) { +#if defined(OPENADS_WITH_ODBC) + if (auto* si = get_odbc_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "odbc index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + si->parent->row_valid = false; + auto r = si->parent->conn->seek_index( + si->parent, si->column, si->expr_kind, key, + /*soft=*/false, /*last=*/true); + if (!r) return fail(r.error()); + const bool found = r.value(); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif if (auto* ri = get_remote_index(hIndex)) { std::string key(reinterpret_cast(pucKey), u16KeyLen); @@ -13888,6 +14317,31 @@ UNSIGNED32 AdsGetRelKeyPos(ADSHANDLE h, double* p) { *p = static_cast(rn - 1) / static_cast(rc - 1); return ok(); } +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(h)) { + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + std::uint32_t rc = 0; + if (st->rec_count_cached) { + rc = st->cached_rec_count; + } else { + auto rcr = st->conn->record_count(st); + if (!rcr) return fail(rcr.error()); + rc = rcr.value(); + st->cached_rec_count = rc; + st->rec_count_cached = true; + } + std::uint32_t rn = 0; + if (st->row_valid && st->positioned) { + rn = st->current_recno; + } + if (rc <= 1 || rn == 0) { *p = 0.0; return ok(); } + if (rn > rc) rn = rc; + *p = static_cast(rn - 1) / static_cast(rc - 1); + return ok(); + } +#endif Table* t = get_table(h); if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); std::uint32_t rc = t->record_count(); diff --git a/src/session/handle_registry.h b/src/session/handle_registry.h index 1d585538..2e74d446 100644 --- a/src/session/handle_registry.h +++ b/src/session/handle_registry.h @@ -20,7 +20,11 @@ enum class HandleKind { // M12.16 — remote (TCP) index handle. Wraps a server-side // index id; ABI calls (AdsSeek, AdsCloseIndex, …) route the // hIndex through the wire instead of a local IIndex. - RemoteIndex = 8 + RemoteIndex = 8, + // Optional SQL backend (odbc:// …). + OdbcConnection = 9, + OdbcTable = 10, + OdbcIndex = 11 }; using Handle = std::uint64_t; diff --git a/src/sql_backend/odbc_backend.cpp b/src/sql_backend/odbc_backend.cpp new file mode 100644 index 00000000..752d1435 --- /dev/null +++ b/src/sql_backend/odbc_backend.cpp @@ -0,0 +1,111 @@ +#include "sql_backend/odbc_backend.h" + +#include "openads/ace.h" + +#include + +namespace openads::sql_backend { + +namespace { + +// ODBC SQL type codes (sql.h / sqlext.h). Kept as local constants so +// this translation unit does not depend on the ODBC headers. +constexpr int kSqlChar = 1; +constexpr int kSqlNumeric = 2; +constexpr int kSqlDecimal = 3; +constexpr int kSqlInteger = 4; +constexpr int kSqlSmallint = 5; +constexpr int kSqlFloat = 6; +constexpr int kSqlReal = 7; +constexpr int kSqlDouble = 8; +constexpr int kSqlVarchar = 12; +constexpr int kSqlLongVarchar = -1; +constexpr int kSqlBinary = -2; +constexpr int kSqlVarbinary = -3; +constexpr int kSqlLongVarbinary = -4; +constexpr int kSqlBigint = -5; +constexpr int kSqlTinyint = -6; +constexpr int kSqlBit = -7; + +} // namespace + +OdbcTable::FieldDesc map_odbc_column(const std::string& name, + int sql_type, + bool nullable, + int column_size, + int decimal_digits) { + OdbcTable::FieldDesc fd; + fd.name = name; + fd.nullable = nullable; + + switch (sql_type) { + case kSqlInteger: + case kSqlSmallint: + case kSqlTinyint: + case kSqlBigint: + fd.type = ADS_INTEGER; + fd.length = 4; + fd.decimals = 0; + break; + case kSqlNumeric: + case kSqlDecimal: + case kSqlFloat: + case kSqlReal: + case kSqlDouble: + fd.type = ADS_DOUBLE; + fd.length = 8; + fd.decimals = decimal_digits > 0 + ? static_cast(decimal_digits) + : 6; + break; + case kSqlBit: + fd.type = ADS_LOGICAL; + fd.length = 1; + fd.decimals = 0; + break; + case kSqlBinary: + case kSqlVarbinary: + case kSqlLongVarbinary: + fd.type = ADS_BINARY; + fd.length = 10; + fd.decimals = 0; + break; + case kSqlChar: + case kSqlVarchar: + case kSqlLongVarchar: + default: + fd.type = ADS_STRING; + fd.length = column_size > 0 + ? static_cast(column_size) + : 64; + fd.decimals = 0; + break; + } + return fd; +} + +util::Error odbc_error(const char* context, const std::string& detail) { + std::string msg = context ? context : ""; + if (!detail.empty()) { + msg += ": "; + msg += detail; + } + return util::Error{5001, 0, msg, ""}; +} + +std::size_t field_index_ci(const OdbcTable& tbl, const std::string& name) { + std::string want = name; + for (auto& c : want) { + c = static_cast(std::toupper(static_cast(c))); + } + for (std::size_t i = 0; i < tbl.fields.size(); ++i) { + std::string have = tbl.fields[i].name; + for (auto& c : have) { + c = static_cast(std::toupper(static_cast(c))); + } + if (have == want) return i; + } + return static_cast(-1); +} + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_backend.h b/src/sql_backend/odbc_backend.h new file mode 100644 index 00000000..58285f30 --- /dev/null +++ b/src/sql_backend/odbc_backend.h @@ -0,0 +1,23 @@ +#pragma once + +#include "sql_backend/odbc_table.h" +#include "util/result.h" + +#include +#include + +namespace openads::sql_backend { + +// Map an ODBC SQL type code (SQLColumns.DATA_TYPE) + size/scale to the +// ADS field descriptor surfaced through the ABI. +OdbcTable::FieldDesc map_odbc_column(const std::string& name, + int sql_type, + bool nullable, + int column_size, + int decimal_digits); + +util::Error odbc_error(const char* context, const std::string& detail); + +std::size_t field_index_ci(const OdbcTable& tbl, const std::string& name); + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_connection.cpp b/src/sql_backend/odbc_connection.cpp new file mode 100644 index 00000000..2018a438 --- /dev/null +++ b/src/sql_backend/odbc_connection.cpp @@ -0,0 +1,701 @@ +#include "sql_backend/odbc_connection.h" + +#include "sql_backend/odbc_backend.h" +#include "sql_backend/sql_common.h" + +#include "openads/ace.h" + +#if !defined(OPENADS_WITH_ODBC) + +// This translation unit is only compiled when the ODBC backend is +// enabled (see src/CMakeLists.txt, target_sources under OPENADS_WITH_ODBC). +// Guard defensively so a stray build without the flag is an empty unit +// rather than a pile of undefined-symbol errors. +namespace openads::sql_backend {} + +#else + +#if defined(_WIN32) +# include +#endif +#include +#include + +#include +#include + +namespace openads::sql_backend { + +namespace { + +inline SQLCHAR* sqlstr(const std::string& s) { + return const_cast( + reinterpret_cast(s.c_str())); +} + +std::string odbc_diag(SQLSMALLINT handle_type, SQLHANDLE handle) { + std::string out; + SQLCHAR state[6]; + SQLINTEGER native = 0; + SQLCHAR msg[1024]; + SQLSMALLINT msg_len = 0; + for (SQLSMALLINT i = 1; i <= 8; ++i) { + SQLRETURN r = SQLGetDiagRec(handle_type, handle, i, state, &native, + msg, static_cast(sizeof(msg)), + &msg_len); + if (r == SQL_NO_DATA || !SQL_SUCCEEDED(r)) break; + if (!out.empty()) out += "; "; + out += reinterpret_cast(state); + out += ": "; + out += reinterpret_cast(msg); + } + return out; +} + +// Read every row of an already-executed statement, each cell coerced to +// a UTF-8/ANSI string via SQL_C_CHAR (long values stitched across +// SQLGetData calls). NULL cells are flagged. +util::Result read_all_rows( + SQLHSTMT st, + std::vector>& rows, + std::vector>& nulls) { + SQLSMALLINT cols = 0; + if (!SQL_SUCCEEDED(SQLNumResultCols(st, &cols))) { + return odbc_error("odbc num cols", odbc_diag(SQL_HANDLE_STMT, st)); + } + while (true) { + SQLRETURN r = SQLFetch(st); + if (r == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(r)) { + return odbc_error("odbc fetch", odbc_diag(SQL_HANDLE_STMT, st)); + } + std::vector row; + std::vector rn; + row.reserve(cols); + rn.reserve(cols); + for (SQLSMALLINT c = 1; c <= cols; ++c) { + std::string val; + bool is_null = false; + char buf[4096]; + SQLLEN ind = 0; + while (true) { + SQLRETURN gr = SQLGetData(st, static_cast(c), + SQL_C_CHAR, buf, + static_cast(sizeof(buf)), + &ind); + if (gr == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(gr)) { + return odbc_error("odbc getdata", + odbc_diag(SQL_HANDLE_STMT, st)); + } + if (ind == SQL_NULL_DATA) { + is_null = true; + break; + } + std::size_t chunk; + if (ind == SQL_NO_TOTAL || + ind >= static_cast(sizeof(buf))) { + chunk = sizeof(buf) - 1; // truncated; NUL eats one byte + } else { + chunk = static_cast(ind); + } + val.append(buf, chunk); + if (gr == SQL_SUCCESS) break; // all data consumed + // SQL_SUCCESS_WITH_INFO: more remains, keep reading + } + row.push_back(std::move(val)); + rn.push_back(is_null); + } + rows.push_back(std::move(row)); + nulls.push_back(std::move(rn)); + } + return util::Result{}; +} + +util::Result run_query( + SQLHDBC dbc, const std::string& sql, + std::vector>& rows, + std::vector>& nulls, + SQLULEN max_rows = 0) { + SQLHSTMT st = SQL_NULL_HSTMT; + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &st))) { + return odbc_error("odbc alloc stmt", odbc_diag(SQL_HANDLE_DBC, dbc)); + } + if (max_rows > 0) { + SQLSetStmtAttr(st, SQL_ATTR_MAX_ROWS, + reinterpret_cast(max_rows), 0); + } + SQLRETURN r = SQLExecDirect(st, sqlstr(sql), SQL_NTS); + if (!SQL_SUCCEEDED(r) && r != SQL_NO_DATA) { + auto e = odbc_error("odbc exec", odbc_diag(SQL_HANDLE_STMT, st)); + SQLFreeHandle(SQL_HANDLE_STMT, st); + return e; + } + auto rr = read_all_rows(st, rows, nulls); + SQLFreeHandle(SQL_HANDLE_STMT, st); + return rr; +} + +std::string quote_ident(const std::string& q, const std::string& name) { + if (q.empty()) return name; + return q + name + q; +} + +std::string escape_literal(const std::string& value) { + std::string out = "'"; + for (char c : value) { + if (c == '\'') out += "''"; + else out += c; + } + out += '\''; + return out; +} + +// Injection-safe numeric-literal check: only [0-9 . + - e E], at least +// one digit. The xBase key/PK values arrive as strings; numeric columns +// must NOT be quoted (Jet/Access and others reject 'text' = int_column), +// so a validated number is emitted bare. +bool is_numeric_literal(const std::string& s) { + if (s.empty()) return false; + bool any_digit = false; + for (char c : s) { + if (c >= '0' && c <= '9') { any_digit = true; continue; } + if (c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E') continue; + return false; + } + return any_digit; +} + +// Emit a SQL literal for `value` against `column`: bare for numeric +// columns (when the value is a clean number), single-quoted otherwise. +std::string format_literal(const OdbcTable& tbl, const std::string& column, + const std::string& value) { + const std::size_t idx = field_index_ci(tbl, column); + const bool numeric = idx != static_cast(-1) && + (tbl.fields[idx].type == ADS_INTEGER || + tbl.fields[idx].type == ADS_DOUBLE); + if (numeric && is_numeric_literal(value)) return value; + return escape_literal(value); +} + +std::string index_column_sql(const std::string& q, const std::string& column, + IndexExprKind kind) { + const std::string qcol = quote_ident(q, column); + return kind == IndexExprKind::UpperColumn ? "UPPER(" + qcol + ")" : qcol; +} + +std::string pk_select_list(const std::string& q, const OdbcTable& tbl) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(q, tbl.pk_columns[i]); + } + return out; +} + +std::string pk_where_clause(const std::string& q, const OdbcTable& tbl, + const OdbcTable::PkRow& pk) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += " AND "; + out += quote_ident(q, tbl.pk_columns[i]) + " = " + + format_literal(tbl, tbl.pk_columns[i], pk.values[i]); + } + return out; +} + +std::vector order_keyed(std::vector> k) { + std::stable_sort(k.begin(), k.end(), + [](const auto& a, const auto& b) { + return a.first < b.first; + }); + std::vector out; + out.reserve(k.size()); + for (auto& kv : k) out.push_back(kv.second); + return out; +} + +// Try SQLPrimaryKeys, falling back to the first UNIQUE index reported by +// SQLStatistics. Many drivers (the Access/Jet ODBC driver among them) do +// not implement SQLPrimaryKeys but do report a unique index, which is an +// equally valid stable row key for snapshot navigation. +util::Result> +discover_pk(SQLHDBC dbc, const std::string& name) { + // 1) SQLPrimaryKeys — columns (1-based): 4 COLUMN_NAME, 5 KEY_SEQ. + { + SQLHSTMT st = SQL_NULL_HSTMT; + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &st))) { + return odbc_error("odbc alloc stmt", odbc_diag(SQL_HANDLE_DBC, dbc)); + } + SQLRETURN r = SQLPrimaryKeys(st, nullptr, 0, nullptr, 0, + sqlstr(name), SQL_NTS); + if (SQL_SUCCEEDED(r)) { + std::vector> rows; + std::vector> nulls; + auto rr = read_all_rows(st, rows, nulls); + SQLFreeHandle(SQL_HANDLE_STMT, st); + if (!rr) return rr.error(); + std::vector> keyed; + for (auto& row : rows) { + std::string col = row.size() >= 4 ? row[3] : std::string(); + int seq = row.size() >= 5 ? std::atoi(row[4].c_str()) : 0; + if (!col.empty()) keyed.emplace_back(seq, col); + } + if (!keyed.empty()) return order_keyed(std::move(keyed)); + } else { + // Unsupported (IM001) or failed — fall through to statistics. + SQLFreeHandle(SQL_HANDLE_STMT, st); + } + } + + // 2) SQLStatistics unique index — columns (1-based): 4 NON_UNIQUE + // (0 = unique), 6 INDEX_NAME, 8 ORDINAL_POSITION, 9 COLUMN_NAME. + { + SQLHSTMT st = SQL_NULL_HSTMT; + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &st))) { + return odbc_error("odbc alloc stmt", odbc_diag(SQL_HANDLE_DBC, dbc)); + } + SQLRETURN r = SQLStatistics(st, nullptr, 0, nullptr, 0, + sqlstr(name), SQL_NTS, + SQL_INDEX_UNIQUE, SQL_QUICK); + if (SQL_SUCCEEDED(r)) { + std::vector> rows; + std::vector> nulls; + auto rr = read_all_rows(st, rows, nulls); + SQLFreeHandle(SQL_HANDLE_STMT, st); + if (!rr) return rr.error(); + std::string chosen; + std::vector> keyed; + for (auto& row : rows) { + const std::string non_unique = row.size() >= 4 ? row[3] : ""; + const std::string idx_name = row.size() >= 6 ? row[5] : ""; + const std::string ord_s = row.size() >= 8 ? row[7] : ""; + const std::string col_name = row.size() >= 9 ? row[8] : ""; + if (col_name.empty()) continue; // table-statistic row + if (non_unique != "0") continue; // only unique indexes + if (chosen.empty()) chosen = idx_name; + if (idx_name != chosen) continue; + keyed.emplace_back(std::atoi(ord_s.c_str()), col_name); + } + if (!keyed.empty()) return order_keyed(std::move(keyed)); + } else { + SQLFreeHandle(SQL_HANDLE_STMT, st); + } + } + + return util::Error{5001, 0, + "table has no primary key or unique index (ODBC backend v1 " + "navigates by a stable key)", name}; +} + +util::Result describe_columns(SQLHDBC dbc, OdbcTable* tbl) { + if (!is_safe_identifier(tbl->name)) { + return util::Error{5001, 0, "invalid table name", tbl->name}; + } + SQLHSTMT st = SQL_NULL_HSTMT; + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, dbc, &st))) { + return odbc_error("odbc alloc stmt", odbc_diag(SQL_HANDLE_DBC, dbc)); + } + SQLRETURN r = SQLColumns(st, nullptr, 0, nullptr, 0, + sqlstr(tbl->name), SQL_NTS, nullptr, 0); + if (!SQL_SUCCEEDED(r)) { + auto e = odbc_error("SQLColumns", odbc_diag(SQL_HANDLE_STMT, st)); + SQLFreeHandle(SQL_HANDLE_STMT, st); + return e; + } + std::vector> rows; + std::vector> nulls; + auto rr = read_all_rows(st, rows, nulls); + SQLFreeHandle(SQL_HANDLE_STMT, st); + if (!rr) return rr.error(); + if (rows.empty()) { + return util::Error{5001, 0, "table not found or has no columns", + tbl->name}; + } + // SQLColumns columns (1-based): 4 COLUMN_NAME, 5 DATA_TYPE, + // 7 COLUMN_SIZE, 9 DECIMAL_DIGITS, 11 NULLABLE. Rows arrive in + // ORDINAL_POSITION order per the ODBC spec. + std::vector out; + out.reserve(rows.size()); + for (auto& row : rows) { + auto cell = [&](std::size_t one_based) -> std::string { + std::size_t i = one_based - 1; + return i < row.size() ? row[i] : std::string(); + }; + const std::string cname = cell(4); + const int dtype = std::atoi(cell(5).c_str()); + const int csize = std::atoi(cell(7).c_str()); + const int ddig = std::atoi(cell(9).c_str()); + const int ncode = std::atoi(cell(11).c_str()); // 0=NO_NULLS,1=NULLABLE + out.push_back(map_odbc_column(cname, dtype, ncode != 0, csize, ddig)); + } + tbl->fields = std::move(out); + tbl->fields_cached = true; + return util::Result{}; +} + +util::Result load_pk_snapshot(SQLHDBC dbc, const std::string& q, + OdbcTable* tbl) { + const std::string list = pk_select_list(q, *tbl); + const std::string sql = + "SELECT " + list + " FROM " + quote_ident(q, tbl->name) + + " ORDER BY " + list; + std::vector> rows; + std::vector> nulls; + auto r = run_query(dbc, sql, rows, nulls); + if (!r) return r.error(); + tbl->pk_snapshot.clear(); + tbl->pk_snapshot.reserve(rows.size()); + for (auto& row : rows) { + OdbcTable::PkRow pk; + pk.values = std::move(row); + tbl->pk_snapshot.push_back(std::move(pk)); + } + return util::Result{}; +} + +util::Result load_current_row(SQLHDBC dbc, const std::string& q, + OdbcTable* tbl, std::size_t idx) { + if (idx >= tbl->pk_snapshot.size()) { + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; + } + const std::string sql = + "SELECT * FROM " + quote_ident(q, tbl->name) + " WHERE " + + pk_where_clause(q, *tbl, tbl->pk_snapshot[idx]); + std::vector> rows; + std::vector> nulls; + auto r = run_query(dbc, sql, rows, nulls, /*max_rows=*/1); + if (!r) return r.error(); + if (rows.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; + } + tbl->current_row = std::move(rows[0]); + tbl->current_nulls = std::move(nulls[0]); + tbl->pos = idx; + tbl->current_recno = static_cast(idx + 1); + tbl->positioned = true; + tbl->row_valid = true; + return util::Result{}; +} + +} // namespace + +struct OdbcConnection::Impl { + SQLHENV env = SQL_NULL_HENV; + SQLHDBC dbc = SQL_NULL_HDBC; + std::string quote; +}; + +OdbcConnection::OdbcConnection() = default; +OdbcConnection::~OdbcConnection() { disconnect(); } + +OdbcConnection::OdbcConnection(OdbcConnection&& other) noexcept + : impl_(std::move(other.impl_)) {} + +OdbcConnection& OdbcConnection::operator=(OdbcConnection&& other) noexcept { + if (this != &other) { + disconnect(); + impl_ = std::move(other.impl_); + } + return *this; +} + +util::Result OdbcConnection::open(const OdbcUri& uri) { + OdbcConnection conn; + conn.impl_ = std::make_unique(); + + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, + &conn.impl_->env))) { + return util::Error{5001, 0, "odbc: SQLAllocHandle(ENV) failed", ""}; + } + SQLSetEnvAttr(conn.impl_->env, SQL_ATTR_ODBC_VERSION, + reinterpret_cast(SQL_OV_ODBC3), 0); + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_DBC, conn.impl_->env, + &conn.impl_->dbc))) { + return odbc_error("odbc: SQLAllocHandle(DBC)", + odbc_diag(SQL_HANDLE_ENV, conn.impl_->env)); + } + + SQLCHAR outbuf[2048]; + SQLSMALLINT outlen = 0; + SQLRETURN r = SQLDriverConnect( + conn.impl_->dbc, nullptr, sqlstr(uri.connstr), SQL_NTS, + outbuf, static_cast(sizeof(outbuf)), &outlen, + SQL_DRIVER_NOPROMPT); + if (!SQL_SUCCEEDED(r)) { + return odbc_error("odbc connect", + odbc_diag(SQL_HANDLE_DBC, conn.impl_->dbc)); + } + + SQLCHAR q[16] = {0}; + SQLSMALLINT qlen = 0; + if (SQL_SUCCEEDED(SQLGetInfo(conn.impl_->dbc, SQL_IDENTIFIER_QUOTE_CHAR, + q, static_cast(sizeof(q)), + &qlen))) { + std::string qs(reinterpret_cast(q)); + if (!qs.empty() && qs != " ") conn.impl_->quote = qs; + } + return std::move(conn); +} + +void OdbcConnection::disconnect() noexcept { + if (impl_) { + if (impl_->dbc != SQL_NULL_HDBC) { + SQLDisconnect(impl_->dbc); + SQLFreeHandle(SQL_HANDLE_DBC, impl_->dbc); + impl_->dbc = SQL_NULL_HDBC; + } + if (impl_->env != SQL_NULL_HENV) { + SQLFreeHandle(SQL_HANDLE_ENV, impl_->env); + impl_->env = SQL_NULL_HENV; + } + } + impl_.reset(); +} + +bool OdbcConnection::valid() const noexcept { + return impl_ && impl_->dbc != SQL_NULL_HDBC; +} + +util::Result> +OdbcConnection::open_table(const std::string& table_name) { + if (!valid()) { + return util::Error{5001, 0, "odbc connection not open", ""}; + } + if (!is_safe_identifier(table_name)) { + return util::Error{5001, 0, "invalid table name", table_name}; + } + auto tbl = std::make_unique(); + tbl->conn = this; + tbl->name = table_name; + + auto pk = discover_pk(impl_->dbc, table_name); + if (!pk) return pk.error(); + tbl->pk_columns = std::move(pk).value(); + + if (auto d = describe_columns(impl_->dbc, tbl.get()); !d) { + return d.error(); + } + if (auto s = load_pk_snapshot(impl_->dbc, impl_->quote, tbl.get()); !s) { + return s.error(); + } + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = 0; + tbl->current_recno = 0; + return tbl; +} + +util::Result OdbcConnection::goto_top(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc goto_top", ""}; + } + if (tbl->cached_rec_count == 0) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + return load_current_row(impl_->dbc, impl_->quote, tbl, 0); +} + +util::Result OdbcConnection::goto_bottom(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc goto_bottom", ""}; + } + if (tbl->cached_rec_count == 0) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + return load_current_row(impl_->dbc, impl_->quote, tbl, + tbl->cached_rec_count - 1); +} + +util::Result OdbcConnection::skip(OdbcTable* tbl, std::int32_t step) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc skip", ""}; + } + if (step == 0) return util::Result{}; + if (tbl->cached_rec_count == 0) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = 0; + return util::Result{}; + } + + std::int64_t next = 0; + if (!tbl->positioned) { + if (tbl->pos == 0) { + if (step > 0) next = step - 1; + else return util::Error{5026, 0, "bof", ""}; + } else { + if (step < 0) next = static_cast(tbl->pos) + step; + else return util::Result{}; + } + } else { + next = static_cast(tbl->pos) + step; + } + + if (next < 0) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = 0; + return util::Error{5026, 0, "bof", ""}; + } + if (static_cast(next) >= tbl->cached_rec_count) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = tbl->cached_rec_count; + return util::Result{}; + } + return load_current_row(impl_->dbc, impl_->quote, tbl, + static_cast(next)); +} + +util::Result OdbcConnection::at_eof(OdbcTable* tbl) const { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc at_eof", ""}; + } + if (tbl->cached_rec_count == 0) return true; + if (!tbl->positioned && tbl->pos >= tbl->cached_rec_count) return true; + return false; +} + +util::Result OdbcConnection::at_bof(OdbcTable* tbl) const { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc at_bof", ""}; + } + if (tbl->cached_rec_count == 0) return true; + return !tbl->positioned && tbl->pos == 0; +} + +util::Result OdbcConnection::record_count(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc record_count", ""}; + } + if (tbl->rec_count_cached) return tbl->cached_rec_count; + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + return tbl->cached_rec_count; +} + +util::Result> +OdbcConnection::describe_table(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc describe_table", ""}; + } + if (tbl->fields_cached) return tbl->fields; + if (auto d = describe_columns(impl_->dbc, tbl); !d) return d.error(); + return tbl->fields; +} + +util::Result OdbcConnection::read_field( + OdbcTable* tbl, const std::string& field_name, + std::string& buf, bool& is_null) const { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc read_field", ""}; + } + if (!tbl->fields_cached) { + return util::Error{5001, 0, "schema not cached", ""}; + } + if (!tbl->row_valid) { + return util::Error{5026, 0, "no current record", ""}; + } + const std::size_t idx = field_index_ci(*tbl, field_name); + if (idx == static_cast(-1)) { + return util::Error{5063, 0, "column not found", field_name}; + } + if (idx >= tbl->current_row.size()) { + return util::Error{5001, 0, "row cache mismatch", ""}; + } + is_null = tbl->current_nulls[idx]; + buf = tbl->current_row[idx]; + return util::Result{}; +} + +util::Result OdbcConnection::seek_index( + OdbcTable* tbl, const std::string& column, IndexExprKind kind, + const std::string& key, bool soft, bool last_key) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc seek", ""}; + } + if (!is_safe_identifier(column)) { + return util::Error{5001, 0, "invalid seek column", column}; + } + if (!tbl->fields_cached) { + if (auto d = describe_columns(impl_->dbc, tbl); !d) return d.error(); + } + if (field_index_ci(*tbl, column) == static_cast(-1)) { + return util::Error{5063, 0, "seek column not found", column}; + } + + const std::string& q = impl_->quote; + const std::string pkcols = pk_select_list(q, *tbl); + const std::string esc = (kind == IndexExprKind::UpperColumn) + ? escape_literal(key) + : format_literal(*tbl, column, key); + const std::string qexpr = index_column_sql(q, column, kind); + const std::string from = " FROM " + quote_ident(q, tbl->name); + + std::string sql; + if (last_key) { + sql = soft + ? "SELECT " + pkcols + from + " WHERE " + qexpr + " <= " + esc + + " ORDER BY " + qexpr + " DESC" + : "SELECT " + pkcols + from + " WHERE " + qexpr + " = " + esc + + " ORDER BY " + qexpr + " DESC"; + } else { + sql = soft + ? "SELECT " + pkcols + from + " WHERE " + qexpr + " >= " + esc + + " ORDER BY " + qexpr + " ASC" + : "SELECT " + pkcols + from + " WHERE " + qexpr + " = " + esc; + } + + std::vector> rows; + std::vector> nulls; + auto r = run_query(impl_->dbc, sql, rows, nulls, /*max_rows=*/1); + if (!r) return r.error(); + + bool found = false; + if (!rows.empty()) { + OdbcTable::PkRow pk; + pk.values = rows[0]; + std::size_t pos = static_cast(-1); + for (std::size_t i = 0; i < tbl->pk_snapshot.size(); ++i) { + if (tbl->pk_snapshot[i] == pk) { + pos = i; + break; + } + } + if (pos != static_cast(-1)) { + if (auto lr = load_current_row(impl_->dbc, q, tbl, pos); !lr) { + return lr.error(); + } + found = tbl->positioned && tbl->row_valid; + } else { + tbl->positioned = false; + tbl->row_valid = false; + found = false; + } + } else { + tbl->positioned = false; + tbl->row_valid = false; + found = false; + } + tbl->last_seek_found = found; + return found; +} + +} // namespace openads::sql_backend + +#endif // OPENADS_WITH_ODBC diff --git a/src/sql_backend/odbc_connection.h b/src/sql_backend/odbc_connection.h new file mode 100644 index 00000000..fc70a903 --- /dev/null +++ b/src/sql_backend/odbc_connection.h @@ -0,0 +1,67 @@ +#pragma once + +#include "sql_backend/odbc_table.h" +#include "sql_backend/odbc_uri.h" +#include "sql_backend/sql_common.h" +#include "util/result.h" + +#include +#include +#include +#include + +namespace openads::sql_backend { + +// Read-only v1 ODBC backend. Talks to any data source with an ODBC +// driver (SQL Server, Oracle, Firebird, PostgreSQL, MariaDB, DB2, +// Access, …) through the Win32 / unixODBC API. Write support +// (append / update / delete) is a later slice; until then a write on an +// ODBC-backed table is rejected at the ABI border as an unknown handle. +class OdbcConnection { +public: + OdbcConnection(); + ~OdbcConnection(); + + OdbcConnection(OdbcConnection&&) noexcept; + OdbcConnection& operator=(OdbcConnection&&) noexcept; + + OdbcConnection(const OdbcConnection&) = delete; + OdbcConnection& operator=(const OdbcConnection&) = delete; + + static util::Result open(const OdbcUri& uri); + + void disconnect() noexcept; + bool valid() const noexcept; + + util::Result> + open_table(const std::string& table_name); + + util::Result goto_top(OdbcTable* tbl); + util::Result goto_bottom(OdbcTable* tbl); + util::Result skip(OdbcTable* tbl, std::int32_t step); + + util::Result at_eof(OdbcTable* tbl) const; + util::Result at_bof(OdbcTable* tbl) const; + util::Result record_count(OdbcTable* tbl); + + util::Result> + describe_table(OdbcTable* tbl); + + util::Result read_field(OdbcTable* tbl, + const std::string& field_name, + std::string& buf, + bool& is_null) const; + + util::Result seek_index(OdbcTable* tbl, + const std::string& column, + IndexExprKind kind, + const std::string& key, + bool soft, + bool last_key); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_index.h b/src/sql_backend/odbc_index.h new file mode 100644 index 00000000..f78cb305 --- /dev/null +++ b/src/sql_backend/odbc_index.h @@ -0,0 +1,18 @@ +#pragma once + +#include "sql_backend/sql_common.h" + +#include + +namespace openads::sql_backend { + +struct OdbcTable; + +struct OdbcIndex { + OdbcTable* parent = nullptr; + std::string column; + IndexExprKind expr_kind = IndexExprKind::Column; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_table.h b/src/sql_backend/odbc_table.h new file mode 100644 index 00000000..45b2f156 --- /dev/null +++ b/src/sql_backend/odbc_table.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +namespace openads::sql_backend { + +class OdbcConnection; + +// Read-only v1 table state behind the ACE ABI. Navigation uses a +// primary-key snapshot loaded once at open_table: the ordered list of +// PK tuples is materialised in memory, so GO TOP / SKIP / GO BOTTOM are +// index arithmetic and the row payload is loaded lazily by PK. This is +// the most portable form of "PK snapshot" navigation — it needs only +// COUNT / SELECT / WHERE / ORDER BY, which every ODBC driver supports +// (no LIMIT / OFFSET / TOP, no scrollable-cursor dependency). +struct OdbcTable { + OdbcConnection* conn = nullptr; + std::string name; + + struct FieldDesc { + std::string name; + std::uint16_t type = 0; + std::uint32_t length = 0; + std::uint16_t decimals = 0; + bool nullable = true; + }; + + std::vector fields; + bool fields_cached = false; + + std::vector current_row; + std::vector current_nulls; + bool row_valid = false; + + std::uint32_t current_recno = 0; + bool current_deleted = false; + std::uint32_t cached_rec_count = 0; + bool rec_count_cached = false; + + std::vector pk_columns; + struct PkRow { + std::vector values; + bool operator==(const PkRow& o) const { return values == o.values; } + }; + // PK snapshot: every row's PK tuple, ordered by PK ascending. + std::vector pk_snapshot; + + std::size_t pos = 0; + bool positioned = false; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_uri.cpp b/src/sql_backend/odbc_uri.cpp new file mode 100644 index 00000000..698fc5f5 --- /dev/null +++ b/src/sql_backend/odbc_uri.cpp @@ -0,0 +1,23 @@ +#include "sql_backend/odbc_uri.h" + +namespace openads::sql_backend { + +bool parse_odbc_uri(const std::string& uri, OdbcUri& out) { + static constexpr const char* kSlash = "odbc://"; + static constexpr const char* kBare = "odbc:"; + const auto slen = std::char_traits::length(kSlash); + const auto blen = std::char_traits::length(kBare); + + if (uri.size() >= slen && uri.compare(0, slen, kSlash) == 0) { + out = OdbcUri{}; + out.connstr = uri.substr(slen); + } else if (uri.size() >= blen && uri.compare(0, blen, kBare) == 0) { + out = OdbcUri{}; + out.connstr = uri.substr(blen); + } else { + return false; + } + return !out.connstr.empty(); +} + +} // namespace openads::sql_backend diff --git a/src/sql_backend/odbc_uri.h b/src/sql_backend/odbc_uri.h new file mode 100644 index 00000000..97c5eddf --- /dev/null +++ b/src/sql_backend/odbc_uri.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +// An `odbc://` (or `odbc:`) connection URI. The remainder after the +// scheme is handed verbatim to SQLDriverConnect as the ODBC connection +// string, so any DSN-less driver string or `DSN=name;…` form works: +// odbc://Driver={Some Driver};Server=host;Database=db;UID=u;PWD=p; +// odbc:DSN=mydsn;UID=u;PWD=p; +struct OdbcUri { + std::string connstr; +}; + +bool parse_odbc_uri(const std::string& uri, OdbcUri& out); + +} // namespace openads::sql_backend diff --git a/src/sql_backend/sql_common.cpp b/src/sql_backend/sql_common.cpp new file mode 100644 index 00000000..b6accb99 --- /dev/null +++ b/src/sql_backend/sql_common.cpp @@ -0,0 +1,67 @@ +#include "sql_backend/sql_common.h" + +#include +#include + +namespace openads::sql_backend { + +namespace { + +std::string trim_copy(std::string s) { + auto not_space = [](unsigned char c) { return !std::isspace(c); }; + s.erase(s.begin(), + std::find_if(s.begin(), s.end(), not_space)); + s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), + s.end()); + return s; +} + +bool iequals(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +} // namespace + +util::Result parse_index_expr(const std::string& expr) { + const std::string trimmed = trim_copy(expr); + if (trimmed.empty()) { + return util::Error{5001, 0, "empty index expression", ""}; + } + + ParsedIndexExpr out; + if (trimmed.size() > 7 && + iequals(trimmed.substr(0, 6), "upper(") && + trimmed.back() == ')') { + out.kind = IndexExprKind::UpperColumn; + out.column = trim_copy(trimmed.substr(6, trimmed.size() - 7)); + } else { + out.kind = IndexExprKind::Column; + out.column = trimmed; + } + + if (!is_safe_identifier(out.column)) { + return util::Error{5001, 0, "unsupported index expression", expr}; + } + return out; +} + +bool is_safe_identifier(const std::string& name) { + if (name.empty()) return false; + for (char c : name) { + const bool ok = (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_'; + if (!ok) return false; + } + return true; +} + +} // namespace openads::sql_backend diff --git a/src/sql_backend/sql_common.h b/src/sql_backend/sql_common.h new file mode 100644 index 00000000..a8444d67 --- /dev/null +++ b/src/sql_backend/sql_common.h @@ -0,0 +1,24 @@ +#pragma once + +#include "util/result.h" + +#include + +namespace openads::sql_backend { + +bool is_safe_identifier(const std::string& name); + +enum class IndexExprKind { + Column, + UpperColumn, +}; + +struct ParsedIndexExpr { + IndexExprKind kind = IndexExprKind::Column; + std::string column; +}; + +// v1 Plus SQL index expressions: bare column or UPPER(column) only. +util::Result parse_index_expr(const std::string& expr); + +} // namespace openads::sql_backend diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21d33f6f..6d81b3ea 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -142,6 +142,14 @@ add_executable(openads_unit_tests unit/abi_sql_dd_sql_test.cpp ) +if(OPENADS_WITH_ODBC) + target_sources(openads_unit_tests PRIVATE + unit/odbc_uri_test.cpp + unit/abi_plus_odbc_read_test.cpp + unit/abi_plus_odbc_seek_test.cpp + ) +endif() + # M11.4 — side DLL with stored procedures used by abi_aep_test. add_library(openads_test_aep_proc SHARED fixtures/test_aep_proc.cpp diff --git a/tests/unit/abi_plus_odbc_read_test.cpp b/tests/unit/abi_plus_odbc_read_test.cpp new file mode 100644 index 00000000..91008942 --- /dev/null +++ b/tests/unit/abi_plus_odbc_read_test.cpp @@ -0,0 +1,114 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include + +#if defined(OPENADS_WITH_ODBC) + +namespace { + +// Live ODBC fixture: an ODBC connection string pointing at a data +// source that already holds a `clientes` table seeded with +// (1,'Ana',10.5), (2,'Bob',NULL), (3,'Cid',0.0) +// id INTEGER PRIMARY KEY, nome VARCHAR(64), saldo DOUBLE. +// The run script (tools/scripts/run_odbc_tests.*) creates and seeds an +// Access .accdb and exports this variable. When unset the live test is +// skipped (the backend itself is still exercised by the unit tests). +const char* test_odbc_connstr() { + const char* v = std::getenv("OPENADS_TEST_ODBC_CONNSTR"); + return (v != nullptr && v[0] != '\0') ? v : nullptr; +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[64]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[256] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +ADSHANDLE connect_odbc(const char* connstr) { + const std::string uri = std::string("odbc://") + connstr; + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + return hConn; +} + +} // namespace + +TEST_CASE("ABI: odbc read-only AdsOpenTable navigation") { + const char* connstr = test_odbc_connstr(); + if (connstr == nullptr) { + MESSAGE("OPENADS_TEST_ODBC_CONNSTR not set; skipping live ODBC test"); + return; + } + + ADSHANDLE hConn = connect_odbc(connstr); + + UNSIGNED8 tbl_name[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl_name, tbl_name, + ADS_DEFAULT, 0, 0, 0, ADS_READONLY, + &hTable) == 0); + + UNSIGNED16 nfields = 0; + REQUIRE(AdsGetNumFields(hTable, &nfields) == 0); + CHECK(nfields == 3); + + UNSIGNED32 count = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &count) == 0); + CHECK(count == 3); + + REQUIRE(AdsGotoTop(hTable) == 0); + + UNSIGNED16 bof = 1; + REQUIRE(AdsAtBOF(hTable, &bof) == 0); + CHECK(bof == 0); + + // nome is VARCHAR(64) -> ADS_STRING, padded to the declared width. + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Ana")); + + REQUIRE(AdsSkip(hTable, 1) == 0); // Bob + CHECK(field_str(hTable, "saldo").empty()); // saldo is NULL + + REQUIRE(AdsSkip(hTable, 1) == 0); // Cid + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Cid")); + + UNSIGNED32 recno = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); + CHECK(recno == 3); + + UNSIGNED16 eof = 0; + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 0); + + REQUIRE(AdsSkip(hTable, 1) == 0); // past the end + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 1); + + REQUIRE(AdsGotoBottom(hTable) == 0); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Cid")); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#else + +TEST_CASE("ABI: odbc backend disabled at compile time") { + UNSIGNED8 uri[] = "odbc://DSN=none"; + ADSHANDLE hConn = 0; + const UNSIGNED32 rc = AdsConnect60(uri, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn); + CHECK(rc == openads::AE_FUNCTION_NOT_AVAILABLE); +} + +#endif diff --git a/tests/unit/abi_plus_odbc_seek_test.cpp b/tests/unit/abi_plus_odbc_seek_test.cpp new file mode 100644 index 00000000..c7289bed --- /dev/null +++ b/tests/unit/abi_plus_odbc_seek_test.cpp @@ -0,0 +1,90 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include + +#if defined(OPENADS_WITH_ODBC) + +namespace { + +const char* test_odbc_connstr() { + const char* v = std::getenv("OPENADS_TEST_ODBC_CONNSTR"); + return (v != nullptr && v[0] != '\0') ? v : nullptr; +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[64]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[256] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +UNSIGNED16 seek(ADSHANDLE hIndex, const char* key, UNSIGNED16 seek_type) { + UNSIGNED16 found = 0; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(key)), + static_cast(std::strlen(key)), + /*keyType=*/0, seek_type, &found) == 0); + return found; +} + +} // namespace + +TEST_CASE("ABI: odbc seek by primary-key index") { + const char* connstr = test_odbc_connstr(); + if (connstr == nullptr) { + MESSAGE("OPENADS_TEST_ODBC_CONNSTR not set; skipping live ODBC test"); + return; + } + + const std::string uri = std::string("odbc://") + connstr; + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + + UNSIGNED8 tbl_name[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl_name, tbl_name, + ADS_DEFAULT, 0, 0, 0, ADS_READONLY, + &hTable) == 0); + + UNSIGNED8 file_name[16] = "idx"; + UNSIGNED8 idx_name[16] = "id"; + UNSIGNED8 idx_expr[16] = "id"; + ADSHANDLE hIndex = 0; + REQUIRE(AdsCreateIndex61(hTable, file_name, idx_name, idx_expr, + nullptr, nullptr, 0, 0, &hIndex) == 0); + + // Hard hit: id = 2 -> Bob, recno 2. + CHECK(seek(hIndex, "2", /*hard*/ 0) == 1); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Bob")); + UNSIGNED32 recno = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); + CHECK(recno == 2); + UNSIGNED16 found = 0; + REQUIRE(AdsIsFound(hTable, &found) == 0); + CHECK(found == 1); + + // Hard miss: id = 99 -> not found. + CHECK(seek(hIndex, "99", /*hard*/ 0) == 0); + REQUIRE(AdsIsFound(hTable, &found) == 0); + CHECK(found == 0); + + // Soft seek: first id >= 2 is Bob. + CHECK(seek(hIndex, "2", ADS_SOFTSEEK) == 1); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Bob")); + + REQUIRE(AdsCloseIndex(hIndex) == 0); + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif diff --git a/tests/unit/odbc_uri_test.cpp b/tests/unit/odbc_uri_test.cpp new file mode 100644 index 00000000..5013f92d --- /dev/null +++ b/tests/unit/odbc_uri_test.cpp @@ -0,0 +1,24 @@ +#include "doctest.h" + +#include "sql_backend/odbc_uri.h" + +using openads::sql_backend::OdbcUri; +using openads::sql_backend::parse_odbc_uri; + +TEST_CASE("odbc URI: scheme stripping passes the connection string verbatim") { + OdbcUri u; + REQUIRE(parse_odbc_uri("odbc://Driver={Some Driver};DBQ=C:/a.accdb;", u)); + CHECK(u.connstr == "Driver={Some Driver};DBQ=C:/a.accdb;"); + + OdbcUri u2; + REQUIRE(parse_odbc_uri("odbc:DSN=mydsn;UID=u;PWD=p", u2)); + CHECK(u2.connstr == "DSN=mydsn;UID=u;PWD=p"); +} + +TEST_CASE("odbc URI: rejects non-odbc schemes and empty connection strings") { + OdbcUri u; + CHECK_FALSE(parse_odbc_uri("mariadb://host/db", u)); + CHECK_FALSE(parse_odbc_uri("/local/data/dir", u)); + CHECK_FALSE(parse_odbc_uri("odbc://", u)); + CHECK_FALSE(parse_odbc_uri("odbc:", u)); +} diff --git a/tools/scripts/build_msvc_x64_odbc.bat b/tools/scripts/build_msvc_x64_odbc.bat new file mode 100644 index 00000000..39ec110a --- /dev/null +++ b/tools/scripts/build_msvc_x64_odbc.bat @@ -0,0 +1,13 @@ +@echo off +REM Build OpenADS x64 with the ODBC Plus backend (MSVC). +REM Prereq: Visual Studio x64 tools on PATH (Developer Command Prompt). +REM The ODBC driver-manager import library (odbc32) ships with the +REM Windows SDK, so there is no external dependency to configure. +cd /d "%~dp0..\.." +cmake -S . -B build\odbc-msvc -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ^ + -DOPENADS_WITH_ODBC=ON -DOPENADS_WITH_HTTP=OFF -DOPENADS_WITH_TLS=OFF ^ + -DOPENADS_WARNINGS_AS_ERRORS=OFF +if errorlevel 1 exit /b 1 +cmake --build build\odbc-msvc +exit /b %ERRORLEVEL% diff --git a/tools/scripts/run_odbc_tests.ps1 b/tools/scripts/run_odbc_tests.ps1 new file mode 100644 index 00000000..aaa4d732 --- /dev/null +++ b/tools/scripts/run_odbc_tests.ps1 @@ -0,0 +1,48 @@ +<# + Run the OpenADS ODBC ABI e2e tests against a throwaway fixture. + + Creates a temporary Microsoft Access .accdb (via the ACE OLE DB + provider that ships with the Access Database Engine / Office), seeds + the `clientes` table, exports OPENADS_TEST_ODBC_CONNSTR, and runs the + unit-test binary filtered to the ODBC cases. Any data source reachable + through an ODBC driver works the same way; Access is just a zero-server + fixture available out of the box on Windows. + + Usage: + pwsh tools/scripts/run_odbc_tests.ps1 [-BuildDir build\odbc-msvc] +#> +param( + [string]$BuildDir = "build\odbc-msvc" +) +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$exe = Join-Path $root (Join-Path $BuildDir "tests\openads_unit_tests.exe") +if (-not (Test-Path $exe)) { + Write-Error "test binary not found: $exe (build first)" + exit 1 +} + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ` + ("openads_odbc_" + [System.Guid]::NewGuid().ToString("N").Substring(0, 8)) +New-Item -ItemType Directory -Path $work | Out-Null +$accdb = Join-Path $work "fixture.accdb" + +$cat = New-Object -ComObject ADOX.Catalog +$cat.Create("Provider=Microsoft.ACE.OLEDB.16.0;Data Source=$accdb;") | Out-Null + +$connstr = "Driver={Microsoft Access Driver (*.mdb, *.accdb)};DBQ=$accdb;" +$c = New-Object System.Data.Odbc.OdbcConnection($connstr) +$c.Open() +foreach ($sql in @( + "CREATE TABLE clientes (id INTEGER CONSTRAINT pk PRIMARY KEY, nome VARCHAR(64), saldo DOUBLE)", + "INSERT INTO clientes (id,nome,saldo) VALUES (1,'Ana',10.5)", + "INSERT INTO clientes (id,nome,saldo) VALUES (2,'Bob',NULL)", + "INSERT INTO clientes (id,nome,saldo) VALUES (3,'Cid',0.0)")) { + $cmd = $c.CreateCommand(); $cmd.CommandText = $sql; [void]$cmd.ExecuteNonQuery() +} +$c.Close() + +$env:OPENADS_TEST_ODBC_CONNSTR = $connstr +& $exe --test-case=*odbc* +exit $LASTEXITCODE From b36de76e9904479933237c48fd585ba639a1e12d Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 19:29:48 -0300 Subject: [PATCH 2/7] fix(odbc): address review -- schema filtering, indicator guard, env attr - read_all_rows: ignore stray negative SQLGetData indicator values instead of casting them to a huge size_t (defensive crash guard for a misbehaving driver). - discover_pk / describe_columns: pin the SQLPrimaryKeys, SQLStatistics and SQLColumns result sets to the first schema seen (column 2, TABLE_SCHEM). With a null schema argument a driver that supports schemas can return rows for same-named tables across several schemas; mixing them would corrupt the key or the field list. - OdbcConnection::open: check the SQLSetEnvAttr(ODBC_VERSION) return so a failure to select ODBC 3 is surfaced rather than ignored. The full unit suite stays green (528/528) and the live Access e2e (4/4) is unaffected -- the Access driver reports a null schema, so the schema filter is a no-op there. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sql_backend/odbc_connection.cpp | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/sql_backend/odbc_connection.cpp b/src/sql_backend/odbc_connection.cpp index 2018a438..3c88496d 100644 --- a/src/sql_backend/odbc_connection.cpp +++ b/src/sql_backend/odbc_connection.cpp @@ -96,6 +96,8 @@ util::Result read_all_rows( if (ind == SQL_NO_TOTAL || ind >= static_cast(sizeof(buf))) { chunk = sizeof(buf) - 1; // truncated; NUL eats one byte + } else if (ind < 0) { + chunk = 0; // stray driver indicator; ignore } else { chunk = static_cast(ind); } @@ -235,10 +237,19 @@ discover_pk(SQLHDBC dbc, const std::string& name) { auto rr = read_all_rows(st, rows, nulls); SQLFreeHandle(SQL_HANDLE_STMT, st); if (!rr) return rr.error(); + // Pin to the first schema seen (col 2 = TABLE_SCHEM): with a + // null schema arg, drivers that support schemas can return PK + // columns for same-named tables across schemas; mixing them + // would corrupt the key. + std::string chosen_schema; + bool has_schema = false; std::vector> keyed; for (auto& row : rows) { - std::string col = row.size() >= 4 ? row[3] : std::string(); + std::string schem = row.size() >= 2 ? row[1] : std::string(); + std::string col = row.size() >= 4 ? row[3] : std::string(); int seq = row.size() >= 5 ? std::atoi(row[4].c_str()) : 0; + if (!has_schema) { chosen_schema = schem; has_schema = true; } + if (schem != chosen_schema) continue; if (!col.empty()) keyed.emplace_back(seq, col); } if (!keyed.empty()) return order_keyed(std::move(keyed)); @@ -265,14 +276,19 @@ discover_pk(SQLHDBC dbc, const std::string& name) { SQLFreeHandle(SQL_HANDLE_STMT, st); if (!rr) return rr.error(); std::string chosen; + std::string chosen_schema; + bool has_schema = false; std::vector> keyed; for (auto& row : rows) { + const std::string schem = row.size() >= 2 ? row[1] : ""; const std::string non_unique = row.size() >= 4 ? row[3] : ""; const std::string idx_name = row.size() >= 6 ? row[5] : ""; const std::string ord_s = row.size() >= 8 ? row[7] : ""; const std::string col_name = row.size() >= 9 ? row[8] : ""; if (col_name.empty()) continue; // table-statistic row if (non_unique != "0") continue; // only unique indexes + if (!has_schema) { chosen_schema = schem; has_schema = true; } + if (schem != chosen_schema) continue; if (chosen.empty()) chosen = idx_name; if (idx_name != chosen) continue; keyed.emplace_back(std::atoi(ord_s.c_str()), col_name); @@ -315,6 +331,8 @@ util::Result describe_columns(SQLHDBC dbc, OdbcTable* tbl) { // SQLColumns columns (1-based): 4 COLUMN_NAME, 5 DATA_TYPE, // 7 COLUMN_SIZE, 9 DECIMAL_DIGITS, 11 NULLABLE. Rows arrive in // ORDINAL_POSITION order per the ODBC spec. + std::string chosen_schema; + bool has_schema = false; std::vector out; out.reserve(rows.size()); for (auto& row : rows) { @@ -322,6 +340,12 @@ util::Result describe_columns(SQLHDBC dbc, OdbcTable* tbl) { std::size_t i = one_based - 1; return i < row.size() ? row[i] : std::string(); }; + // Pin to the first schema seen (col 2 = TABLE_SCHEM) so a null + // schema arg cannot mix columns of same-named tables in different + // schemas into one field list. + const std::string schem = cell(2); + if (!has_schema) { chosen_schema = schem; has_schema = true; } + if (schem != chosen_schema) continue; const std::string cname = cell(4); const int dtype = std::atoi(cell(5).c_str()); const int csize = std::atoi(cell(7).c_str()); @@ -412,8 +436,12 @@ util::Result OdbcConnection::open(const OdbcUri& uri) { &conn.impl_->env))) { return util::Error{5001, 0, "odbc: SQLAllocHandle(ENV) failed", ""}; } - SQLSetEnvAttr(conn.impl_->env, SQL_ATTR_ODBC_VERSION, - reinterpret_cast(SQL_OV_ODBC3), 0); + if (!SQL_SUCCEEDED(SQLSetEnvAttr(conn.impl_->env, SQL_ATTR_ODBC_VERSION, + reinterpret_cast(SQL_OV_ODBC3), + 0))) { + return odbc_error("odbc: SQLSetEnvAttr(ODBC_VERSION)", + odbc_diag(SQL_HANDLE_ENV, conn.impl_->env)); + } if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_DBC, conn.impl_->env, &conn.impl_->dbc))) { return odbc_error("odbc: SQLAllocHandle(DBC)", From 71dbf7dc18c85186b132e6494bdb894eae531428 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 21:03:43 -0300 Subject: [PATCH 3/7] fix(odbc): Firebird quoted-identifier SQL names + test script Use driver-reported TABLE_NAME for FROM clauses and canonical column names in seek SQL. Firebird stores unquoted identifiers uppercase; quoting the ABI lowercase name broke AdsOpenTable/AdsSeek. Add run_firebird_odbc_tests.ps1 for the portable devai_test.fdb fixture. Co-Authored-By: Grok Code --- src/sql_backend/odbc_connection.cpp | 23 +++++--- src/sql_backend/odbc_table.h | 3 +- tools/scripts/run_firebird_odbc_tests.ps1 | 67 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 tools/scripts/run_firebird_odbc_tests.ps1 diff --git a/src/sql_backend/odbc_connection.cpp b/src/sql_backend/odbc_connection.cpp index 3c88496d..e2c2fb10 100644 --- a/src/sql_backend/odbc_connection.cpp +++ b/src/sql_backend/odbc_connection.cpp @@ -344,7 +344,11 @@ util::Result describe_columns(SQLHDBC dbc, OdbcTable* tbl) { // schema arg cannot mix columns of same-named tables in different // schemas into one field list. const std::string schem = cell(2); - if (!has_schema) { chosen_schema = schem; has_schema = true; } + if (!has_schema) { + chosen_schema = schem; + tbl->sql_table = cell(3); + has_schema = true; + } if (schem != chosen_schema) continue; const std::string cname = cell(4); const int dtype = std::atoi(cell(5).c_str()); @@ -362,7 +366,7 @@ util::Result load_pk_snapshot(SQLHDBC dbc, const std::string& q, OdbcTable* tbl) { const std::string list = pk_select_list(q, *tbl); const std::string sql = - "SELECT " + list + " FROM " + quote_ident(q, tbl->name) + + "SELECT " + list + " FROM " + quote_ident(q, tbl->sql_table) + " ORDER BY " + list; std::vector> rows; std::vector> nulls; @@ -386,7 +390,7 @@ util::Result load_current_row(SQLHDBC dbc, const std::string& q, return util::Result{}; } const std::string sql = - "SELECT * FROM " + quote_ident(q, tbl->name) + " WHERE " + + "SELECT * FROM " + quote_ident(q, tbl->sql_table) + " WHERE " + pk_where_clause(q, *tbl, tbl->pk_snapshot[idx]); std::vector> rows; std::vector> nulls; @@ -499,7 +503,8 @@ OdbcConnection::open_table(const std::string& table_name) { } auto tbl = std::make_unique(); tbl->conn = this; - tbl->name = table_name; + tbl->name = table_name; + tbl->sql_table = table_name; auto pk = discover_pk(impl_->dbc, table_name); if (!pk) return pk.error(); @@ -663,17 +668,19 @@ util::Result OdbcConnection::seek_index( if (!tbl->fields_cached) { if (auto d = describe_columns(impl_->dbc, tbl); !d) return d.error(); } - if (field_index_ci(*tbl, column) == static_cast(-1)) { + const std::size_t col_idx = field_index_ci(*tbl, column); + if (col_idx == static_cast(-1)) { return util::Error{5063, 0, "seek column not found", column}; } + const std::string& sql_col = tbl->fields[col_idx].name; const std::string& q = impl_->quote; const std::string pkcols = pk_select_list(q, *tbl); const std::string esc = (kind == IndexExprKind::UpperColumn) ? escape_literal(key) - : format_literal(*tbl, column, key); - const std::string qexpr = index_column_sql(q, column, kind); - const std::string from = " FROM " + quote_ident(q, tbl->name); + : format_literal(*tbl, sql_col, key); + const std::string qexpr = index_column_sql(q, sql_col, kind); + const std::string from = " FROM " + quote_ident(q, tbl->sql_table); std::string sql; if (last_key) { diff --git a/src/sql_backend/odbc_table.h b/src/sql_backend/odbc_table.h index 45b2f156..42de3f2f 100644 --- a/src/sql_backend/odbc_table.h +++ b/src/sql_backend/odbc_table.h @@ -17,7 +17,8 @@ class OdbcConnection; // (no LIMIT / OFFSET / TOP, no scrollable-cursor dependency). struct OdbcTable { OdbcConnection* conn = nullptr; - std::string name; + std::string name; // ABI / AdsOpenTable name (caller casing) + std::string sql_table; // driver-reported TABLE_NAME for SQL struct FieldDesc { std::string name; diff --git a/tools/scripts/run_firebird_odbc_tests.ps1 b/tools/scripts/run_firebird_odbc_tests.ps1 new file mode 100644 index 00000000..095009a0 --- /dev/null +++ b/tools/scripts/run_firebird_odbc_tests.ps1 @@ -0,0 +1,67 @@ +<# + Run OpenADS ODBC ABI tests against the portable Firebird .fdb fixture. + + Prereqs: + - Firebird ODBC driver installed (see _UtlAI\firebird\install_firebird_odbc.bat) + - gds32.dll on PATH via FIREBIRD home (script sets this) + - devai_test.fdb seeded with table clientes (see seed_odbc_clientes.sql) + + Usage: + pwsh tools/scripts/run_firebird_odbc_tests.ps1 [-BuildDir build\odbc-msvc] +#> +param( + [string]$BuildDir = "build\odbc-msvc" +) +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$exe = Join-Path $root (Join-Path $BuildDir "tests\openads_unit_tests.exe") +if (-not (Test-Path $exe)) { + Write-Error "test binary not found: $exe (build with OPENADS_WITH_ODBC=ON first)" + exit 1 +} + +# DEVAI root = parent of _Prj (portable SSD — no drive hardcode in logic). +$devaiRoot = (Resolve-Path (Join-Path $root "..\..")).Path +$fbHome = Join-Path $devaiRoot "_UtlAI\firebird" +$fbDb = Join-Path $devaiRoot "_Prj\data\firebird\devai_test.fdb" +$fbPass = "devai_fb" +$cfg = Join-Path $devaiRoot "config_ai\.firebird\credenciais.txt" + +if (Test-Path $cfg) { + Get-Content $cfg | ForEach-Object { + if ($_ -match '^\s*SYSDBA_PASSWORD\s*=\s*(.+)\s*$') { $fbPass = $Matches[1].Trim() } + } +} + +if (-not (Test-Path $fbHome)) { + Write-Error "Firebird portable not found: $fbHome (run setup_firebird.bat)" + exit 1 +} +if (-not (Test-Path $fbDb)) { + Write-Error "test database not found: $fbDb (run create_test_db.bat + seed_odbc_clientes.sql)" + exit 1 +} + +$drivers = Get-OdbcDriver -Platform "64-bit" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match 'Firebird' } +if (-not $drivers) { + Write-Error "Firebird ODBC driver not installed — run install_firebird_odbc.bat" + exit 1 +} + +# fbclient as gds32 for the ODBC driver (no copy into System32 required). +$gds = Join-Path $fbHome "gds32.dll" +if (-not (Test-Path $gds)) { + Copy-Item (Join-Path $fbHome "fbclient.dll") $gds -Force +} + +$env:FIREBIRD = $fbHome +$env:PATH = "$fbHome;$env:PATH" + +$connstr = "Driver={Firebird ODBC Driver};Database=$fbDb;Uid=SYSDBA;Pwd=$fbPass;Charset=UTF8;" +$env:OPENADS_TEST_ODBC_CONNSTR = $connstr + +Write-Host "[openads] Firebird ODBC fixture: $fbDb" +& $exe --test-case=*odbc* +exit $LASTEXITCODE \ No newline at end of file From 5cdabb3127d14e6912e3c85bc5634e344058c4c2 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 21:14:16 -0300 Subject: [PATCH 4/7] chore(odbc): prefer portable Firebird ODBC Driver (DEVAI) Use HKLM-registered driver pointing at SSD odbc\bin; fallback to system driver with warning. Co-Authored-By: Grok Code --- tools/scripts/run_firebird_odbc_tests.ps1 | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tools/scripts/run_firebird_odbc_tests.ps1 b/tools/scripts/run_firebird_odbc_tests.ps1 index 095009a0..85574b26 100644 --- a/tools/scripts/run_firebird_odbc_tests.ps1 +++ b/tools/scripts/run_firebird_odbc_tests.ps1 @@ -43,10 +43,15 @@ if (-not (Test-Path $fbDb)) { exit 1 } -$drivers = Get-OdbcDriver -Platform "64-bit" -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match 'Firebird' } -if (-not $drivers) { - Write-Error "Firebird ODBC driver not installed — run install_firebird_odbc.bat" +$driverName = "Firebird ODBC Driver (DEVAI)" +$drivers = Get-OdbcDriver -Platform "64-bit" -ErrorAction SilentlyContinue +if ($drivers.Name -contains $driverName) { + # portable SSD registration (HKLM -> odbc\bin\FirebirdODBC.dll) +} elseif ($drivers.Name -contains "Firebird ODBC Driver") { + Write-Warning "Using system Firebird ODBC Driver (C:\Windows\System32). Run install_firebird_odbc.bat for portable." + $driverName = "Firebird ODBC Driver" +} else { + Write-Error "Firebird ODBC driver not registered — run install_firebird_odbc.bat (UAC 1x per machine)" exit 1 } @@ -59,7 +64,7 @@ if (-not (Test-Path $gds)) { $env:FIREBIRD = $fbHome $env:PATH = "$fbHome;$env:PATH" -$connstr = "Driver={Firebird ODBC Driver};Database=$fbDb;Uid=SYSDBA;Pwd=$fbPass;Charset=UTF8;" +$connstr = "Driver={$driverName};Database=$fbDb;Uid=SYSDBA;Pwd=$fbPass;Charset=UTF8;" $env:OPENADS_TEST_ODBC_CONNSTR = $connstr Write-Host "[openads] Firebird ODBC fixture: $fbDb" From 4486b0180ba5da52db49a816bb9078e828995ef1 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sun, 21 Jun 2026 17:17:36 -0300 Subject: [PATCH 5/7] test(odbc): live e2e harness + verified SQL Server target Add a connection-string-driven harness (run_odbc_tests_live.ps1) that runs the existing ODBC ABI cases against any live SQL data source, plus a note on verified targets. The unmodified backend passes the same 4 cases / 59 assertions against SQL Server 2022 (ODBC Driver 18) that it passes against the Access fixture -- no dialect-specific code: SQLPrimaryKeys is honoured, the identifier quote char is discovered via SQLGetInfo, and numeric literals are emitted type-aware. When the connection string is unset the live cases skip, so the suite stays green without a configured data source. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openads-plus/ODBC_LIVE_TARGETS.md | 28 +++++++++++++ tools/scripts/run_odbc_tests_live.ps1 | 55 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/openads-plus/ODBC_LIVE_TARGETS.md create mode 100644 tools/scripts/run_odbc_tests_live.ps1 diff --git a/docs/openads-plus/ODBC_LIVE_TARGETS.md b/docs/openads-plus/ODBC_LIVE_TARGETS.md new file mode 100644 index 00000000..204eefba --- /dev/null +++ b/docs/openads-plus/ODBC_LIVE_TARGETS.md @@ -0,0 +1,28 @@ +# ODBC backend — verified live targets + +The ODBC Plus backend (`odbc://`) is data-source agnostic: it reaches any +engine through its ODBC driver using only standard catalog calls +(`SQLPrimaryKeys` / `SQLStatistics` / `SQLColumns`) and portable SQL. No +per-dialect code lives in the backend. + +The same ABI test cases (`--test-case=*odbc*`) run against any target by +pointing `OPENADS_TEST_ODBC_CONNSTR` at it. Two reproducible harnesses ship: + +| Target | Harness | Notes | +|--------|---------|-------| +| Microsoft Access (`.accdb`) | `tools/scripts/run_odbc_tests.ps1` | Zero-server; ADOX fixture, available out of the box on Windows. | +| SQL Server / PostgreSQL / MariaDB / Firebird | `tools/scripts/run_odbc_tests_live.ps1 -ConnStr '...'` | Connection-string driven; seeds the `clientes` fixture over ODBC. | + +## Verified + +- **Microsoft Access** — 4 cases / 59 assertions (CI fixture). +- **SQL Server 2022** — 4 cases / 59 assertions, via the *ODBC Driver 18 for + SQL Server*. The unmodified backend (same binary that passes against + Access) navigates a `dbo.clientes` table by primary key with no + dialect-specific changes: `SQLPrimaryKeys` is honoured, the identifier + quote character is discovered via `SQLGetInfo`, and numeric literals are + emitted type-aware. + +When `OPENADS_TEST_ODBC_CONNSTR` is unset, the live cases skip (the backend +is still exercised by the URI-parsing unit tests), so the suite stays green +on machines without a configured data source. diff --git a/tools/scripts/run_odbc_tests_live.ps1 b/tools/scripts/run_odbc_tests_live.ps1 new file mode 100644 index 00000000..287e1b5c --- /dev/null +++ b/tools/scripts/run_odbc_tests_live.ps1 @@ -0,0 +1,55 @@ +<# + Run the OpenADS ODBC ABI e2e tests against a live SQL data source. + + Unlike run_odbc_tests.ps1 (which spins up a zero-server Microsoft Access + fixture via ADOX), this script targets any server reachable through an + ODBC driver -- SQL Server, PostgreSQL, MariaDB, Firebird, ... -- given a + connection string. It seeds the `clientes` fixture table over ODBC, + exports OPENADS_TEST_ODBC_CONNSTR, and runs the unit-test binary filtered + to the ODBC cases. + + Usage: + pwsh tools/scripts/run_odbc_tests_live.ps1 -ConnStr '' + + Examples (connection strings are environment-specific): + # SQL Server + -ConnStr 'Driver={ODBC Driver 18 for SQL Server};Server=;Database=;Trusted_Connection=yes;Encrypt=no;TrustServerCertificate=yes;' + # PostgreSQL + -ConnStr 'Driver={PostgreSQL Unicode};Server=;Port=5432;Database=;Uid=;Pwd=;' +#> +param( + [Parameter(Mandatory = $true)] + [string]$ConnStr, + [string]$BuildDir = "build\odbc-msvc" +) +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$exe = Join-Path $root (Join-Path $BuildDir "tests\openads_unit_tests.exe") +if (-not (Test-Path $exe)) { + Write-Error "test binary not found: $exe (build first)" + exit 1 +} + +# Seed the fixture table the ABI tests expect: +# clientes(id INT PRIMARY KEY, nome VARCHAR(64), saldo FLOAT) +# rows (1,'Ana',10.5), (2,'Bob',NULL), (3,'Cid',0.0) +# Standard SQL; portable across SQL Server / PostgreSQL / MariaDB / Firebird. +Add-Type -AssemblyName System.Data +$c = New-Object System.Data.Odbc.OdbcConnection($ConnStr) +$c.Open() +function Invoke-Sql([string]$sql, [bool]$ignoreErr = $false) { + $cmd = $c.CreateCommand(); $cmd.CommandText = $sql + try { [void]$cmd.ExecuteNonQuery() } + catch { if (-not $ignoreErr) { throw } } +} +Invoke-Sql "DROP TABLE clientes" $true +Invoke-Sql "CREATE TABLE clientes (id INT NOT NULL PRIMARY KEY, nome VARCHAR(64), saldo FLOAT)" +Invoke-Sql "INSERT INTO clientes (id, nome, saldo) VALUES (1, 'Ana', 10.5)" +Invoke-Sql "INSERT INTO clientes (id, nome, saldo) VALUES (2, 'Bob', NULL)" +Invoke-Sql "INSERT INTO clientes (id, nome, saldo) VALUES (3, 'Cid', 0.0)" +$c.Close() + +$env:OPENADS_TEST_ODBC_CONNSTR = $ConnStr +& $exe --test-case=*odbc* +exit $LASTEXITCODE From 57fde45fba268bdd1d6ddb70cef9e6456a266a45 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sun, 21 Jun 2026 19:11:47 -0300 Subject: [PATCH 6/7] feat(odbc): navigational write (append / update / delete) The ODBC backend was read-only; a write on an ODBC-backed table fell through to the native-engine path and failed as an unknown handle. Add navigational write so a Harbour rddads app (APPEND BLANK + field assigns + commit, or DELETE) works unmodified against any ODBC data source. - OdbcTable gains a staged field list + an `appending` flag. - OdbcConnection: append_blank / set_field / flush_table / delete_record, mirroring the remote (tcp://) backend's write surface. flush emits one INSERT (appending) or UPDATE (positioned edit) built from the staged values via the existing type-aware literal formatter, then reloads the PK snapshot and repositions the cursor on the written row; delete issues a DELETE by primary key. - ABI: AdsAppendRecord / AdsWriteRecord / AdsDeleteRecord / AdsSetString / AdsSetDouble / AdsSetLogical route ODBC handles to the new methods, alongside the existing remote and native branches. - read_all_rows returns empty when the statement has no result set, so the same execute helper serves DML. v1 expects the caller to supply the primary key on append (no IDENTITY round-trip yet) and emits SQL literals (parameter binding is a later hardening slice). New live test abi_plus_odbc_write_test.cpp appends, updates and deletes a row; it is self-restoring (ends back at 3 rows) so it composes with the read/seek cases. Green on both the Access CI fixture and live SQL Server 2022 (ODBC Driver 18): 5 ODBC cases / 83 assertions. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openads-plus/ODBC_LIVE_TARGETS.md | 13 +- src/abi/ace_exports.cpp | 42 ++++++ src/sql_backend/odbc_connection.cpp | 167 ++++++++++++++++++++++++ src/sql_backend/odbc_connection.h | 24 +++- src/sql_backend/odbc_table.h | 8 ++ tests/CMakeLists.txt | 1 + tests/unit/abi_plus_odbc_write_test.cpp | 112 ++++++++++++++++ 7 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 tests/unit/abi_plus_odbc_write_test.cpp diff --git a/docs/openads-plus/ODBC_LIVE_TARGETS.md b/docs/openads-plus/ODBC_LIVE_TARGETS.md index 204eefba..d2dbbefe 100644 --- a/docs/openads-plus/ODBC_LIVE_TARGETS.md +++ b/docs/openads-plus/ODBC_LIVE_TARGETS.md @@ -15,14 +15,23 @@ pointing `OPENADS_TEST_ODBC_CONNSTR` at it. Two reproducible harnesses ship: ## Verified -- **Microsoft Access** — 4 cases / 59 assertions (CI fixture). -- **SQL Server 2022** — 4 cases / 59 assertions, via the *ODBC Driver 18 for +- **Microsoft Access** — 5 cases / 83 assertions (CI fixture). +- **SQL Server 2022** — 5 cases / 83 assertions, via the *ODBC Driver 18 for SQL Server*. The unmodified backend (same binary that passes against Access) navigates a `dbo.clientes` table by primary key with no dialect-specific changes: `SQLPrimaryKeys` is honoured, the identifier quote character is discovered via `SQLGetInfo`, and numeric literals are emitted type-aware. +Both read navigation (`GO TOP` / `SKIP` / `SEEK`) and navigational write +(`AdsAppendRecord` → `AdsSetString`/`AdsSetDouble` → `AdsWriteRecord`, +plus `AdsDeleteRecord`) are exercised against the same fixture on both +drivers. Write stages field values and flushes one `INSERT` (append) or +`UPDATE` (positioned edit) per record; `AdsDeleteRecord` issues a `DELETE` +by primary key. v1 expects the caller to supply the primary key on append +(no IDENTITY round-trip yet) and emits SQL literals (parameter binding is a +later hardening slice). + When `OPENADS_TEST_ODBC_CONNSTR` is unset, the live cases skip (the backend is still exercised by the URI-parsing unit tests), so the suite stays green on machines without a configured data source. diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 9f5063cf..d3042a01 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -3409,6 +3409,11 @@ UNSIGNED32 AdsAppendRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } + if (auto* st = get_odbc_table(hTable)) { + auto r = st->conn->append_blank(st); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->append_record(); @@ -3430,6 +3435,11 @@ UNSIGNED32 AdsWriteRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } + if (auto* st = get_odbc_table(hTable)) { + auto r = st->conn->flush_table(st); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); bool is_insert = t->pending_append(); @@ -3485,6 +3495,11 @@ UNSIGNED32 AdsDeleteRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } + if (auto* st = get_odbc_table(hTable)) { + auto r = st->conn->delete_record(st); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); t->set_pending_append(false); // abandon any in-flight append @@ -3594,6 +3609,17 @@ UNSIGNED32 AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, if (!r) return fail(r.error()); return ok(); } + if (auto* st = get_odbc_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) { + val.assign(reinterpret_cast(pucValue), ulLen); + } + auto r = st->conn->set_field(st, fname, val); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); std::uint16_t idx = 0; @@ -3616,6 +3642,13 @@ UNSIGNED32 AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, reinterpret_cast(pucField), bValue ? "1" : "0")) return ok(); + if (auto* st = get_odbc_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto r = st->conn->set_field( + st, reinterpret_cast(pucField), bValue ? "1" : "0"); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); std::uint16_t idx = 0; @@ -3642,6 +3675,15 @@ UNSIGNED32 AdsSetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, std::string(nbuf))) return ok(); } + if (auto* st = get_odbc_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + char nbuf2[64]; + std::snprintf(nbuf2, sizeof(nbuf2), "%.17g", dValue); + auto r = st->conn->set_field( + st, reinterpret_cast(pucField), std::string(nbuf2)); + if (!r) return fail(r.error()); + return ok(); + } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); std::uint16_t idx = 0; diff --git a/src/sql_backend/odbc_connection.cpp b/src/sql_backend/odbc_connection.cpp index e2c2fb10..0db7c0ae 100644 --- a/src/sql_backend/odbc_connection.cpp +++ b/src/sql_backend/odbc_connection.cpp @@ -22,6 +22,7 @@ namespace openads::sql_backend {} #include #include +#include #include namespace openads::sql_backend { @@ -63,6 +64,10 @@ util::Result read_all_rows( if (!SQL_SUCCEEDED(SQLNumResultCols(st, &cols))) { return odbc_error("odbc num cols", odbc_diag(SQL_HANDLE_STMT, st)); } + // INSERT / UPDATE / DELETE produce no result set; there is nothing to + // fetch and SQLFetch on a cursorless statement would error. Return the + // empty row set so the write path can reuse run_query for DML. + if (cols == 0) return util::Result{}; while (true) { SQLRETURN r = SQLFetch(st); if (r == SQL_NO_DATA) break; @@ -731,6 +736,168 @@ util::Result OdbcConnection::seek_index( return found; } +namespace { + +bool ci_equal(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +// Build the PK tuple of the row just written, so flush_table can reposition +// the cursor on it after reloading the snapshot. For an append the PK comes +// from the staged values; for an edit it is the current PK with any staged +// PK-column override applied. +OdbcTable::PkRow target_pk(const OdbcTable& tbl, bool appending) { + OdbcTable::PkRow pk; + pk.values.reserve(tbl.pk_columns.size()); + for (std::size_t c = 0; c < tbl.pk_columns.size(); ++c) { + std::string v = (!appending && tbl.pos < tbl.pk_snapshot.size()) + ? tbl.pk_snapshot[tbl.pos].values[c] + : std::string(); + for (const auto& kv : tbl.staged) { + if (ci_equal(kv.first, tbl.pk_columns[c])) { v = kv.second; break; } + } + pk.values.push_back(v); + } + return pk; +} + +} // namespace + +util::Result OdbcConnection::append_blank(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc append", ""}; + } + if (!tbl->fields_cached) { + if (auto d = describe_columns(impl_->dbc, tbl); !d) return d.error(); + } + tbl->staged.clear(); + tbl->appending = true; + return util::Result{}; +} + +util::Result OdbcConnection::set_field(OdbcTable* tbl, + const std::string& name, + const std::string& value) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc set_field", ""}; + } + if (!tbl->fields_cached) { + if (auto d = describe_columns(impl_->dbc, tbl); !d) return d.error(); + } + const std::size_t idx = field_index_ci(*tbl, name); + if (idx == static_cast(-1)) { + return util::Error{5063, 0, "column not found", name}; + } + const std::string& sqlname = tbl->fields[idx].name; // driver casing + for (auto& kv : tbl->staged) { + if (ci_equal(kv.first, sqlname)) { kv.second = value; return {}; } + } + tbl->staged.emplace_back(sqlname, value); + return util::Result{}; +} + +util::Result OdbcConnection::flush_table(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc flush", ""}; + } + const std::string& q = impl_->quote; + + if (tbl->appending) { + if (tbl->staged.empty()) { + return util::Error{5001, 0, "append with no fields set", + tbl->name}; + } + std::string cols, vals; + for (std::size_t i = 0; i < tbl->staged.size(); ++i) { + if (i) { cols += ", "; vals += ", "; } + cols += quote_ident(q, tbl->staged[i].first); + vals += format_literal(*tbl, tbl->staged[i].first, + tbl->staged[i].second); + } + const std::string sql = + "INSERT INTO " + quote_ident(q, tbl->sql_table) + + " (" + cols + ") VALUES (" + vals + ")"; + std::vector> rows; + std::vector> nulls; + if (auto r = run_query(impl_->dbc, sql, rows, nulls); !r) { + return r.error(); + } + } else { + if (tbl->staged.empty()) return util::Result{}; // no-op edit + if (!tbl->positioned || tbl->pos >= tbl->pk_snapshot.size()) { + return util::Error{5026, 0, "no current record to update", ""}; + } + std::string sets; + for (std::size_t i = 0; i < tbl->staged.size(); ++i) { + if (i) sets += ", "; + sets += quote_ident(q, tbl->staged[i].first) + " = " + + format_literal(*tbl, tbl->staged[i].first, + tbl->staged[i].second); + } + const std::string sql = + "UPDATE " + quote_ident(q, tbl->sql_table) + " SET " + sets + + " WHERE " + pk_where_clause(q, *tbl, tbl->pk_snapshot[tbl->pos]); + std::vector> rows; + std::vector> nulls; + if (auto r = run_query(impl_->dbc, sql, rows, nulls); !r) { + return r.error(); + } + } + + const OdbcTable::PkRow want = target_pk(*tbl, tbl->appending); + tbl->staged.clear(); + tbl->appending = false; + + if (auto s = load_pk_snapshot(impl_->dbc, q, tbl); !s) return s.error(); + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + + std::size_t pos = static_cast(-1); + for (std::size_t i = 0; i < tbl->pk_snapshot.size(); ++i) { + if (tbl->pk_snapshot[i] == want) { pos = i; break; } + } + if (pos != static_cast(-1)) { + return load_current_row(impl_->dbc, q, tbl, pos); + } + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; +} + +util::Result OdbcConnection::delete_record(OdbcTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid odbc delete", ""}; + } + tbl->staged.clear(); + tbl->appending = false; + if (!tbl->positioned || tbl->pos >= tbl->pk_snapshot.size()) { + return util::Error{5026, 0, "no current record to delete", ""}; + } + const std::string& q = impl_->quote; + const std::string sql = + "DELETE FROM " + quote_ident(q, tbl->sql_table) + " WHERE " + + pk_where_clause(q, *tbl, tbl->pk_snapshot[tbl->pos]); + std::vector> rows; + std::vector> nulls; + if (auto r = run_query(impl_->dbc, sql, rows, nulls); !r) { + return r.error(); + } + + if (auto s = load_pk_snapshot(impl_->dbc, q, tbl); !s) return s.error(); + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; +} + } // namespace openads::sql_backend #endif // OPENADS_WITH_ODBC diff --git a/src/sql_backend/odbc_connection.h b/src/sql_backend/odbc_connection.h index fc70a903..9dac82c9 100644 --- a/src/sql_backend/odbc_connection.h +++ b/src/sql_backend/odbc_connection.h @@ -12,11 +12,13 @@ namespace openads::sql_backend { -// Read-only v1 ODBC backend. Talks to any data source with an ODBC -// driver (SQL Server, Oracle, Firebird, PostgreSQL, MariaDB, DB2, -// Access, …) through the Win32 / unixODBC API. Write support -// (append / update / delete) is a later slice; until then a write on an -// ODBC-backed table is rejected at the ABI border as an unknown handle. +// ODBC backend. Talks to any data source with an ODBC driver (SQL Server, +// Oracle, Firebird, PostgreSQL, MariaDB, DB2, Access, …) through the +// Win32 / unixODBC API. Read navigates a primary-key snapshot; write +// (append / update / delete) stages field values and flushes one +// INSERT / UPDATE / DELETE per record. v1 expects the caller to supply the +// primary key on append (no IDENTITY round-trip yet) and formats values as +// SQL literals (parameter binding is a later hardening slice). class OdbcConnection { public: OdbcConnection(); @@ -59,6 +61,18 @@ class OdbcConnection { bool soft, bool last_key); + // --- navigational write (v1) --- + // append_blank starts a fresh staged row; set_field stages a value by + // column name (append OR positioned edit); flush_table emits the + // INSERT (appending) or UPDATE (positioned) and repositions on the + // written row; delete_record removes the current row. + util::Result append_blank(OdbcTable* tbl); + util::Result set_field(OdbcTable* tbl, + const std::string& name, + const std::string& value); + util::Result flush_table(OdbcTable* tbl); + util::Result delete_record(OdbcTable* tbl); + private: struct Impl; std::unique_ptr impl_; diff --git a/src/sql_backend/odbc_table.h b/src/sql_backend/odbc_table.h index 42de3f2f..3bee9c6d 100644 --- a/src/sql_backend/odbc_table.h +++ b/src/sql_backend/odbc_table.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace openads::sql_backend { @@ -51,6 +52,13 @@ struct OdbcTable { std::size_t pos = 0; bool positioned = false; bool last_seek_found = false; + + // --- write staging (navigational append / update via SQL) --- + // Field values set since the last AppendRecord (appending) or since + // navigating onto a row (edit), keyed by the driver-reported column + // name. flush_table emits one INSERT (appending) or UPDATE (edit). + std::vector> staged; + bool appending = false; }; } // namespace openads::sql_backend diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d81b3ea..5abe709d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -147,6 +147,7 @@ if(OPENADS_WITH_ODBC) unit/odbc_uri_test.cpp unit/abi_plus_odbc_read_test.cpp unit/abi_plus_odbc_seek_test.cpp + unit/abi_plus_odbc_write_test.cpp ) endif() diff --git a/tests/unit/abi_plus_odbc_write_test.cpp b/tests/unit/abi_plus_odbc_write_test.cpp new file mode 100644 index 00000000..e4cbda77 --- /dev/null +++ b/tests/unit/abi_plus_odbc_write_test.cpp @@ -0,0 +1,112 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include + +#if defined(OPENADS_WITH_ODBC) + +namespace { + +// Live ODBC fixture: a connection string pointing at a data source that +// already holds a `clientes` table seeded with +// (1,'Ana',10.5), (2,'Bob',NULL), (3,'Cid',0.0) +// id INTEGER PRIMARY KEY, nome VARCHAR(64), saldo (FLOAT/DOUBLE). +// tools/scripts/run_odbc_tests_live.ps1 seeds it and exports the variable. +// When unset the live test is skipped. The test is self-restoring: it +// appends a row and then deletes it, leaving the table back at 3 rows so +// the read/seek cases (which expect 3) pass regardless of order. +const char* test_odbc_connstr() { + const char* v = std::getenv("OPENADS_TEST_ODBC_CONNSTR"); + return (v != nullptr && v[0] != '\0') ? v : nullptr; +} + +ADSHANDLE connect_odbc(const char* connstr) { + const std::string uri = std::string("odbc://") + connstr; + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + return hConn; +} + +ADSHANDLE open_clientes(ADSHANDLE hConn) { + UNSIGNED8 tbl[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl, tbl, ADS_DEFAULT, 0, 0, 0, + ADS_DEFAULT, &hTable) == 0); + return hTable; +} + +void set_str(ADSHANDLE hTable, const char* field, const char* value) { + UNSIGNED8 f[64]; + std::memcpy(f, field, std::strlen(field) + 1); + UNSIGNED8 v[256]; + std::memcpy(v, value, std::strlen(value) + 1); + REQUIRE(AdsSetString(hTable, f, v, + static_cast(std::strlen(value))) == 0); +} + +void set_dbl(ADSHANDLE hTable, const char* field, double value) { + UNSIGNED8 f[64]; + std::memcpy(f, field, std::strlen(field) + 1); + REQUIRE(AdsSetDouble(hTable, f, value) == 0); +} + +std::string read_str(ADSHANDLE hTable, const char* field) { + UNSIGNED8 f[64]; + std::memcpy(f, field, std::strlen(field) + 1); + UNSIGNED8 buf[256] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, f, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +UNSIGNED32 count_rows(ADSHANDLE hTable) { + UNSIGNED32 n = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &n) == 0); + return n; +} + +} // namespace + +TEST_CASE("ABI: odbc navigational write append/update/delete") { + const char* connstr = test_odbc_connstr(); + if (connstr == nullptr) { + MESSAGE("OPENADS_TEST_ODBC_CONNSTR not set; skipping live ODBC write"); + return; + } + + ADSHANDLE hConn = connect_odbc(connstr); + ADSHANDLE hTable = open_clientes(hConn); + REQUIRE(count_rows(hTable) == 3); + + // --- APPEND a new row (id=4, 'Dan', 99.9). WriteRecord leaves the + // cursor positioned on the freshly inserted row. --- + REQUIRE(AdsAppendRecord(hTable) == 0); + set_str(hTable, "id", "4"); + set_str(hTable, "nome", "Dan"); + set_dbl(hTable, "saldo", 99.9); + REQUIRE(AdsWriteRecord(hTable) == 0); + CHECK(count_rows(hTable) == 4); + CHECK(read_str(hTable, "nome").find("Dan") != std::string::npos); + + // --- UPDATE the saldo of the positioned row --- + set_dbl(hTable, "saldo", 42.0); + REQUIRE(AdsWriteRecord(hTable) == 0); + CHECK(read_str(hTable, "saldo").find("42") != std::string::npos); + CHECK(count_rows(hTable) == 4); // update must not add a row + + // --- DELETE it, restoring the table to 3 rows --- + REQUIRE(AdsDeleteRecord(hTable) == 0); + CHECK(count_rows(hTable) == 3); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif // OPENADS_WITH_ODBC From 352f65057b278046a53ab0375a9bab1011670ee0 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sun, 21 Jun 2026 20:33:08 -0300 Subject: [PATCH 7/7] chore(odbc): drop environment-specific Firebird test runner It hardcoded local toolchain paths and a fixture password. The generic connection-string-driven runner (run_odbc_tests_live.ps1) already covers Firebird: pass -ConnStr 'Driver={Firebird ODBC Driver};Database=...;Uid=SYSDBA;Pwd=...'. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/scripts/run_firebird_odbc_tests.ps1 | 72 ----------------------- 1 file changed, 72 deletions(-) delete mode 100644 tools/scripts/run_firebird_odbc_tests.ps1 diff --git a/tools/scripts/run_firebird_odbc_tests.ps1 b/tools/scripts/run_firebird_odbc_tests.ps1 deleted file mode 100644 index 85574b26..00000000 --- a/tools/scripts/run_firebird_odbc_tests.ps1 +++ /dev/null @@ -1,72 +0,0 @@ -<# - Run OpenADS ODBC ABI tests against the portable Firebird .fdb fixture. - - Prereqs: - - Firebird ODBC driver installed (see _UtlAI\firebird\install_firebird_odbc.bat) - - gds32.dll on PATH via FIREBIRD home (script sets this) - - devai_test.fdb seeded with table clientes (see seed_odbc_clientes.sql) - - Usage: - pwsh tools/scripts/run_firebird_odbc_tests.ps1 [-BuildDir build\odbc-msvc] -#> -param( - [string]$BuildDir = "build\odbc-msvc" -) -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -$exe = Join-Path $root (Join-Path $BuildDir "tests\openads_unit_tests.exe") -if (-not (Test-Path $exe)) { - Write-Error "test binary not found: $exe (build with OPENADS_WITH_ODBC=ON first)" - exit 1 -} - -# DEVAI root = parent of _Prj (portable SSD — no drive hardcode in logic). -$devaiRoot = (Resolve-Path (Join-Path $root "..\..")).Path -$fbHome = Join-Path $devaiRoot "_UtlAI\firebird" -$fbDb = Join-Path $devaiRoot "_Prj\data\firebird\devai_test.fdb" -$fbPass = "devai_fb" -$cfg = Join-Path $devaiRoot "config_ai\.firebird\credenciais.txt" - -if (Test-Path $cfg) { - Get-Content $cfg | ForEach-Object { - if ($_ -match '^\s*SYSDBA_PASSWORD\s*=\s*(.+)\s*$') { $fbPass = $Matches[1].Trim() } - } -} - -if (-not (Test-Path $fbHome)) { - Write-Error "Firebird portable not found: $fbHome (run setup_firebird.bat)" - exit 1 -} -if (-not (Test-Path $fbDb)) { - Write-Error "test database not found: $fbDb (run create_test_db.bat + seed_odbc_clientes.sql)" - exit 1 -} - -$driverName = "Firebird ODBC Driver (DEVAI)" -$drivers = Get-OdbcDriver -Platform "64-bit" -ErrorAction SilentlyContinue -if ($drivers.Name -contains $driverName) { - # portable SSD registration (HKLM -> odbc\bin\FirebirdODBC.dll) -} elseif ($drivers.Name -contains "Firebird ODBC Driver") { - Write-Warning "Using system Firebird ODBC Driver (C:\Windows\System32). Run install_firebird_odbc.bat for portable." - $driverName = "Firebird ODBC Driver" -} else { - Write-Error "Firebird ODBC driver not registered — run install_firebird_odbc.bat (UAC 1x per machine)" - exit 1 -} - -# fbclient as gds32 for the ODBC driver (no copy into System32 required). -$gds = Join-Path $fbHome "gds32.dll" -if (-not (Test-Path $gds)) { - Copy-Item (Join-Path $fbHome "fbclient.dll") $gds -Force -} - -$env:FIREBIRD = $fbHome -$env:PATH = "$fbHome;$env:PATH" - -$connstr = "Driver={$driverName};Database=$fbDb;Uid=SYSDBA;Pwd=$fbPass;Charset=UTF8;" -$env:OPENADS_TEST_ODBC_CONNSTR = $connstr - -Write-Host "[openads] Firebird ODBC fixture: $fbDb" -& $exe --test-case=*odbc* -exit $LASTEXITCODE \ No newline at end of file