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/docs/openads-plus/ODBC_LIVE_TARGETS.md b/docs/openads-plus/ODBC_LIVE_TARGETS.md new file mode 100644 index 00000000..d2dbbefe --- /dev/null +++ b/docs/openads-plus/ODBC_LIVE_TARGETS.md @@ -0,0 +1,37 @@ +# 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** — 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/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..d3042a01 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; @@ -3096,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(); @@ -3117,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(); @@ -3172,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 @@ -3237,6 +3565,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; @@ -3275,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; @@ -3297,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; @@ -3323,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; @@ -3923,6 +4284,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 +4471,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 +4570,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 +6749,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 +6773,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 +6891,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 +14359,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..0db7c0ae --- /dev/null +++ b/src/sql_backend/odbc_connection.cpp @@ -0,0 +1,903 @@ +#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 +#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)); + } + // 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; + 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 if (ind < 0) { + chunk = 0; // stray driver indicator; ignore + } 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(); + // 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 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)); + } 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::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); + } + 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::string chosen_schema; + bool has_schema = false; + 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(); + }; + // 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; + 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()); + 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->sql_table) + + " 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->sql_table) + " 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", ""}; + } + 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)", + 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; + tbl->sql_table = 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(); + } + 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, 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) { + 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 { + +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 new file mode 100644 index 00000000..9dac82c9 --- /dev/null +++ b/src/sql_backend/odbc_connection.h @@ -0,0 +1,81 @@ +#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 { + +// 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(); + ~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); + + // --- 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_; +}; + +} // 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..3bee9c6d --- /dev/null +++ b/src/sql_backend/odbc_table.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#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; // ABI / AdsOpenTable name (caller casing) + std::string sql_table; // driver-reported TABLE_NAME for SQL + + 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; + + // --- 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/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..5abe709d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -142,6 +142,15 @@ 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 + unit/abi_plus_odbc_write_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/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 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 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