diff --git a/CMakeLists.txt b/CMakeLists.txt index c24d2d31..6a89caa5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,12 @@ option(OPENADS_WITH_HTTP "Enable embedded HTTP web console (Studio) in openads_s # OpenADS Plus — optional SQLite-backed table driver (static amalgamation). # On by default; set OFF for dev builds that skip the FetchContent download. option(OPENADS_WITH_SQLITE "Enable the SQLite-backed table driver (vendors the SQLite amalgamation via FetchContent)" ON) +# Optional PostgreSQL table driver (libpq). OFF by default. +option(OPENADS_WITH_POSTGRESQL "Enable PostgreSQL-backed table driver (libpq)" OFF) +# Optional MariaDB/MySQL table driver (libmariadb). OFF by default. +option(OPENADS_WITH_MARIADB "Enable MariaDB-backed table driver (libmariadb)" OFF) +# Optional generic ODBC table driver (system ODBC driver manager). 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 @@ -160,12 +166,126 @@ if(OPENADS_WITH_SQLITE) message(STATUS "OpenADS: SQLite-backed table driver enabled") endif() +if(OPENADS_WITH_POSTGRESQL) + set(_openads_pg_linked FALSE) + set(OPENADS_LIBPQ_INCLUDE "" CACHE PATH "Directory containing libpq-fe.h") + set(OPENADS_LIBPQ_LIBRARY "" CACHE FILEPATH "libpq import library (.lib/.a)") + if(OPENADS_LIBPQ_INCLUDE AND OPENADS_LIBPQ_LIBRARY) + set(_openads_pg_inc "${OPENADS_LIBPQ_INCLUDE}") + set(_openads_pg_lib "${OPENADS_LIBPQ_LIBRARY}") + endif() + if(NOT _openads_pg_lib) + if(DEFINED ENV{OPENADS_LIBPQ_LIBRARY}) + set(_openads_pg_lib "$ENV{OPENADS_LIBPQ_LIBRARY}") + endif() + if(DEFINED ENV{OPENADS_LIBPQ_INCLUDE}) + set(_openads_pg_inc "$ENV{OPENADS_LIBPQ_INCLUDE}") + endif() + endif() + if(NOT _openads_pg_lib OR NOT _openads_pg_inc) + if(DEFINED ENV{OPENADS_TOOLCHAIN_ROOT}) + set(_openads_toolchain_root "$ENV{OPENADS_TOOLCHAIN_ROOT}") + elseif(DEFINED OPENADS_TOOLCHAIN_ROOT) + set(_openads_toolchain_root "${OPENADS_TOOLCHAIN_ROOT}") + endif() + if(_openads_toolchain_root) + if(NOT _openads_pg_inc) + find_path(_openads_pg_inc libpq-fe.h + PATHS "${_openads_toolchain_root}/pgsql/include" + NO_DEFAULT_PATH) + endif() + if(NOT _openads_pg_lib) + if(CMAKE_SIZEOF_VOID_P EQUAL 4) + find_library(_openads_pg_lib NAMES libpq pq + PATHS "${_openads_toolchain_root}/libpq/x86" + "${_openads_toolchain_root}/libpq/x86/lib" + NO_DEFAULT_PATH) + else() + find_library(_openads_pg_lib NAMES pq libpq + PATHS "${_openads_toolchain_root}/pgsql/lib" + NO_DEFAULT_PATH) + endif() + endif() + endif() + endif() + if(_openads_pg_inc AND _openads_pg_lib) + add_library(openads_libpq INTERFACE) + target_include_directories(openads_libpq INTERFACE "${_openads_pg_inc}") + target_link_libraries(openads_libpq INTERFACE "${_openads_pg_lib}") + set(_openads_pg_linked TRUE) + message(STATUS "OpenADS: PostgreSQL backend (libpq: ${_openads_pg_lib})") + endif() + if(NOT _openads_pg_linked) + find_package(PostgreSQL REQUIRED) + add_library(openads_libpq INTERFACE) + target_include_directories(openads_libpq INTERFACE "${PostgreSQL_INCLUDE_DIRS}") + target_link_libraries(openads_libpq INTERFACE PostgreSQL::PostgreSQL) + message(STATUS "OpenADS: PostgreSQL backend (libpq via find_package)") + endif() +endif() + +if(OPENADS_WITH_MARIADB) + set(_openads_maria_linked FALSE) + set(OPENADS_LIBMARIADB_INCLUDE "" CACHE PATH "Directory containing mysql.h") + set(OPENADS_LIBMARIADB_LIBRARY "" CACHE FILEPATH "libmariadb import library (.lib/.a)") + if(OPENADS_LIBMARIADB_INCLUDE AND OPENADS_LIBMARIADB_LIBRARY) + set(_openads_maria_inc "${OPENADS_LIBMARIADB_INCLUDE}") + set(_openads_maria_lib "${OPENADS_LIBMARIADB_LIBRARY}") + endif() + if(NOT _openads_maria_lib) + if(DEFINED ENV{OPENADS_LIBMARIADB_LIBRARY}) + set(_openads_maria_lib "$ENV{OPENADS_LIBMARIADB_LIBRARY}") + endif() + if(DEFINED ENV{OPENADS_LIBMARIADB_INCLUDE}) + set(_openads_maria_inc "$ENV{OPENADS_LIBMARIADB_INCLUDE}") + endif() + endif() + if(NOT _openads_maria_lib OR NOT _openads_maria_inc) + if(DEFINED ENV{OPENADS_TOOLCHAIN_ROOT}) + set(_openads_toolchain_root "$ENV{OPENADS_TOOLCHAIN_ROOT}") + elseif(DEFINED OPENADS_TOOLCHAIN_ROOT) + set(_openads_toolchain_root "${OPENADS_TOOLCHAIN_ROOT}") + endif() + if(_openads_toolchain_root) + if(NOT _openads_maria_inc) + find_path(_openads_maria_inc mysql.h + PATHS "${_openads_toolchain_root}/mariadb/include" + NO_DEFAULT_PATH) + endif() + if(NOT _openads_maria_lib) + if(CMAKE_SIZEOF_VOID_P EQUAL 4) + find_library(_openads_maria_lib NAMES libmariadb + PATHS "${_openads_toolchain_root}/mariadb/lib" + NO_DEFAULT_PATH) + else() + find_library(_openads_maria_lib NAMES libmariadb64 libmariadb + PATHS "${_openads_toolchain_root}/mariadb/lib" + NO_DEFAULT_PATH) + endif() + endif() + endif() + endif() + if(_openads_maria_inc AND _openads_maria_lib) + add_library(openads_libmariadb INTERFACE) + target_include_directories(openads_libmariadb INTERFACE "${_openads_maria_inc}") + target_link_libraries(openads_libmariadb INTERFACE "${_openads_maria_lib}") + set(_openads_maria_linked TRUE) + message(STATUS "OpenADS: MariaDB backend (libmariadb: ${_openads_maria_lib})") + endif() + if(NOT _openads_maria_linked) + message(FATAL_ERROR "OPENADS_WITH_MARIADB=ON but libmariadb was not found") + endif() +endif() + if(MSVC) add_compile_options(/W4 /permissive-) add_compile_definitions(_CRT_SECURE_NO_WARNINGS) if(OPENADS_WARNINGS_AS_ERRORS) add_compile_options(/WX) endif() +elseif(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + # MinGW/winlibs: static link toolchain runtime; libpq.dll still loads at runtime. + add_link_options(-static) else() add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) # _CRT_SECURE_NO_WARNINGS: needed when clang-cl targets MSVC CRT on Windows; diff --git a/docs/OPENADS_PLUS.md b/docs/OPENADS_PLUS.md new file mode 100644 index 00000000..8c1c1486 --- /dev/null +++ b/docs/OPENADS_PLUS.md @@ -0,0 +1,39 @@ +# OpenADS Plus — PostgreSQL + +Extensão aditiva do [OpenADS](https://github.com/FiveTechSoft/OpenADS): tabelas PostgreSQL atrás da ABI ACE. DBF e wire inalterados. + +## Deploy rápido + +```bat +set OPENADS_TOOLCHAIN_ROOT= +tools\scripts\build_nmake_postgres.bat +``` + +Saída: `build\pg\src\openace32.dll` — copiar para a pasta do `.exe` Harbour antes de outra `ace*.dll`. + +## Conexão + +`AdsConnect60("postgresql://user:pass@host:5432/dbname")` + +## Teste ponta a ponta + +```bat +set OPENADS_TEST_PG_URI=postgresql://user:pass@127.0.0.1:5432/testdb +build\pg\tests\openads_unit_tests.exe --test-case="*postgresql*" +``` + +O teste cria/derruba a tabela `clientes`, insere 3 linhas e valida navegação + SEEK pela ABI. Sem URI definida, os casos E2E fazem SKIP (CI não quebra). + +## Segurança + +- Nomes de tabela/coluna: só identificadores ASCII seguros (`[A-Za-z0-9_]`). +- Valores de SEEK e chaves: parâmetros preparados (`$1`), nunca concatenados. +- URI montada em runtime no app — sem paths hardcoded no código. + +## Capacidades + +| Recurso | Status | +|---------|--------| +| Read + navegação | Sim | +| SEEK por coluna | Sim | +| Write | Planejado | \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-21-backend-ops-registry.md b/docs/superpowers/plans/2026-06-21-backend-ops-registry.md new file mode 100644 index 00000000..7ba67919 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-backend-ops-registry.md @@ -0,0 +1,613 @@ +# Backend-ops registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the per-backend `#if OPENADS_WITH_X` table dispatch out of the ~17 ABI functions in `ace_exports.cpp` into a single `HandleKind`-keyed ops registry, proven by migrating SQLite with the unit suite staying green. + +**Architecture:** A `BackendTableOps` vtable (function pointers mirroring the ABI table ops) is registered per `HandleKind`. Each ABI function does one `backend_table_ops_for(h)` lookup and dispatches if non-null, else runs the unchanged native/remote (`tcp://`) path. SQLite's inline dispatch bodies are lifted into named static `sqlite_()` functions and exposed as one `BackendTableOps` instance. + +**Tech Stack:** C++17, MSVC `cl` x64, CMake + Ninja, doctest unit suite. + +## Refinement vs spec (flagged) + +The spec (`docs/superpowers/specs/2026-06-21-backend-ops-registry-design.md`) placed the lifted `sqlite_()` functions "in the backend file". During planning, the inline SQLite bodies were found to depend on `ace_exports.cpp`-local helpers (`fail`, `ok`, `get_sqlite_table`, `sqlite_field_index`, `pad_char_field`). For this POC the lifted functions and the `sqlite_table_ops()` accessor therefore live in **a single `#if defined(OPENADS_WITH_SQLITE)` section inside `ace_exports.cpp`**, reusing those helpers. This still removes the per-function `#if` (the merge-conflict surface): the 17 ABI functions become backend-agnostic, and each backend's PR touches only its own ops section + one registration line. Fully relocating the ops into `sql_backend/*.cpp` (exposing the helpers via a header) is a clean follow-up once the mechanism is green. `backend_table_ops.h` and `backend_registry.{h,cpp}` are backend-agnostic and reusable as the spec intends. + +## Global Constraints + +- Behavior-preserving refactor: **no edits to any test file**; the existing + `openads_unit_tests` suite is the regression guard. +- Native local DBF/ADT and native client/server (`tcp://`, `RemoteTable`, + `HandleKind::Table`/`RemoteTable`) MUST remain the unchanged fall-through. They + do NOT register ops. +- No change to the public ACE ABI (exported function signatures unchanged). +- Build/test env: run from a shell with MSVC x64 on PATH (a Visual Studio + x64 Developer Command Prompt); configure with `OPENADS_WITH_SQLITE=ON`. +- Branch: `refactor/backend-ops-registry` (base `973d6f3`). Supervised merge to + the integration line. +- Each commit ends with the `Co-Authored-By: Claude Opus 4.8 (1M context)` trailer. + +--- + +## Baseline build & test commands (used by every task) + +Configure (once; reuse the dir afterward): +``` +cmake -S . -B build\reg-msvc -G Ninja -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ^ + -DOPENADS_WITH_SQLITE=ON -DOPENADS_WITH_HTTP=OFF -DOPENADS_WITH_TLS=OFF ^ + -DOPENADS_WARNINGS_AS_ERRORS=OFF +``` +Build + run suite: +``` +cmake --build build\reg-msvc +build\reg-msvc\tests\openads_unit_tests.exe +``` +Expected suite result throughout: `0 failed` (baseline 528 passed / 2 skipped on +the equivalent ODBC tree; the exact pass count on this SQLite config is recorded +in Task 0 and must not regress). + +--- + +### Task 0: Pin the green baseline + +**Files:** none (measurement only). + +- [ ] **Step 1: Configure** + +Run the configure command above. Expected: `-- Configuring done` / `-- Generating done`, SQLite backend detected. + +- [ ] **Step 2: Build** + +Run: `cmake --build build\reg-msvc` +Expected: builds `openace64.dll` and `tests\openads_unit_tests.exe`, 0 errors. + +- [ ] **Step 3: Run suite and record the number** + +Run: `build\reg-msvc\tests\openads_unit_tests.exe` +Expected: `Status: SUCCESS!`, `0 failed`. Write the exact `test cases: N | N passed` +line into the PR/commit notes — this is the number every later task must match. + +- [ ] **Step 4: Commit the plan/baseline note** (no code yet) + +``` +git add docs/superpowers/plans/2026-06-21-backend-ops-registry.md +git commit -m "docs(plan): backend-ops registry implementation plan" +``` + +--- + +### Task 1: `HandleKind kind_of(Handle)` on the registry + +Lets `backend_table_ops_for` find a handle's kind without knowing the backend type. + +**Files:** +- Modify: `src/session/handle_registry.h` (add method to `HandleRegistry`) +- Test: `tests/unit/handle_registry_kind_of_test.cpp` (new) +- Modify: `tests/CMakeLists.txt` (register the new test source) + +**Interfaces:** +- Produces: `openads::session::HandleKind HandleRegistry::kind_of(Handle h) const;` + — returns the slot's kind, or `HandleKind::None` if `h` is unknown. + +- [ ] **Step 1: Write the failing test** + +```cpp +// tests/unit/handle_registry_kind_of_test.cpp +#include "doctest.h" +#include "session/handle_registry.h" + +using openads::session::HandleRegistry; +using openads::session::HandleKind; + +TEST_CASE("kind_of returns the registered kind, None for unknown") { + HandleRegistry reg; + int dummy = 0; + auto h = reg.register_object(HandleKind::SqliteTable, &dummy); + CHECK(reg.kind_of(h) == HandleKind::SqliteTable); + CHECK(reg.kind_of(h + 999) == HandleKind::None); + reg.release(h); + CHECK(reg.kind_of(h) == HandleKind::None); +} +``` + +- [ ] **Step 2: Add the test to the build** + +In `tests/CMakeLists.txt`, add `unit/handle_registry_kind_of_test.cpp` to the +`openads_unit_tests` source list (follow the existing pattern of the adjacent +`unit/*_test.cpp` entries). + +- [ ] **Step 3: Run to verify it fails** + +Run: `cmake --build build\reg-msvc && build\reg-msvc\tests\openads_unit_tests.exe -tc="kind_of*"` +Expected: FAIL to compile — `kind_of` not a member of `HandleRegistry`. + +- [ ] **Step 4: Implement `kind_of`** + +In `src/session/handle_registry.h`, inside `class HandleRegistry`, after `for_each_handle`: +```cpp + HandleKind kind_of(Handle h) const { + std::lock_guard lk(mu_); + auto it = slots_.find(h); + return it == slots_.end() ? HandleKind::None : it->second.kind; + } +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cmake --build build\reg-msvc && build\reg-msvc\tests\openads_unit_tests.exe -tc="kind_of*"` +Expected: PASS. + +- [ ] **Step 6: Full suite still green** + +Run: `build\reg-msvc\tests\openads_unit_tests.exe` +Expected: `0 failed`, same total as Task 0 + 1 new case. + +- [ ] **Step 7: Commit** + +``` +git add src/session/handle_registry.h tests/unit/handle_registry_kind_of_test.cpp tests/CMakeLists.txt +git commit -m "feat(session): HandleRegistry::kind_of for backend dispatch" +``` + +--- + +### Task 2: Backend-ops vtable + registry + +**Files:** +- Create: `src/abi/backend_table_ops.h` +- Create: `src/abi/backend_registry.h` +- Create: `src/abi/backend_registry.cpp` +- Modify: `src/CMakeLists.txt` (add `abi/backend_registry.cpp` to `openads_core` sources) +- Test: `tests/unit/backend_registry_test.cpp` (new) + `tests/CMakeLists.txt` + +**Interfaces:** +- Produces (`backend_table_ops.h`): + ```cpp + namespace openads::abi { + struct BackendTableOps { + UNSIGNED32 (*close_table) (ADSHANDLE); + UNSIGNED32 (*goto_top) (ADSHANDLE); + UNSIGNED32 (*goto_bottom) (ADSHANDLE); + UNSIGNED32 (*skip) (ADSHANDLE, SIGNED32); + UNSIGNED32 (*at_eof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*at_bof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*num_fields) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*field_name) (ADSHANDLE, UNSIGNED16, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_type) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_length) (ADSHANDLE, UNSIGNED8*, UNSIGNED32*); + UNSIGNED32 (*field_decimals) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*record_num) (ADSHANDLE, UNSIGNED32*); + UNSIGNED32 (*record_count) (ADSHANDLE, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*get_field) (ADSHANDLE, UNSIGNED8*, UNSIGNED8*, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*is_record_deleted)(ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*open_index) (ADSHANDLE, UNSIGNED8*, ADSHANDLE*, UNSIGNED16*); + UNSIGNED32 (*is_found) (ADSHANDLE, UNSIGNED16*); + }; + } // namespace openads::abi + ``` + (The exact `ACE.h` typedefs `ADSHANDLE/UNSIGNED32/SIGNED32/UNSIGNED16/UNSIGNED8` + are reached by `#include "ace.h"`; copy the include path used at the top of + `ace_exports.cpp`.) +- Produces (`backend_registry.h`): + ```cpp + namespace openads::abi { + void register_backend_table_ops(openads::session::HandleKind kind, + const BackendTableOps* ops); + const BackendTableOps* backend_table_ops_for(ADSHANDLE h); + void register_builtin_backends(); // defined in ace_exports.cpp + } + ``` + +- [ ] **Step 1: Write the failing test** + +```cpp +// tests/unit/backend_registry_test.cpp +#include "doctest.h" +#include "abi/backend_table_ops.h" +#include "abi/backend_registry.h" +#include "session/handle_registry.h" + +using namespace openads::abi; +using openads::session::HandleKind; + +static UNSIGNED32 fake_goto_top(ADSHANDLE) { return 4242; } + +TEST_CASE("registry returns ops for a registered kind, null otherwise") { + static const BackendTableOps ops = [] { + BackendTableOps o{}; + o.goto_top = &fake_goto_top; + return o; + }(); + register_backend_table_ops(HandleKind::SqliteTable, &ops); + // Native kinds never registered -> a Table handle resolves to null. + // (Uses the process-wide session registry; see note below.) + CHECK(backend_table_ops_for(/*unknown handle*/ 0) == nullptr); +} +``` + +Note: `backend_table_ops_for` reads the process `state().registry`. The test +above only asserts the null path for an unknown handle (kind `None`); the +positive end-to-end path is covered by the existing suite once SQLite is wired +(Task 4). Keep this unit test minimal and dependency-free. + +- [ ] **Step 2: Add test to `tests/CMakeLists.txt`** (same pattern as Task 1). + +- [ ] **Step 3: Create `src/abi/backend_table_ops.h`** with the struct above. + +- [ ] **Step 4: Create `src/abi/backend_registry.h`** with the declarations above. + +- [ ] **Step 5: Create `src/abi/backend_registry.cpp`** + +```cpp +#include "abi/backend_registry.h" +#include "abi/backend_table_ops.h" +#include "session/handle_registry.h" +#include + +namespace openads::abi { + +namespace { +// Small fixed table indexed by HandleKind (enum is dense, max value 11). +std::array& ops_table() { + static std::array t{}; // all nullptr + return t; +} +} // namespace + +void register_backend_table_ops(openads::session::HandleKind kind, + const BackendTableOps* ops) { + auto idx = static_cast(kind); + if (idx < ops_table().size()) ops_table()[idx] = ops; +} + +const BackendTableOps* ops_for_kind(openads::session::HandleKind kind) { + auto idx = static_cast(kind); + return idx < ops_table().size() ? ops_table()[idx] : nullptr; +} + +} // namespace openads::abi +``` + +The `backend_table_ops_for(ADSHANDLE)` definition lives in `ace_exports.cpp` +(Task 3) because it needs `state()`; it calls `ops_for_kind`. Declare +`ops_for_kind` in `backend_registry.h` too. + +- [ ] **Step 6: Build + run new test fails-then-passes** + +Run: `cmake --build build\reg-msvc && build\reg-msvc\tests\openads_unit_tests.exe -tc="registry returns*"` +Expected: PASS (null path). If link error on `backend_table_ops_for`, it is +defined in Task 3 — temporarily stub it in the test or order Task 3 first; see +Step 7. + +- [ ] **Step 7: Ordering note** + +`backend_table_ops_for` is defined in Task 3. If building Task 2 alone fails to +link, do Tasks 2 and 3 as one commit (they form one compilable unit). Prefer +keeping the registry test asserting only `ops_for_kind` if `backend_table_ops_for` +is not yet linkable: +```cpp +CHECK(ops_for_kind(HandleKind::Table) == nullptr); +CHECK(ops_for_kind(HandleKind::SqliteTable) == &ops); +``` + +- [ ] **Step 8: Commit** + +``` +git add src/abi/backend_table_ops.h src/abi/backend_registry.h src/abi/backend_registry.cpp src/CMakeLists.txt tests/unit/backend_registry_test.cpp tests/CMakeLists.txt +git commit -m "feat(abi): backend-ops vtable + HandleKind-keyed registry" +``` + +--- + +### Task 3: SQLite ops section + registration + dispatcher + +Add the lifted SQLite ops and the once-init dispatcher. The 17 ABI functions are +NOT touched yet — so behavior is unchanged and the suite stays green. + +**Files:** +- Modify: `src/abi/ace_exports.cpp` (add a new `#if defined(OPENADS_WITH_SQLITE)` + ops section near the existing SQLite helpers ~line 341; add + `backend_table_ops_for` + `register_builtin_backends` in the anonymous namespace) + +**Interfaces:** +- Consumes: `register_backend_table_ops`, `ops_for_kind` (Task 2), + `state()`, `get_sqlite_table`, `sqlite_field_index`, `fail`, `ok`, + `pad_char_field`, `openads::abi::to_internal`, `openads::abi::copy_to_caller` + (all existing in `ace_exports.cpp`). +- Produces: `const BackendTableOps* backend_table_ops_for(ADSHANDLE)` and a + registered `SqliteTable` ops instance. + +- [ ] **Step 1: Add the dispatcher + once-init** (anonymous namespace, after the + includes of `backend_registry.h`/`backend_table_ops.h`) + +```cpp +const openads::abi::BackendTableOps* backend_table_ops_for(ADSHANDLE h) { + static const bool _ = (openads::abi::register_builtin_backends(), true); + (void)_; + return openads::abi::ops_for_kind(state().registry.kind_of(h)); +} +``` + +- [ ] **Step 2: Add the lifted SQLite ops** in a single + `#if defined(OPENADS_WITH_SQLITE)` block. Each function is the body currently + inline in the matching ABI function, with `hTable` as its `ADSHANDLE` param. + Full text for the three body-shapes; the rest are identical-shaped per the + table after. + +Nav shape (goto_top/goto_bottom/skip): +```cpp +UNSIGNED32 sqlite_goto_top(ADSHANDLE hTable) { + auto* st = get_sqlite_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(); +} +UNSIGNED32 sqlite_goto_bottom(ADSHANDLE hTable) { + auto* st = get_sqlite_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(); +} +UNSIGNED32 sqlite_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_sqlite_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(); +} +``` + +get_field shape (the value-reading body from lines 2755-2773): +```cpp +UNSIGNED32 sqlite_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_sqlite_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 = sqlite_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(); +} +``` + +Metadata/predicate shape (at_eof shown; at_bof, num_fields, field_name, +field_type, field_length, field_decimals, record_num, record_count, +is_record_deleted, open_index, is_found follow the same lift — copy each one's +existing inline SQLite body verbatim, parameter-for-parameter): +```cpp +UNSIGNED32 sqlite_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_sqlite_table(hTable); + /* */ +} +``` + +The 17 functions and their lifted names (each body copied verbatim from the +named ABI function's current inline SQLite block): + +| Lifted fn | From ABI fn | Source lines | +|---|---|---| +| `sqlite_close_table` | `AdsCloseTable` | ~2050 | +| `sqlite_goto_top` | `AdsGotoTop` | 2102-2109 | +| `sqlite_goto_bottom` | `AdsGotoBottom` | 2126-2133 | +| `sqlite_skip` | `AdsSkip` | 2165-2172 | +| `sqlite_at_eof` | `AdsAtEOF` | ~2191 | +| `sqlite_at_bof` | `AdsAtBOF` | ~2217 | +| `sqlite_num_fields` | `AdsGetNumFields` | ~2246 | +| `sqlite_field_name` | `AdsGetFieldName` | ~2285 | +| `sqlite_field_type` | `AdsGetFieldType` | ~2382 | +| `sqlite_field_length` | `AdsGetFieldLength` | ~2413 | +| `sqlite_field_decimals` | `AdsGetFieldDecimals` | ~2465 | +| `sqlite_record_num` | `AdsGetRecordNum` | ~2632 | +| `sqlite_record_count` | `AdsGetRecordCount` | ~2667 | +| `sqlite_get_field` | `AdsGetField` | 2755-2773 | +| `sqlite_is_record_deleted`| `AdsIsRecordDeleted` | ~3546 | +| `sqlite_open_index` | `AdsOpenIndex` | ~4265 | +| `sqlite_is_found` | `AdsIsFound` | ~6705 | + +- [ ] **Step 3: Add the ops instance + `register_builtin_backends`** + +Still inside the `#if defined(OPENADS_WITH_SQLITE)` block: +```cpp +const openads::abi::BackendTableOps* sqlite_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &sqlite_close_table; + o.goto_top = &sqlite_goto_top; + o.goto_bottom = &sqlite_goto_bottom; + o.skip = &sqlite_skip; + o.at_eof = &sqlite_at_eof; + o.at_bof = &sqlite_at_bof; + o.num_fields = &sqlite_num_fields; + o.field_name = &sqlite_field_name; + o.field_type = &sqlite_field_type; + o.field_length = &sqlite_field_length; + o.field_decimals = &sqlite_field_decimals; + o.record_num = &sqlite_record_num; + o.record_count = &sqlite_record_count; + o.get_field = &sqlite_get_field; + o.is_record_deleted = &sqlite_is_record_deleted; + o.open_index = &sqlite_open_index; + o.is_found = &sqlite_is_found; + return o; + }(); + return &ops; +} +``` +Then, OUTSIDE the `#if` (so it always exists), in the anonymous namespace: +```cpp +} // namespace (close anon ns if needed, or keep within) +namespace openads::abi { +void register_builtin_backends() { +#if defined(OPENADS_WITH_SQLITE) + register_backend_table_ops(openads::session::HandleKind::SqliteTable, + sqlite_table_ops()); +#endif +} +} +``` +(Place `register_builtin_backends` where `sqlite_table_ops` is visible; if the +ops live in the anonymous namespace, define `register_builtin_backends` in the +same TU after them.) + +- [ ] **Step 4: Add includes** at the top of `ace_exports.cpp` (near other abi + includes): `#include "abi/backend_table_ops.h"` and + `#include "abi/backend_registry.h"`. + +- [ ] **Step 5: Build** + +Run: `cmake --build build\reg-msvc` +Expected: 0 errors. (Ops are defined but not yet called by the 17 functions.) + +- [ ] **Step 6: Full suite green (behavior unchanged)** + +Run: `build\reg-msvc\tests\openads_unit_tests.exe` +Expected: `0 failed`, same total as Task 1. + +- [ ] **Step 7: Commit** + +``` +git add src/abi/ace_exports.cpp src/abi/backend_registry.h +git commit -m "feat(abi): lifted SQLite table ops + registry dispatcher (not yet wired)" +``` + +--- + +### Task 4: Wire the 17 ABI functions to the registry + +Replace each inline `#if OPENADS_WITH_SQLITE { if (get_sqlite_table) {...} }` +block with a single registry lookup. Do it in three commits (nav, fields, misc) +so a reviewer can reject one batch independently; the suite must be green after +each. + +**Files:** Modify `src/abi/ace_exports.cpp` (the 17 functions). + +**Replacement pattern** (example: `AdsGotoTop`): delete lines 2101-2110 (the +`#if … #endif` SQLite block) and insert, right after the `get_remote_table` +block: +```cpp + if (auto* ops = backend_table_ops_for(hTable)) + if (ops->goto_top) return ops->goto_top(hTable); +``` +The native `Table* t = get_table(hTable); …` tail is left exactly as-is. + +- [ ] **Step 1: Batch A — nav + close (4 fns)** + +Wire `AdsCloseTable`(→`close_table`), `AdsGotoTop`(→`goto_top`), +`AdsGotoBottom`(→`goto_bottom`), `AdsSkip`(→`skip(hTable, lRows)`). Each: remove +the inline `#if` block, insert the two-line lookup using the matching op and its +exact args. + +- [ ] **Step 2: Build + suite green** + +Run: `cmake --build build\reg-msvc && build\reg-msvc\tests\openads_unit_tests.exe` +Expected: `0 failed`, same total. + +- [ ] **Step 3: Commit** + +``` +git add src/abi/ace_exports.cpp +git commit -m "refactor(abi): route SQLite nav ops through the registry" +``` + +- [ ] **Step 4: Batch B — field accessors (8 fns)** + +Wire `AdsAtEOF`(→`at_eof(hTable,pbAtEnd)`), `AdsAtBOF`(→`at_bof`), +`AdsGetNumFields`(→`num_fields`), `AdsGetFieldName`(→`field_name` with its 4 +args), `AdsGetFieldType`(→`field_type`), `AdsGetFieldLength`(→`field_length`), +`AdsGetFieldDecimals`(→`field_decimals`), `AdsGetField`(→`get_field` with its 5 +args). Use each op's exact parameter list from the vtable. + +- [ ] **Step 5: Build + suite green** (same command). Expected: `0 failed`. + +- [ ] **Step 6: Commit** + +``` +git add src/abi/ace_exports.cpp +git commit -m "refactor(abi): route SQLite field accessors through the registry" +``` + +- [ ] **Step 7: Batch C — record/index/found (3 fns + remaining)** + +Wire `AdsGetRecordNum`(→`record_num`), `AdsGetRecordCount`(→`record_count` with +its filter arg), `AdsIsRecordDeleted`(→`is_record_deleted`), +`AdsOpenIndex`(→`open_index` with its 4 args), `AdsIsFound`(→`is_found`). + +- [ ] **Step 8: Build + suite green** (same command). Expected: `0 failed`. + +- [ ] **Step 9: Verify zero remaining inline SQLite table dispatch** + +Run: `git grep -n "get_sqlite_table(hTable)" src/abi/ace_exports.cpp` +Expected: only the matches inside the lifted `sqlite_()` functions (Task 3), +none inside `Ads*` ABI functions. + +- [ ] **Step 10: Commit** + +``` +git add src/abi/ace_exports.cpp +git commit -m "refactor(abi): route SQLite record/index ops through the registry" +``` + +--- + +### Task 5: Final verification + reviewer gate + +**Files:** none (verification). + +- [ ] **Step 1: Clean rebuild** + +Run: `cmake --build build\reg-msvc --clean-first` +Expected: 0 errors, 0 warnings introduced by the change. + +- [ ] **Step 2: Full suite** + +Run: `build\reg-msvc\tests\openads_unit_tests.exe` +Expected: `Status: SUCCESS!`, `0 failed`, total = Task 0 baseline + 2 new unit +cases (Tasks 1 and 2). + +- [ ] **Step 3: Diff sanity — native path untouched** + +Run: `git diff 973d6f3 -- src/abi/ace_exports.cpp | findstr /C:"get_table(" /C:"get_remote_table("` +Expected: no native/remote dispatch lines were modified (only SQLite `#if` +blocks were removed and the two-line lookups added). + +- [ ] **Step 4: Request supervised review** (regra 7) before merging to the + integration line. Summarize: files changed, suite number before/after, + confirmation native/remote dispatch unchanged. + +--- + +## Self-Review + +**Spec coverage:** mechanism (`backend_table_ops.h` + `backend_registry.*`) → +Task 2; `HandleKind` lookup → Task 1 + Task 3 dispatcher; lifted SQLite ops + +registration + once-init → Task 3; 17 ABI functions wired → Task 4; native +fall-through untouched → constraint enforced + verified Task 5 Step 3; +behavior-preserving via unchanged suite → every task's green gate. Covered. + +**Placeholder scan:** the only intentionally-templated steps are the 14 +identical-shape lifts in Task 3 (Step 2), each pinned to an exact source ABI +function + line range with two fully-worked shape exemplars (nav, get_field) and +one metadata exemplar — an implementer copies the named inline block verbatim. No +TBD/TODO. + +**Type consistency:** `BackendTableOps` member names and signatures in Task 2 are +reused verbatim in the `sqlite_table_ops()` wiring (Task 3) and the Task 4 +call-sites; `kind_of`/`ops_for_kind`/`backend_table_ops_for`/ +`register_builtin_backends`/`register_backend_table_ops` names are consistent +across Tasks 1-4. diff --git a/docs/superpowers/specs/2026-06-21-backend-ops-registry-design.md b/docs/superpowers/specs/2026-06-21-backend-ops-registry-design.md new file mode 100644 index 00000000..97da757c --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-backend-ops-registry-design.md @@ -0,0 +1,187 @@ +# Design — Pluggable backend-ops registry for `ace_exports.cpp` + +**Date:** 2026-06-21 +**Base:** `973d6f3` (`pr/openads-plus-nav-setdouble-remote` = upstream + sqlite + +sql-passthrough + AdsSetDouble/AdsSetLogical remote fixes) +**Work branch:** `refactor/backend-ops-registry` + +## Problem + +Each SQL backend (sqlite, sqlcipher, postgres, mariadb, odbc, and the future +Firebird/MSSQL/Oracle + Grok's ODBC-universal line) adds its table-level dispatch +as an inline `#if defined(OPENADS_WITH_X) { if (auto* st = get_X_table(h)) {...} }` +block inside **every** ABI function that needs it. On the current base, SQLite +alone has **17** such call-sites: + +`AdsCloseTable`, `AdsGotoTop`, `AdsGotoBottom`, `AdsSkip`, `AdsAtEOF`, +`AdsAtBOF`, `AdsGetNumFields`, `AdsGetFieldName`, `AdsGetFieldType`, +`AdsGetFieldLength`, `AdsGetFieldDecimals`, `AdsGetRecordNum`, +`AdsGetRecordCount`, `AdsGetField`, `AdsIsRecordDeleted`, `AdsOpenIndex`, +`AdsIsFound`. + +Adding N backends multiplies that: 17 functions × N backends ≈ the ~100 merge +hunks observed in `ace_exports.cpp` when combining the per-backend PR branches. +A naive `union` git-merge corrupts the file (duplicated blocks → C2371, +unbalanced `#if/#endif` → C1070). The per-function `#if` ladder is the root +cause. + +## Goal + +Move the per-backend `#if OPENADS_WITH_X` **out** of the ~17 ABI functions and +into a single registration point, so: + +- Each ABI function dispatches through **one** backend-agnostic lookup. +- A new backend = fill one struct + add one registration line. Near-zero + conflict surface in `ace_exports.cpp`. +- True pluggability for Firebird/MSSQL/Oracle and Grok's ODBC-universal line. + +## Non-goals / hard constraints + +- **The native path is first-class and untouched.** Local DBF/ADT and the native + client/server protocol (`tcp://`, `RemoteTable`) do **not** register ops; they + remain the unchanged fall-through in every function. Native works in both + client/server and enterprise modes; nothing is forced — the caller chooses the + connection mode by URI. (Owner's north-star: coexistence, no forcing.) +- This is a **behavior-preserving refactor**, not a feature change. The existing + unit suite must stay green with no test edits. +- No change to the public ACE ABI surface. + +## Architecture + +Three new, isolated units: + +### 1. `src/abi/backend_table_ops.h` +Defines the well-defined boundary a backend implements: + +```cpp +struct BackendTableOps { + UNSIGNED32 (*close_table) (ADSHANDLE); + UNSIGNED32 (*goto_top) (ADSHANDLE); + UNSIGNED32 (*goto_bottom) (ADSHANDLE); + UNSIGNED32 (*skip) (ADSHANDLE, SIGNED32); + UNSIGNED32 (*at_eof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*at_bof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*num_fields) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*field_name) (ADSHANDLE, UNSIGNED16, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_type) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_length) (ADSHANDLE, UNSIGNED8*, UNSIGNED32*); + UNSIGNED32 (*field_decimals) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*record_num) (ADSHANDLE, UNSIGNED32*); + UNSIGNED32 (*record_count) (ADSHANDLE, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*get_field) (ADSHANDLE, UNSIGNED8*, UNSIGNED8*, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*is_record_deleted)(ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*open_index) (ADSHANDLE, UNSIGNED8*, ADSHANDLE*, UNSIGNED16*); + UNSIGNED32 (*is_found) (ADSHANDLE, UNSIGNED16*); +}; +``` +Signatures mirror the corresponding ABI functions exactly. Each function pointer +may be left `nullptr` if a backend doesn't support that op (the dispatcher then +falls through / returns the same error the inline code does today). The exact +final argument lists are copied verbatim from the current ABI signatures during +implementation. + +### 2. `src/abi/backend_registry.{h,cpp}` +```cpp +void register_backend_table_ops(HandleKind table_kind, const BackendTableOps* ops); +const BackendTableOps* backend_table_ops_for(ADSHANDLE h); // nullptr if not a registered backend +void register_builtin_backends(); // the ONLY place with #if OPENADS_WITH_X +``` +- The registry is a small map `HandleKind -> const BackendTableOps*` (≤ a handful + of entries; a fixed array indexed by kind or an `unordered_map`). +- `backend_table_ops_for(h)` reads the handle's `HandleKind` from the session + registry and returns the registered ops, or `nullptr` for native/remote kinds + (`Table`, `RemoteTable`) → fall-through. +- `register_builtin_backends()` contains, e.g.: + ```cpp + #if defined(OPENADS_WITH_SQLITE) + register_backend_table_ops(HandleKind::SqliteTable, sqlite_table_ops()); + #endif + // (postgres, mariadb, odbc … added here later — one line each) + ``` + +### 3. Per-backend ops accessor +Each backend file exposes `const BackendTableOps* sqlite_table_ops();` returning a +pointer to a file-local `static const BackendTableOps` wired to that backend's +existing logic (`get_sqlite_table` + the bodies currently inline in +`ace_exports.cpp`, lifted into thin functions). The `#if OPENADS_WITH_SQLITE` +lives in the backend file and the one registration line — never again in the ABI +functions. + +## Dispatch transformation + +Each of the 17 ABI functions changes from: +```cpp +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { /* sqlite body */ return rc; } +#endif + /* remote (tcp://) + native body — unchanged */ +``` +to: +```cpp + if (auto* ops = backend_table_ops_for(hTable)) + if (ops->goto_top) return ops->goto_top(hTable); + /* remote (tcp://) + native body — unchanged */ +``` +The sqlite body moves verbatim into `sqlite_()` in the backend file. + +## Initialization + +No `DllMain` exists. `register_builtin_backends()` runs once via a function-local +static inside `backend_table_ops_for`: +```cpp +const BackendTableOps* backend_table_ops_for(ADSHANDLE h) { + static const bool _ = (register_builtin_backends(), true); (void)_; + ... +} +``` +Function-local statics are thread-safe-once (C++11) and **always run** because +the enclosing function is called by the ABI layer — unlike a namespace-scope +static in the `openads_core` STATIC lib, which the MSVC linker can strip when +building the `openace64` SHARED DLL. This is why registration is **explicit**, +not self-registration. + +## Coexistence (native + enterprise, by URI) + +Native local DBF and native client/server (`tcp://`) handles use `HandleKind` +values that are **not** registered → `backend_table_ops_for` returns `nullptr` → +the original native/remote code runs untouched. SQL/ODBC backends are reached +only when the caller opened that kind of connection (chosen by URI). Native and +enterprise modes coexist; neither is forced. + +## Scope (this session) + +1. Add the three units (header, registry, sqlite ops accessor). +2. Lift the 17 SQLite dispatch bodies into `sqlite_()` functions; replace the + 17 inline `#if` blocks with the single registry lookup. +3. Build the SQLite configuration and run the existing unit suite. + +**pg / maria / odbc / sqlcipher and future Firebird/MSSQL/Oracle are out of scope +here** — they follow later by adding their ops accessor + one registration line. + +## Testing + +- The existing unit suite already exercises the SQLite dispatch paths + (`AdsGetField`, `AdsGotoTop`, etc. over SqliteTable handles). After the + refactor it must stay **green with zero test edits** — that is the + behavior-preserving proof. Baseline before the refactor: 528/528 (44,666 + assertions) on the ODBC build of an equivalent tree. +- Build the SQLite config (`OPENADS_WITH_SQLITE=ON`), run + `build\\tests\openads_unit_tests.exe`; require 0 failed. + +## Risks & mitigations + +- **Touching core dispatch.** Mitigated: only the SQL branch is *lifted*; the + native/remote branch is copied unchanged. The suite is the guard-rail (green + before and after). Supervised merge per regra 7. +- **Linker stripping the registration.** Mitigated by explicit registration via a + function-local static in a called function (see Initialization). +- **Signature drift** when copying ABI arg lists into the ops struct. Mitigated by + copying verbatim from the current declarations during implementation; the + compiler enforces an exact match at the assignment of each function pointer. + +## Future (out of scope, enabled by this) + +Adding a backend becomes: implement `BackendTableOps` for it, expose +`X_table_ops()`, add one `#if`-guarded line to `register_builtin_backends()`. No +edits to the 17 ABI functions → the ~100-hunk merge problem is gone. Grok's +ODBC-universal line and Firebird/MSSQL/Oracle slot in the same way. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 63afc757..b6af5ed5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(openads_core STATIC abi/ace_stubs.cpp abi/last_error.cpp abi/charset.cpp + abi/backend_registry.cpp session/handle_registry.cpp session/connection.cpp engine/table.cpp @@ -57,10 +58,35 @@ add_library(openads_core STATIC mgmt/mg_collector.cpp network/mg_wire.cpp sql_backend/uri.cpp + sql_backend/sql_common.cpp sql_backend/sqlite_connection.cpp sql_backend/sqlite_backend.cpp ) +if(OPENADS_WITH_POSTGRESQL) + target_sources(openads_core PRIVATE + sql_backend/postgres_uri.cpp + sql_backend/postgres_backend.cpp + sql_backend/postgres_connection.cpp + ) +endif() + +if(OPENADS_WITH_MARIADB) + target_sources(openads_core PRIVATE + sql_backend/maria_uri.cpp + sql_backend/maria_backend.cpp + sql_backend/maria_connection.cpp + ) +endif() + +if(OPENADS_WITH_ODBC) + target_sources(openads_core PRIVATE + 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 @@ -106,6 +132,25 @@ if(OPENADS_WITH_SQLITE) target_link_libraries(openads_core PRIVATE openads_sqlite3) endif() +if(OPENADS_WITH_POSTGRESQL) + target_compile_definitions(openads_core PUBLIC OPENADS_WITH_POSTGRESQL=1) + target_link_libraries(openads_core PUBLIC openads_libpq) +endif() + +if(OPENADS_WITH_MARIADB) + target_compile_definitions(openads_core PUBLIC OPENADS_WITH_MARIADB=1) + target_link_libraries(openads_core PUBLIC openads_libmariadb) +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 4a80734a..2fe2088c 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -3,6 +3,8 @@ #include +#include "abi/backend_table_ops.h" +#include "abi/backend_registry.h" #include "abi/charset.h" #include "abi/last_error.h" @@ -29,6 +31,21 @@ #include "sql_backend/sqlite_index.h" #include "sql_backend/uri.h" #endif +#if defined(OPENADS_WITH_POSTGRESQL) +#include "sql_backend/postgres_connection.h" +#include "sql_backend/postgres_index.h" +#include "sql_backend/postgres_uri.h" +#endif +#if defined(OPENADS_WITH_MARIADB) +#include "sql_backend/maria_connection.h" +#include "sql_backend/maria_index.h" +#include "sql_backend/maria_uri.h" +#endif +#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 "drivers/dbf_common.h" #include "drivers/index_trait.h" #include "drivers/ntx/ntx_index.h" @@ -320,93 +337,1269 @@ std::size_t sqlite_field_index(openads::sql_backend::SqliteTable* st, return std::numeric_limits::max(); } } + if (pucField == nullptr) { + 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_SQLITE + +#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 + +#if defined(OPENADS_WITH_MARIADB) +std::unordered_map>& +maria_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +maria_tables_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::MariaTable* get_maria_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MariaTable); +} + +std::unordered_map>& +maria_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::MariaIndex* get_maria_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MariaIndex); +} + +std::size_t maria_field_index(openads::sql_backend::MariaTable* 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(); + } + } + if (pucField == nullptr) { + 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_MARIADB + +#if defined(OPENADS_WITH_POSTGRESQL) +std::unordered_map>& +postgres_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +postgres_tables_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::PostgresTable* get_postgres_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::PostgresTable); +} + +std::unordered_map>& +postgres_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::PostgresIndex* get_postgres_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::PostgresIndex); +} + +std::size_t postgres_field_index(openads::sql_backend::PostgresTable* 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; + 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_POSTGRESQL + +// M12.16 — same dispatch helper for remote-index handles. Returns +// nullptr when `h` is a local IIndex / Connection / unknown. +openads::network::RemoteIndex* get_remote_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::RemoteIndex); +} + +// 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. +bool& seek_last_retry_latch() { + static thread_local bool v = false; + return v; +} + +// Harbour rddads' default connection handle is 0 when the caller +// never AdsConnect'd. SAP-ACE in this mode auto-connects against the +// current working directory; mirror that by lazily creating one +// process-wide Connection rooted at fs::current_path. Returned handle +// is cached so subsequent calls reuse the same Connection. +ADSHANDLE get_or_create_default_connection() { + static ADSHANDLE cached = 0; + auto& s = state(); + if (cached != 0) { + if (s.registry.lookup(cached, HandleKind::Connection)) + return cached; + cached = 0; + } + namespace fs = std::filesystem; + auto opened = Connection::open(fs::current_path().string()); + if (!opened) return 0; + auto holder = std::make_unique(std::move(opened).value()); + Connection* raw = holder.get(); + Handle h = s.registry.register_object(HandleKind::Connection, raw); + s.conns.emplace(h, std::move(holder)); + cached = h; + return h; +} + +Table* get_table(ADSHANDLE h) { + auto& s = state(); + Table* t = s.registry.lookup(h, HandleKind::Table); + if (t != nullptr) return t; + // Real ACE accepts an index handle anywhere a table handle is + // expected — rddads' adsGoTop calls AdsGotoTop(hOrdCurrent) when + // an order is active. The bound Table is the same as the table's + // own; we additionally swap the binding's parked IIndex into the + // Table's active order so navigation actually walks the requested + // tag (multi-tag CDX support, M8.9). + Table* via_idx = lookup_table_by_index(h); + if (via_idx != nullptr) { + (void)activate_binding(h); + } + return via_idx; +} + +// DBF/xbase CHARACTER fields are fixed-width space-padded. The internal +// decode path (make_string / decode_field) trims trailing spaces because +// the SQL engine, index keys, and AOF filters need trimmed values. On the +// way out to an ABI caller, re-pad to the declared field width so that +// FieldGet of a C(20) field always returns exactly 20 characters — the +// behaviour expected by rddads, Clipper, and X# (Pritpal's xbrowse bug). +// Never truncates: a value already at or above width is returned as-is. +std::string pad_char_field(std::string s, std::size_t width) { + if (s.size() < width) + s.append(width - s.size(), ' '); + return s; +} + +// --------------------------------------------------------------------------- +// Task 3: Lifted SQLite table ops + accessor +// Placed after pad_char_field (sqlite_get_field uses it). +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_SQLITE) + +UNSIGNED32 sqlite_close_table(ADSHANDLE hTable) { + auto* st = get_sqlite_table(hTable); + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + sqlite_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); +} + +UNSIGNED32 sqlite_goto_top(ADSHANDLE hTable) { + auto* st = get_sqlite_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(); +} + +UNSIGNED32 sqlite_goto_bottom(ADSHANDLE hTable) { + auto* st = get_sqlite_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(); +} + +UNSIGNED32 sqlite_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_sqlite_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(); +} + +UNSIGNED32 sqlite_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_sqlite_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(); +} + +UNSIGNED32 sqlite_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { + auto* st = get_sqlite_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()); + *pbAtBof = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 sqlite_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + auto* st = get_sqlite_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()); + } + *pusCnt = static_cast(st->fields.size()); + return ok(); +} + +UNSIGNED32 sqlite_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + auto* st = get_sqlite_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 (n == 0 || n > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, st->fields[n - 1].name); + return ok(); +} + +UNSIGNED32 sqlite_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + auto* st = get_sqlite_table(hTable); + auto i = sqlite_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = st->fields[i].type; + return ok(); +} + +UNSIGNED32 sqlite_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + auto* st = get_sqlite_table(hTable); + auto i = sqlite_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulLen = st->fields[i].length; + return ok(); +} + +UNSIGNED32 sqlite_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + auto* st = get_sqlite_table(hTable); + auto i = sqlite_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusDec = st->fields[i].decimals; + return ok(); +} + +UNSIGNED32 sqlite_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_sqlite_table(hTable); + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRec = static_cast(st->current_rowid); + return ok(); +} + +UNSIGNED32 sqlite_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_sqlite_table(hTable); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (st->rec_count_cached) { + *pulCount = 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; + *pulCount = st->cached_rec_count; + return ok(); +} + +UNSIGNED32 sqlite_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_sqlite_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 = sqlite_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(); +} + +UNSIGNED32 sqlite_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_sqlite_table(hTable); + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); +} + +UNSIGNED32 sqlite_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + auto* st = get_sqlite_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::SqliteIndex, si.get()); + ahIndex[0] = gh; + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + sqlite_indexes_map().emplace(gh, std::move(si)); + return ok(); +} + +UNSIGNED32 sqlite_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_sqlite_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +const openads::abi::BackendTableOps* sqlite_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &sqlite_close_table; + o.goto_top = &sqlite_goto_top; + o.goto_bottom = &sqlite_goto_bottom; + o.skip = &sqlite_skip; + o.at_eof = &sqlite_at_eof; + o.at_bof = &sqlite_at_bof; + o.num_fields = &sqlite_num_fields; + o.field_name = &sqlite_field_name; + o.field_type = &sqlite_field_type; + o.field_length = &sqlite_field_length; + o.field_decimals = &sqlite_field_decimals; + o.record_num = &sqlite_record_num; + o.record_count = &sqlite_record_count; + o.get_field = &sqlite_get_field; + o.is_record_deleted = &sqlite_is_record_deleted; + o.open_index = &sqlite_open_index; + o.is_found = &sqlite_is_found; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_SQLITE (lifted ops) + +// --------------------------------------------------------------------------- +// Lifted ODBC table ops + accessor (mirrors the SQLite lift; bodies are +// the per-function inline ODBC dispatch moved verbatim behind the +// registry so the 17 ABI functions stay backend-agnostic). +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_ODBC) + +UNSIGNED32 odbc_close_table(ADSHANDLE hTable) { + 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(); +} + +UNSIGNED32 odbc_goto_top(ADSHANDLE hTable) { + 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(); +} + +UNSIGNED32 odbc_goto_bottom(ADSHANDLE hTable) { + 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(); +} + +UNSIGNED32 odbc_skip(ADSHANDLE hTable, SIGNED32 lRows) { + 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(); +} + +UNSIGNED32 odbc_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + 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(); +} + +UNSIGNED32 odbc_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { + 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()); + *pbAtBof = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 odbc_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + 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()); + } + *pusCnt = static_cast(st->fields.size()); + return ok(); +} + +UNSIGNED32 odbc_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + 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 (n == 0 || n > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, st->fields[n - 1].name); + return ok(); +} + +UNSIGNED32 odbc_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + 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(); +} + +UNSIGNED32 odbc_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + 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(); +} + +UNSIGNED32 odbc_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + 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(); +} + +UNSIGNED32 odbc_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_odbc_table(hTable); + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRec = static_cast(st->current_recno); + return ok(); +} + +UNSIGNED32 odbc_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_odbc_table(hTable); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (st->rec_count_cached) { + *pulCount = 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; + *pulCount = st->cached_rec_count; + return ok(); +} + +UNSIGNED32 odbc_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + 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(); +} + +UNSIGNED32 odbc_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_odbc_table(hTable); + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); +} + +UNSIGNED32 odbc_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + 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(); +} + +UNSIGNED32 odbc_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_odbc_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +const openads::abi::BackendTableOps* odbc_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &odbc_close_table; + o.goto_top = &odbc_goto_top; + o.goto_bottom = &odbc_goto_bottom; + o.skip = &odbc_skip; + o.at_eof = &odbc_at_eof; + o.at_bof = &odbc_at_bof; + o.num_fields = &odbc_num_fields; + o.field_name = &odbc_field_name; + o.field_type = &odbc_field_type; + o.field_length = &odbc_field_length; + o.field_decimals = &odbc_field_decimals; + o.record_num = &odbc_record_num; + o.record_count = &odbc_record_count; + o.get_field = &odbc_get_field; + o.is_record_deleted = &odbc_is_record_deleted; + o.open_index = &odbc_open_index; + o.is_found = &odbc_is_found; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_ODBC (lifted ops) + +// --------------------------------------------------------------------------- +// Lifted MariaDB table ops + accessor (mirrors the SQLite lift; bodies are +// the per-function inline MariaDB dispatch moved verbatim behind the +// registry so the 17 ABI functions stay backend-agnostic). +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_MARIADB) + +UNSIGNED32 maria_close_table(ADSHANDLE hTable) { + auto* st = get_maria_table(hTable); + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + maria_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); +} + +UNSIGNED32 maria_goto_top(ADSHANDLE hTable) { + auto* st = get_maria_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(); +} + +UNSIGNED32 maria_goto_bottom(ADSHANDLE hTable) { + auto* st = get_maria_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(); +} + +UNSIGNED32 maria_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_maria_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(); +} + +UNSIGNED32 maria_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_maria_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(); +} + +UNSIGNED32 maria_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { + auto* st = get_maria_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()); + *pbAtBof = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 maria_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + auto* st = get_maria_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()); + } + *pusCnt = static_cast(st->fields.size()); + return ok(); +} + +UNSIGNED32 maria_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + auto* st = get_maria_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 (n == 0 || n > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, st->fields[n - 1].name); + return ok(); +} + +UNSIGNED32 maria_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + auto* st = get_maria_table(hTable); + auto i = maria_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = st->fields[i].type; + return ok(); +} + +UNSIGNED32 maria_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + auto* st = get_maria_table(hTable); + auto i = maria_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulLen = st->fields[i].length; + return ok(); +} + +UNSIGNED32 maria_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + auto* st = get_maria_table(hTable); + auto i = maria_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusDec = st->fields[i].decimals; + return ok(); +} + +UNSIGNED32 maria_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_maria_table(hTable); + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRec = static_cast(st->current_recno); + return ok(); +} + +UNSIGNED32 maria_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_maria_table(hTable); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (st->rec_count_cached) { + *pulCount = 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; + *pulCount = st->cached_rec_count; + return ok(); +} + +UNSIGNED32 maria_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_maria_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 = maria_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(); +} + +UNSIGNED32 maria_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_maria_table(hTable); + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); +} + +UNSIGNED32 maria_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + auto* st = get_maria_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::MariaIndex, si.get()); + ahIndex[0] = gh; + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + maria_indexes_map().emplace(gh, std::move(si)); + return ok(); +} + +UNSIGNED32 maria_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_maria_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +const openads::abi::BackendTableOps* maria_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &maria_close_table; + o.goto_top = &maria_goto_top; + o.goto_bottom = &maria_goto_bottom; + o.skip = &maria_skip; + o.at_eof = &maria_at_eof; + o.at_bof = &maria_at_bof; + o.num_fields = &maria_num_fields; + o.field_name = &maria_field_name; + o.field_type = &maria_field_type; + o.field_length = &maria_field_length; + o.field_decimals = &maria_field_decimals; + o.record_num = &maria_record_num; + o.record_count = &maria_record_count; + o.get_field = &maria_get_field; + o.is_record_deleted = &maria_is_record_deleted; + o.open_index = &maria_open_index; + o.is_found = &maria_is_found; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_MARIADB (lifted ops) + +// --------------------------------------------------------------------------- +// Lifted PostgreSQL table ops + accessor (mirrors the SQLite lift; bodies are +// the per-function inline PostgreSQL dispatch moved verbatim behind the +// registry so the 17 ABI functions stay backend-agnostic). +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_POSTGRESQL) + +UNSIGNED32 postgres_close_table(ADSHANDLE hTable) { + auto* st = get_postgres_table(hTable); + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + postgres_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); +} + +UNSIGNED32 postgres_goto_top(ADSHANDLE hTable) { + auto* st = get_postgres_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(); +} + +UNSIGNED32 postgres_goto_bottom(ADSHANDLE hTable) { + auto* st = get_postgres_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(); +} + +UNSIGNED32 postgres_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_postgres_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(); +} + +UNSIGNED32 postgres_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_postgres_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(); +} + +UNSIGNED32 postgres_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { + auto* st = get_postgres_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()); + *pbAtBof = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 postgres_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + auto* st = get_postgres_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()); + } + *pusCnt = static_cast(st->fields.size()); + return ok(); +} + +UNSIGNED32 postgres_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + auto* st = get_postgres_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 (n == 0 || n > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, st->fields[n - 1].name); + return ok(); +} + +UNSIGNED32 postgres_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + auto* st = get_postgres_table(hTable); + auto i = postgres_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = st->fields[i].type; + return ok(); +} + +UNSIGNED32 postgres_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + auto* st = get_postgres_table(hTable); + auto i = postgres_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulLen = st->fields[i].length; + return ok(); +} + +UNSIGNED32 postgres_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + auto* st = get_postgres_table(hTable); + auto i = postgres_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); } - return std::numeric_limits::max(); + *pusDec = st->fields[i].decimals; + return ok(); } -#endif // OPENADS_WITH_SQLITE -// M12.16 — same dispatch helper for remote-index handles. Returns -// nullptr when `h` is a local IIndex / Connection / unknown. -openads::network::RemoteIndex* get_remote_index(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::RemoteIndex); +UNSIGNED32 postgres_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_postgres_table(hTable); + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRec = static_cast(st->current_recno); + return ok(); } -// 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. -bool& seek_last_retry_latch() { - static thread_local bool v = false; - return v; +UNSIGNED32 postgres_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_postgres_table(hTable); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (st->rec_count_cached) { + *pulCount = 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; + *pulCount = st->cached_rec_count; + return ok(); } -// Harbour rddads' default connection handle is 0 when the caller -// never AdsConnect'd. SAP-ACE in this mode auto-connects against the -// current working directory; mirror that by lazily creating one -// process-wide Connection rooted at fs::current_path. Returned handle -// is cached so subsequent calls reuse the same Connection. -ADSHANDLE get_or_create_default_connection() { - static ADSHANDLE cached = 0; - auto& s = state(); - if (cached != 0) { - if (s.registry.lookup(cached, HandleKind::Connection)) - return cached; - cached = 0; +UNSIGNED32 postgres_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_postgres_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 = postgres_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); } - namespace fs = std::filesystem; - auto opened = Connection::open(fs::current_path().string()); - if (!opened) return 0; - auto holder = std::make_unique(std::move(opened).value()); - Connection* raw = holder.get(); - Handle h = s.registry.register_object(HandleKind::Connection, raw); - s.conns.emplace(h, std::move(holder)); - cached = h; - return h; + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); } -Table* get_table(ADSHANDLE h) { +UNSIGNED32 postgres_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_postgres_table(hTable); + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); +} + +UNSIGNED32 postgres_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + auto* st = get_postgres_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(); - Table* t = s.registry.lookup
(h, HandleKind::Table); - if (t != nullptr) return t; - // Real ACE accepts an index handle anywhere a table handle is - // expected — rddads' adsGoTop calls AdsGotoTop(hOrdCurrent) when - // an order is active. The bound Table is the same as the table's - // own; we additionally swap the binding's parked IIndex into the - // Table's active order so navigation actually walks the requested - // tag (multi-tag CDX support, M8.9). - Table* via_idx = lookup_table_by_index(h); - if (via_idx != nullptr) { - (void)activate_binding(h); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object( + HandleKind::PostgresIndex, si.get()); + ahIndex[0] = gh; + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; } - return via_idx; + postgres_indexes_map().emplace(gh, std::move(si)); + return ok(); } -// DBF/xbase CHARACTER fields are fixed-width space-padded. The internal -// decode path (make_string / decode_field) trims trailing spaces because -// the SQL engine, index keys, and AOF filters need trimmed values. On the -// way out to an ABI caller, re-pad to the declared field width so that -// FieldGet of a C(20) field always returns exactly 20 characters — the -// behaviour expected by rddads, Clipper, and X# (Pritpal's xbrowse bug). -// Never truncates: a value already at or above width is returned as-is. -std::string pad_char_field(std::string s, std::size_t width) { - if (s.size() < width) - s.append(width - s.size(), ' '); - return s; +UNSIGNED32 postgres_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_postgres_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); } +const openads::abi::BackendTableOps* postgres_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &postgres_close_table; + o.goto_top = &postgres_goto_top; + o.goto_bottom = &postgres_goto_bottom; + o.skip = &postgres_skip; + o.at_eof = &postgres_at_eof; + o.at_bof = &postgres_at_bof; + o.num_fields = &postgres_num_fields; + o.field_name = &postgres_field_name; + o.field_type = &postgres_field_type; + o.field_length = &postgres_field_length; + o.field_decimals = &postgres_field_decimals; + o.record_num = &postgres_record_num; + o.record_count = &postgres_record_count; + o.get_field = &postgres_get_field; + o.is_record_deleted = &postgres_is_record_deleted; + o.open_index = &postgres_open_index; + o.is_found = &postgres_is_found; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_POSTGRESQL (lifted ops) + // --------------------------------------------------------------------------- // Referential Integrity enforcement // --------------------------------------------------------------------------- @@ -798,6 +1991,40 @@ bool set_stmt_param(ADSHANDLE h, const char* pname, std::string literal); // disconnect paths above can call it before the definition arrives. void purge_pending_binaries_for_table(openads::engine::Table* t); +// --------------------------------------------------------------------------- +// Task 3: register_builtin_backends + backend_table_ops_for +// Defined here (same TU as state() and sqlite_table_ops()) so both symbols +// are reachable without exposing new globals or changing the headers. +// --------------------------------------------------------------------------- +namespace openads::abi { + +void register_builtin_backends() { +#if defined(OPENADS_WITH_SQLITE) + register_backend_table_ops(openads::session::HandleKind::SqliteTable, + sqlite_table_ops()); +#endif +#if defined(OPENADS_WITH_ODBC) + register_backend_table_ops(openads::session::HandleKind::OdbcTable, + odbc_table_ops()); +#endif +#if defined(OPENADS_WITH_MARIADB) + register_backend_table_ops(openads::session::HandleKind::MariaTable, + maria_table_ops()); +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + register_backend_table_ops(openads::session::HandleKind::PostgresTable, + postgres_table_ops()); +#endif +} + +const BackendTableOps* backend_table_ops_for(ADSHANDLE h) { + static const bool _ = (register_builtin_backends(), true); + (void)_; + return ops_for_kind(state().registry.kind_of(h)); +} + +} // namespace openads::abi + extern "C" { UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, @@ -900,6 +2127,105 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, return ok(); } } +#endif +#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 +#if defined(OPENADS_WITH_MARIADB) + { + openads::sql_backend::MariaUri muri; + if (openads::sql_backend::parse_maria_uri(path, muri)) { + auto opened = openads::sql_backend::MariaConnection::open(muri); + if (!opened) return fail(opened.error()); + auto holder = std::make_unique( + std::move(opened).value()); + openads::sql_backend::MariaConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::MariaConnection, raw); + maria_conns_map().emplace(h, std::move(holder)); + *phConnect = h; + return ok(); + } + } +#else + { + static constexpr const char* kMariaPrefixes[] = { + "mariadb://", "mysql://", + }; + for (const char* prefix : kMariaPrefixes) { + 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, + "mariadb URI requires " + "OPENADS_WITH_MARIADB=ON"); + } + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + { + openads::sql_backend::PostgresUri suri; + if (openads::sql_backend::parse_postgres_uri(path, suri)) { + auto opened = openads::sql_backend::PostgresConnection::open(suri); + if (!opened) return fail(opened.error()); + auto holder = std::make_unique( + std::move(opened).value()); + openads::sql_backend::PostgresConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::PostgresConnection, raw); + postgres_conns_map().emplace(h, std::move(holder)); + *phConnect = h; + return ok(); + } + } +#else + { + static constexpr const char* kPgPrefixes[] = { + "postgresql://", "postgres://", "pgsql://", + }; + for (const char* prefix : kPgPrefixes) { + 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, + "postgresql URI requires " + "OPENADS_WITH_POSTGRESQL=ON"); + } + } + } #endif auto opened = Connection::open(path); if (!opened) return fail(opened.error()); @@ -959,6 +2285,49 @@ UNSIGNED32 AdsDisconnect(ADSHANDLE hConnect) { s_local.registry.release(hConnect); return ok(); } +#endif +#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 defined(OPENADS_WITH_MARIADB) + if (auto* sc = s_local.registry.lookup( + hConnect, HandleKind::MariaConnection)) { + for (auto& kv : maria_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + maria_conns_map().erase(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* sc = s_local.registry.lookup( + hConnect, HandleKind::PostgresConnection)) { + for (auto& kv : postgres_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + postgres_conns_map().erase(hConnect); + s_local.registry.release(hConnect); + return ok(); + } #endif if (auto* rc = s_local.registry.lookup( hConnect, HandleKind::RemoteConnection)) { @@ -1060,6 +2429,51 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, *phTable = gh; return ok(); } +#endif +#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 +#if defined(OPENADS_WITH_MARIADB) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::MariaConnection)) { + 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::MariaTable, st.get()); + maria_tables_map().emplace(gh, std::move(st)); + *phTable = gh; + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::PostgresConnection)) { + 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::PostgresTable, st.get()); + postgres_tables_map().emplace(gh, std::move(st)); + *phTable = gh; + return ok(); + } #endif auto* conn = s.registry.lookup(hConnect, HandleKind::Connection); if (conn == nullptr) { @@ -2054,16 +3468,6 @@ UNSIGNED32 AdsCloseAllTables(void) { UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) { { -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - (void)st; - auto& s2 = state(); - std::lock_guard lk2(s2.mu); - sqlite_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. @@ -2075,6 +3479,8 @@ UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) { remote_sql_cursors_map().erase(hTable); return ok(); } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->close_table) return ops->close_table(hTable); } auto& s = state(); std::lock_guard lk(s.mu); @@ -2106,16 +3512,8 @@ UNSIGNED32 AdsGotoTop(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->goto_top) return ops->goto_top(hTable); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_top(); @@ -2130,16 +3528,8 @@ UNSIGNED32 AdsGotoBottom(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->goto_bottom) return ops->goto_bottom(hTable); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_bottom(); @@ -2169,16 +3559,8 @@ UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { if (!r) return fail(r.error()); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->skip) return ops->skip(hTable, lRows); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->skip(lRows); @@ -2190,23 +3572,13 @@ UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { UNSIGNED32 AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { if (auto* rt = get_remote_table(hTable)) { if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto r = rt->conn->at_eof(rt->id); - if (!r) return fail(r.error()); - *pbAtEnd = r.value() ? 1 : 0; - return ok(); - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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); + auto r = rt->conn->at_eof(rt->id); if (!r) return fail(r.error()); *pbAtEnd = r.value() ? 1 : 0; return ok(); } -#endif + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->at_eof) return ops->at_eof(hTable, pbAtEnd); Table* t = get_table(hTable); if (!t || pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtEnd = t->eof() ? 1 : 0; @@ -2221,17 +3593,8 @@ UNSIGNED32 AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { *pbAtBegin = r.value() ? 1 : 0; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->at_bof) return ops->at_bof(hTable, pbAtBegin); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtBegin = t->bof() ? 1 : 0; @@ -2250,19 +3613,8 @@ UNSIGNED32 AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) { *pusFields = static_cast(rt->fields.size()); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->num_fields) return ops->num_fields(hTable, pusFields); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); if (auto* p = projection_for(hTable); p != nullptr) { @@ -2289,23 +3641,8 @@ UNSIGNED32 AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum, rt->fields[usFieldNum - 1].name); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->field_name) return ops->field_name(hTable, usFieldNum, pucBuf, pusLen); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto* p = projection_for(hTable); @@ -2354,6 +3691,9 @@ std::size_t remote_field_index(openads::network::RemoteTable* rt, return std::numeric_limits::max(); } } + if (pucField == nullptr) { + return std::numeric_limits::max(); + } std::string want = openads::abi::to_internal(pucField, 0); for (auto& c : want) { c = static_cast( @@ -2383,16 +3723,8 @@ UNSIGNED32 AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField, *pusType = rt->fields[i].type; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - auto i = sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->field_type) return ops->field_type(hTable, pucField, pusType); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2422,16 +3754,8 @@ UNSIGNED32 AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField, *pulLen = rt->fields[i].length; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - auto i = sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->field_length) return ops->field_length(hTable, pucField, pulLen); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2474,16 +3798,8 @@ UNSIGNED32 AdsGetFieldDecimals(ADSHANDLE hTable, UNSIGNED8* pucField, *pusDec = rt->fields[i].decimals; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - auto i = sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->field_decimals) return ops->field_decimals(hTable, pucField, pusDec); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2641,15 +3957,8 @@ UNSIGNED32 AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, *pulRecordNum = r.value(); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (!st->positioned || !st->row_valid) { - return fail(5026, "no current record"); - } - *pulRecordNum = static_cast(st->current_rowid); - return ok(); - } -#endif + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->record_num) return ops->record_num(hTable, pulRecordNum); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pulRecordNum = t->recno(); @@ -2676,24 +3985,8 @@ UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 bFilterOption, *pulRecordCount = rt->cached_rec_count; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->record_count) return ops->record_count(hTable, pulRecordCount, 0); 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 @@ -2778,27 +4071,8 @@ UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, openads::abi::copy_to_caller(pucBuf, pulLen, val); return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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 = sqlite_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 + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->get_field) return ops->get_field(hTable, pucField, pucBuf, pulLen, 0); Table* t = get_table(hTable); if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -3577,12 +4851,8 @@ UNSIGNED32 AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { *pbDeleted = r.value() ? 1 : 0; return ok(); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - *pbDeleted = st->current_deleted ? 1 : 0; - return ok(); - } -#endif + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->is_record_deleted) return ops->is_record_deleted(hTable, pbDeleted); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbDeleted = t->is_deleted() ? 1 : 0; @@ -4297,38 +5567,6 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, if (ahIndex == nullptr) { return fail(openads::AE_INTERNAL_ERROR, "null out"); } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_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::SqliteIndex, si.get()); - ahIndex[0] = gh; - if (pu16ArrayLen != nullptr) { - *pu16ArrayLen = 1; - } - sqlite_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); @@ -4358,6 +5596,8 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, if (pu16ArrayLen != nullptr) *pu16ArrayLen = out_n; return ok(); } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->open_index) return ops->open_index(hTable, pucName, ahIndex, pu16ArrayLen); Table* t = get_table(hTable); if (!t) { return fail(openads::AE_INTERNAL_ERROR, "unknown table"); @@ -4511,6 +5751,36 @@ UNSIGNED32 AdsCloseIndex(ADSHANDLE hIndex) { s.registry.release(hIndex); return ok(); } +#endif +#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 defined(OPENADS_WITH_MARIADB) + if (auto* si = get_maria_index(hIndex)) { + (void)si; + auto& s = state(); + std::lock_guard lk(s.mu); + maria_indexes_map().erase(hIndex); + s.registry.release(hIndex); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* si = get_postgres_index(hIndex)) { + (void)si; + auto& s = state(); + std::lock_guard lk(s.mu); + postgres_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); @@ -4574,6 +5844,31 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, pucIndexName == nullptr || pucExpr == nullptr) { return fail(openads::AE_INTERNAL_ERROR, "null arg"); } +#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 if (auto* rt = get_remote_table(hTable)) { std::string path = openads::abi::to_internal(pucFileName, 0); std::string tag = openads::abi::to_internal(pucIndexName, 0); @@ -6756,18 +8051,14 @@ 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_SQLITE) - if (auto* st = get_sqlite_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()); *pbFound = r.value() ? 1 : 0; return ok(); } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->is_found) return ops->is_found(hTable, pbFound); Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); if (pbFound != nullptr) *pbFound = t->last_seek_found() ? 1 : 0; @@ -6797,6 +8088,61 @@ UNSIGNED32 AdsSeek(ADSHANDLE hIndex, (void)u16KeyType; return ok(); } +#endif +#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 defined(OPENADS_WITH_MARIADB) + if (auto* si = get_maria_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "mariadb 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, 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 defined(OPENADS_WITH_POSTGRESQL) + if (auto* si = get_postgres_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "postgres 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, 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), @@ -6919,6 +8265,58 @@ UNSIGNED32 AdsSeekLast(ADSHANDLE hIndex, (void)u16KeyType; return ok(); } +#endif +#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 defined(OPENADS_WITH_MARIADB) + if (auto* si = get_maria_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "mariadb 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, 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 defined(OPENADS_WITH_POSTGRESQL) + if (auto* si = get_postgres_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "postgres 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, 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), @@ -7996,6 +9394,7 @@ namespace { struct SqlStatement { Connection* conn = nullptr; openads::network::RemoteConnection* remote = nullptr; + openads::sql_backend::SqliteConnection* sqlite = nullptr; std::string sql; // RCB 2026-05-22 17:03 — The original struct stored only the raw SQL string. // AdsSet* functions never had a place to write named parameter values because @@ -8077,6 +9476,17 @@ UNSIGNED32 AdsCreateSQLStatement(ADSHANDLE hConnect, ADSHANDLE* phStatement) { *phStatement = h; return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::SqliteConnection)) { + auto stmt = std::make_unique(); + stmt->sqlite = sc; + ADSHANDLE h = next_stmt_handle(); + stmt_map()[h] = std::move(stmt); + *phStatement = h; + return ok(); + } +#endif Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); auto stmt = std::make_unique(); @@ -9415,6 +10825,25 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, *phCursor = h; return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (it->second->sqlite != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + // Let SQLite classify the statement (it knows the column count): run_sql + // returns a navigable cursor for a result-producing statement, or a null + // pointer for an executed INSERT/UPDATE/DELETE/DDL — no SQL parsing here. + auto r = it->second->sqlite->run_sql(sqlstr); + if (!r) return fail(r.error()); + auto cursor = std::move(r).value(); + if (!cursor) { *phCursor = 0; return ok(); } + auto& s = state(); + std::lock_guard lk(s.mu); + openads::sql_backend::SqliteTable* raw = cursor.get(); + Handle h = s.registry.register_object(HandleKind::SqliteTable, raw); + sqlite_tables_map().emplace(h, std::move(cursor)); + *phCursor = h; + return ok(); + } +#endif Connection* c = it->second->conn; if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); auto sql = openads::abi::to_internal(pucSQL, 0); @@ -14411,6 +15840,31 @@ UNSIGNED32 AdsGetRecord(ADSHANDLE, UNSIGNED8*, UNSIGNED32* p) UNSIGNED32 AdsGetRelKeyPos(ADSHANDLE h, double* p) { if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *p = 0.0; +#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 if (auto* rt = get_remote_table(h)) { // M12.19 — scrollbar callers (xbrowse) hit this every paint. // current_recno arrives with the row trailer (M12.18) and diff --git a/src/abi/backend_registry.cpp b/src/abi/backend_registry.cpp new file mode 100644 index 00000000..0b655342 --- /dev/null +++ b/src/abi/backend_registry.cpp @@ -0,0 +1,27 @@ +#include "abi/backend_registry.h" + +#include +#include + +namespace openads::abi { + +namespace { +// Fixed table indexed by HandleKind (enum is dense; current max value is 11). +std::array& ops_table() { + static std::array t{}; // all nullptr + return t; +} +} // namespace + +void register_backend_table_ops(openads::session::HandleKind kind, + const BackendTableOps* ops) { + auto idx = static_cast(kind); + if (idx < ops_table().size()) ops_table()[idx] = ops; +} + +const BackendTableOps* ops_for_kind(openads::session::HandleKind kind) { + auto idx = static_cast(kind); + return idx < ops_table().size() ? ops_table()[idx] : nullptr; +} + +} // namespace openads::abi diff --git a/src/abi/backend_registry.h b/src/abi/backend_registry.h new file mode 100644 index 00000000..c07851f1 --- /dev/null +++ b/src/abi/backend_registry.h @@ -0,0 +1,24 @@ +#pragma once + +#include "openads/ace.h" +#include "abi/backend_table_ops.h" +#include "session/handle_registry.h" + +namespace openads::abi { + +// Register a backend's ops under its table HandleKind (e.g. SqliteTable). +void register_backend_table_ops(openads::session::HandleKind kind, + const BackendTableOps* ops); + +// Look up ops by kind. Returns nullptr for unregistered kinds (native/remote). +const BackendTableOps* ops_for_kind(openads::session::HandleKind kind); + +// Resolve a live handle to its backend ops, or nullptr. DEFINED IN ace_exports.cpp +// (Task 3) because it needs the process session registry. Declared here for callers. +const BackendTableOps* backend_table_ops_for(ADSHANDLE h); + +// Registers all compiled-in backends. DEFINED IN ace_exports.cpp (Task 3) — it is +// the single place holding the per-backend #if. Declared here. +void register_builtin_backends(); + +} // namespace openads::abi diff --git a/src/abi/backend_table_ops.h b/src/abi/backend_table_ops.h new file mode 100644 index 00000000..ea069751 --- /dev/null +++ b/src/abi/backend_table_ops.h @@ -0,0 +1,31 @@ +#pragma once + +#include "openads/ace.h" // ADSHANDLE, UNSIGNED32, SIGNED32, UNSIGNED16, UNSIGNED8 + +namespace openads::abi { + +// One backend's table-level ABI operations. A SQL backend fills this struct; +// the native (local DBF) and remote (tcp://) paths do NOT — they remain the +// fall-through in each ABI function. Any pointer may be left null if the +// backend does not implement that op. +struct BackendTableOps { + UNSIGNED32 (*close_table) (ADSHANDLE); + UNSIGNED32 (*goto_top) (ADSHANDLE); + UNSIGNED32 (*goto_bottom) (ADSHANDLE); + UNSIGNED32 (*skip) (ADSHANDLE, SIGNED32); + UNSIGNED32 (*at_eof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*at_bof) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*num_fields) (ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*field_name) (ADSHANDLE, UNSIGNED16, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_type) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*field_length) (ADSHANDLE, UNSIGNED8*, UNSIGNED32*); + UNSIGNED32 (*field_decimals) (ADSHANDLE, UNSIGNED8*, UNSIGNED16*); + UNSIGNED32 (*record_num) (ADSHANDLE, UNSIGNED32*); + UNSIGNED32 (*record_count) (ADSHANDLE, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*get_field) (ADSHANDLE, UNSIGNED8*, UNSIGNED8*, UNSIGNED32*, UNSIGNED16); + UNSIGNED32 (*is_record_deleted)(ADSHANDLE, UNSIGNED16*); + UNSIGNED32 (*open_index) (ADSHANDLE, UNSIGNED8*, ADSHANDLE*, UNSIGNED16*); + UNSIGNED32 (*is_found) (ADSHANDLE, UNSIGNED16*); +}; + +} // namespace openads::abi diff --git a/src/engine/table.cpp b/src/engine/table.cpp index 1bec038e..975fcdf8 100644 --- a/src/engine/table.cpp +++ b/src/engine/table.cpp @@ -504,9 +504,18 @@ util::Result Table::skip(std::int32_t delta) { if (auto r = load_record_(static_cast(target)); !r) { return r.error(); } - if (filter_) { + // SET DELETE ON (no active index) and/or an active filter hide rows + // from the natural-order walk: step in the skip direction until a + // visible row appears or we run off either end. goto_top/goto_bottom + // already do this for deleted rows (576746e); skip was overlooked. + const bool skip_deleted = !openads::abi::show_deleted(); + if (skip_deleted || filter_) { std::int64_t step = (delta >= 0) ? 1 : -1; - while (state_ == State::Positioned && !filter_(*this)) { + auto hidden = [&]() { + return (skip_deleted && is_deleted()) || + (filter_ && !filter_(*this)); + }; + while (state_ == State::Positioned && hidden()) { std::int64_t nt = static_cast(recno_) + step; if (nt < 1) { state_ = State::Bof; recno_ = 0; return {}; } if (nt > static_cast(n)) { diff --git a/src/session/handle_registry.h b/src/session/handle_registry.h index 72c0d442..0e2fbea1 100644 --- a/src/session/handle_registry.h +++ b/src/session/handle_registry.h @@ -24,7 +24,19 @@ enum class HandleKind { // OpenADS Plus — external SQL backend (sqlite:// …). SqliteConnection = 9, SqliteTable = 10, - SqliteIndex = 11 + SqliteIndex = 11, + // OpenADS Plus — PostgreSQL backend (postgresql:// …). + PostgresConnection = 12, + PostgresTable = 13, + PostgresIndex = 14, + // OpenADS Plus — MariaDB/MySQL backend (mariadb:// …). + MariaConnection = 15, + MariaTable = 16, + MariaIndex = 17, + // OpenADS Plus — generic ODBC backend (odbc:// …). + OdbcConnection = 18, + OdbcTable = 19, + OdbcIndex = 20 }; using Handle = std::uint64_t; @@ -51,6 +63,12 @@ class HandleRegistry { } } + HandleKind kind_of(Handle h) const { + std::lock_guard lk(mu_); + auto it = slots_.find(h); + return it == slots_.end() ? HandleKind::None : it->second.kind; + } + private: struct Slot { HandleKind kind = HandleKind::None; void* ptr = nullptr; }; diff --git a/src/sql_backend/maria_backend.cpp b/src/sql_backend/maria_backend.cpp new file mode 100644 index 00000000..ff9c9fa8 --- /dev/null +++ b/src/sql_backend/maria_backend.cpp @@ -0,0 +1,107 @@ +#include "sql_backend/maria_backend.h" + +#include "openads/ace.h" + +#include +#include + +#if defined(OPENADS_WITH_MARIADB) +#include +#endif + +namespace openads::sql_backend { + +MariaTable::FieldDesc map_maria_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale) { + MariaTable::FieldDesc fd; + fd.name = name ? name : ""; + fd.nullable = nullable; + + std::string dt = data_type ? data_type : ""; + for (auto& c : dt) { + c = static_cast(std::tolower(static_cast(c))); + } + + if (dt.find("int") != std::string::npos || + dt == "tinyint" || dt == "smallint" || dt == "mediumint" || + dt == "bigint") { + fd.type = ADS_INTEGER; + fd.length = 4; + fd.decimals = 0; + } else if (dt.find("double") != std::string::npos || + dt.find("float") != std::string::npos || + dt.find("decimal") != std::string::npos || + dt.find("numeric") != std::string::npos) { + fd.type = ADS_DOUBLE; + fd.length = 8; + fd.decimals = numeric_scale > 0 + ? static_cast(numeric_scale) + : 6; + (void)numeric_precision; + } else if (dt.find("blob") != std::string::npos || + dt.find("binary") != std::string::npos) { + fd.type = ADS_BINARY; + fd.length = 10; + fd.decimals = 0; + } else { + fd.type = ADS_STRING; + fd.length = char_max_len > 0 + ? static_cast(char_max_len) + : 64; + fd.decimals = 0; + } + return fd; +} + +#if defined(OPENADS_WITH_MARIADB) + +std::string format_maria_value(char** row, unsigned int col, bool& is_null) { + is_null = false; + if (row == nullptr || row[col] == nullptr) { + is_null = true; + return {}; + } + return row[col]; +} + +util::Error maria_error(const char* context, const char* msg) { + return util::Error{5001, 0, + std::string(context) + ": " + (msg ? msg : ""), + ""}; +} + +#else + +std::string format_maria_value(char**, unsigned int, bool& is_null) { + is_null = true; + return {}; +} + +util::Error maria_error(const char* context, const char* msg) { + (void)context; + (void)msg; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +} + +#endif + +std::size_t field_index_ci(const MariaTable& 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 \ No newline at end of file diff --git a/src/sql_backend/maria_backend.h b/src/sql_backend/maria_backend.h new file mode 100644 index 00000000..629a1cdf --- /dev/null +++ b/src/sql_backend/maria_backend.h @@ -0,0 +1,21 @@ +#pragma once + +#include "sql_backend/maria_table.h" +#include "util/result.h" + +namespace openads::sql_backend { + +MariaTable::FieldDesc map_maria_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale); + +std::string format_maria_value(char** row, unsigned int col, bool& is_null); + +util::Error maria_error(const char* context, const char* msg); + +std::size_t field_index_ci(const MariaTable& tbl, const std::string& name); + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_connection.cpp b/src/sql_backend/maria_connection.cpp new file mode 100644 index 00000000..a8544ede --- /dev/null +++ b/src/sql_backend/maria_connection.cpp @@ -0,0 +1,601 @@ +#include "sql_backend/maria_connection.h" + +#include "sql_backend/maria_backend.h" +#include "sql_backend/sql_common.h" + +#include +#include + +#if defined(OPENADS_WITH_MARIADB) +#include +#endif + +namespace openads::sql_backend { + +namespace { + +#if defined(OPENADS_WITH_MARIADB) + +std::string quote_ident(const std::string& name) { + return '`' + name + '`'; +} + +std::string escape_literal(MYSQL* conn, const std::string& value) { + std::string out; + out.resize(value.size() * 2 + 1); + const unsigned long len = mysql_real_escape_string( + conn, out.data(), value.c_str(), + static_cast(value.size())); + out.resize(len); + return '\'' + out + '\''; +} + +std::string pk_where_clause(MYSQL* conn, const MariaTable& tbl, + const MariaTable::PkRow& pk) { + std::string sql; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) sql += " AND "; + sql += quote_ident(tbl.pk_columns[i]) + " = " + + escape_literal(conn, pk.values[i]); + } + return sql; +} + +util::Result load_current_row(MYSQL* conn, MariaTable* tbl); + +util::Result> +discover_pk_columns(MYSQL* conn, const std::string& table_name) { + const std::string sql = + "SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" + + table_name + + "' AND CONSTRAINT_NAME = 'PRIMARY' " + "ORDER BY ORDINAL_POSITION"; + if (mysql_query(conn, sql.c_str()) != 0) { + return maria_error("pk discovery", mysql_error(conn)); + } + MYSQL_RES* res = mysql_store_result(conn); + if (res == nullptr) { + return maria_error("pk discovery", mysql_error(conn)); + } + std::vector cols; + MYSQL_ROW row; + while ((row = mysql_fetch_row(res)) != nullptr) { + if (row[0] != nullptr) cols.emplace_back(row[0]); + } + mysql_free_result(res); + if (cols.empty()) { + return util::Error{5001, 0, "table has no primary key", table_name}; + } + return cols; +} + +util::Result> +describe_table_impl(MYSQL* conn, MariaTable* tbl) { + if (!is_safe_identifier(tbl->name)) { + return util::Error{5001, 0, "invalid table name", tbl->name}; + } + const std::string sql = + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, " + "CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE " + "FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" + + tbl->name + "' ORDER BY ORDINAL_POSITION"; + if (mysql_query(conn, sql.c_str()) != 0) { + return maria_error("describe_table", mysql_error(conn)); + } + MYSQL_RES* res = mysql_store_result(conn); + if (res == nullptr) { + return maria_error("describe_table", mysql_error(conn)); + } + const unsigned int rows = mysql_num_rows(res); + if (rows == 0) { + mysql_free_result(res); + return util::Error{5001, 0, "table not found or has no columns", + tbl->name}; + } + std::vector out; + out.reserve(rows); + MYSQL_ROW row; + while ((row = mysql_fetch_row(res)) != nullptr) { + const bool nullable = row[2] != nullptr && row[2][0] == 'Y'; + out.push_back(map_maria_column( + row[0], row[1], nullable, + row[3] ? std::atoi(row[3]) : 0, + row[4] ? std::atoi(row[4]) : 0, + row[5] ? std::atoi(row[5]) : 0)); + } + mysql_free_result(res); + tbl->fields = out; + tbl->fields_cached = true; + return out; +} + +util::Result position_at_pk(MYSQL* conn, MariaTable* tbl, + const MariaTable::PkRow& pk) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid mariadb table state", ""}; + } + auto it = std::find_if(tbl->pk_snapshot.begin(), tbl->pk_snapshot.end(), + [&](const MariaTable::PkRow& row) { + return row.values == pk.values; + }); + if (it == tbl->pk_snapshot.end()) { + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; + } + tbl->pos = static_cast( + std::distance(tbl->pk_snapshot.begin(), it)); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(conn, tbl); +} + +util::Result load_current_row(MYSQL* conn, MariaTable* tbl) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid mariadb table state", ""}; + } + if (!tbl->positioned) { + tbl->row_valid = false; + tbl->current_row.clear(); + tbl->current_nulls.clear(); + return util::Result{}; + } + if (!tbl->fields_cached) { + auto d = describe_table_impl(conn, tbl); + if (!d) return d.error(); + } + + const std::string sql = + "SELECT * FROM " + quote_ident(tbl->name) + " WHERE " + + pk_where_clause(conn, *tbl, tbl->pk_snapshot[tbl->pos]); + if (mysql_query(conn, sql.c_str()) != 0) { + return maria_error("load row", mysql_error(conn)); + } + MYSQL_RES* res = mysql_store_result(conn); + if (res == nullptr) { + return maria_error("load row", mysql_error(conn)); + } + + tbl->current_row.clear(); + tbl->current_nulls.clear(); + tbl->row_valid = false; + + MYSQL_ROW row = mysql_fetch_row(res); + if (row != nullptr) { + const unsigned int cols = mysql_num_fields(res); + tbl->current_row.resize(cols); + tbl->current_nulls.resize(cols); + for (unsigned int c = 0; c < cols; ++c) { + bool is_null = false; + tbl->current_row[c] = format_maria_value(row, c, is_null); + tbl->current_nulls[c] = is_null; + } + tbl->row_valid = true; + } else { + tbl->positioned = false; + } + mysql_free_result(res); + return util::Result{}; +} + +std::string pk_select_list(const MariaTable& tbl) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(tbl.pk_columns[i]); + } + return out; +} + +std::string pk_order_by(const MariaTable& tbl) { + return pk_select_list(tbl); +} + +#endif + +} // namespace + +struct MariaConnection::Impl { +#if defined(OPENADS_WITH_MARIADB) + MYSQL* conn = nullptr; +#endif +}; + +MariaConnection::MariaConnection() = default; +MariaConnection::~MariaConnection() { disconnect(); } + +MariaConnection::MariaConnection(MariaConnection&& other) noexcept + : impl_(std::move(other.impl_)) {} + +MariaConnection& MariaConnection::operator=(MariaConnection&& other) noexcept { + if (this != &other) { + disconnect(); + impl_ = std::move(other.impl_); + } + return *this; +} + +util::Result MariaConnection::open(const MariaUri& uri) { +#if defined(OPENADS_WITH_MARIADB) + MariaConnection conn; + conn.impl_ = std::make_unique(); + + MYSQL* raw = mysql_init(nullptr); + if (raw == nullptr) { + return util::Error{5001, 0, "mysql_init failed", ""}; + } + + const char* host = uri.host.empty() ? "127.0.0.1" : uri.host.c_str(); + const char* user = uri.user.empty() ? nullptr : uri.user.c_str(); + const char* pass = uri.password.empty() ? nullptr : uri.password.c_str(); + const char* db = uri.database.empty() ? nullptr : uri.database.c_str(); + + if (mysql_real_connect(raw, host, user, pass, db, uri.port, + nullptr, 0) == nullptr) { + util::Error e = maria_error("connect", mysql_error(raw)); + mysql_close(raw); + return e; + } + conn.impl_->conn = raw; + return std::move(conn); +#else + (void)uri; + return util::Error{5004, 0, + "mariadb backend requires OPENADS_WITH_MARIADB=ON", + ""}; +#endif +} + +void MariaConnection::disconnect() noexcept { +#if defined(OPENADS_WITH_MARIADB) + if (impl_ && impl_->conn) { + mysql_close(impl_->conn); + impl_->conn = nullptr; + } +#endif + impl_.reset(); +} + +bool MariaConnection::valid() const noexcept { +#if defined(OPENADS_WITH_MARIADB) + return impl_ && impl_->conn != nullptr; +#else + return false; +#endif +} + +util::Result> +MariaConnection::open_table(const std::string& table_name) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid()) { + return util::Error{5001, 0, "mariadb 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_columns(impl_->conn, table_name); + if (!pk) return pk.error(); + tbl->pk_columns = std::move(pk).value(); + + const std::string sql = + "SELECT " + pk_select_list(*tbl) + " FROM " + + quote_ident(table_name) + " ORDER BY " + pk_order_by(*tbl); + if (mysql_query(impl_->conn, sql.c_str()) != 0) { + return maria_error("pk snapshot", mysql_error(impl_->conn)); + } + MYSQL_RES* res = mysql_store_result(impl_->conn); + if (res == nullptr) { + return maria_error("pk snapshot", mysql_error(impl_->conn)); + } + + const unsigned int pk_cols = static_cast(tbl->pk_columns.size()); + MYSQL_ROW row; + while ((row = mysql_fetch_row(res)) != nullptr) { + MariaTable::PkRow pk_row; + pk_row.values.resize(pk_cols); + for (unsigned int c = 0; c < pk_cols; ++c) { + bool is_null = false; + pk_row.values[c] = format_maria_value(row, c, is_null); + if (is_null) pk_row.values[c].clear(); + } + tbl->pk_snapshot.push_back(std::move(pk_row)); + } + mysql_free_result(res); + + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->current_deleted = false; + tbl->pos = 0; + + if (auto d = describe_table_impl(impl_->conn, tbl.get()); !d) { + return d.error(); + } + return tbl; +#else + (void)table_name; + return util::Error{5004, 0, + "mariadb backend requires OPENADS_WITH_MARIADB=ON", + ""}; +#endif +} + +util::Result MariaConnection::goto_top(MariaTable* tbl) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb goto_top", ""}; + } + if (tbl->pk_snapshot.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = 0; + tbl->positioned = true; + tbl->current_recno = 1; + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::goto_bottom(MariaTable* tbl) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb goto_bottom", ""}; + } + if (tbl->pk_snapshot.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = tbl->pk_snapshot.size() - 1; + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::skip(MariaTable* tbl, std::int32_t step) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb skip", ""}; + } + if (step == 0) return util::Result{}; + if (tbl->pk_snapshot.empty()) { + 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->pk_snapshot.size()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = tbl->pk_snapshot.size(); + return util::Result{}; + } + + tbl->pos = static_cast(next); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + (void)step; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::at_eof(MariaTable* tbl) const { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb at_eof", ""}; + } + if (tbl->pk_snapshot.empty()) return true; + if (!tbl->positioned && tbl->pos >= tbl->pk_snapshot.size()) return true; + return false; +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::at_bof(MariaTable* tbl) const { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb at_bof", ""}; + } + if (tbl->pk_snapshot.empty()) return true; + return !tbl->positioned && tbl->pos == 0; +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::record_count(MariaTable* tbl) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb 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; +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result> +MariaConnection::describe_table(MariaTable* tbl) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb describe_table", ""}; + } + if (tbl->fields_cached) return tbl->fields; + return describe_table_impl(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::read_field( + MariaTable* tbl, const std::string& field_name, + std::string& buf, bool& is_null) const { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb 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{}; +#else + (void)tbl; + (void)field_name; + (void)buf; + (void)is_null; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +util::Result MariaConnection::seek_index( + MariaTable* tbl, const std::string& column, const std::string& key, + bool soft, bool last_key) { +#if defined(OPENADS_WITH_MARIADB) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mariadb seek", ""}; + } + if (!is_safe_identifier(column)) { + return util::Error{5001, 0, "invalid seek column", column}; + } + if (!tbl->fields_cached) { + if (auto d = describe_table_impl(impl_->conn, 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 pk_cols = pk_select_list(*tbl); + const std::string esc_key = escape_literal(impl_->conn, key); + const std::string qcol = quote_ident(column); + + std::string sql; + if (last_key) { + sql = soft + ? "SELECT " + pk_cols + " FROM " + quote_ident(tbl->name) + + " WHERE " + qcol + " <= " + esc_key + + " ORDER BY " + qcol + " DESC LIMIT 1" + : "SELECT " + pk_cols + " FROM " + quote_ident(tbl->name) + + " WHERE " + qcol + " = " + esc_key + + " ORDER BY " + qcol + " DESC LIMIT 1"; + } else { + sql = soft + ? "SELECT " + pk_cols + " FROM " + quote_ident(tbl->name) + + " WHERE " + qcol + " >= " + esc_key + + " ORDER BY " + qcol + " ASC LIMIT 1" + : "SELECT " + pk_cols + " FROM " + quote_ident(tbl->name) + + " WHERE " + qcol + " = " + esc_key + " LIMIT 1"; + } + + if (mysql_query(impl_->conn, sql.c_str()) != 0) { + return maria_error("seek", mysql_error(impl_->conn)); + } + MYSQL_RES* res = mysql_store_result(impl_->conn); + if (res == nullptr) { + return maria_error("seek", mysql_error(impl_->conn)); + } + + bool found = false; + MYSQL_ROW row = mysql_fetch_row(res); + if (row != nullptr) { + MariaTable::PkRow pk; + const unsigned int pk_cols_n = + static_cast(tbl->pk_columns.size()); + pk.values.resize(pk_cols_n); + for (unsigned int c = 0; c < pk_cols_n; ++c) { + bool is_null = false; + pk.values[c] = format_maria_value(row, c, is_null); + if (is_null) pk.values[c].clear(); + } + mysql_free_result(res); + if (auto p = position_at_pk(impl_->conn, tbl, pk); !p) { + return p.error(); + } + found = tbl->positioned && tbl->row_valid; + tbl->last_seek_found = found; + } else { + mysql_free_result(res); + tbl->positioned = false; + tbl->row_valid = false; + found = false; + tbl->last_seek_found = false; + } + return found; +#else + (void)tbl; + (void)column; + (void)key; + (void)soft; + (void)last_key; + return util::Error{5004, 0, "mariadb backend disabled", ""}; +#endif +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_connection.h b/src/sql_backend/maria_connection.h new file mode 100644 index 00000000..c464dc57 --- /dev/null +++ b/src/sql_backend/maria_connection.h @@ -0,0 +1,59 @@ +#pragma once + +#include "sql_backend/maria_table.h" +#include "sql_backend/maria_uri.h" +#include "util/result.h" + +#include +#include +#include + +namespace openads::sql_backend { + +class MariaConnection { +public: + MariaConnection(); + ~MariaConnection(); + + MariaConnection(MariaConnection&&) noexcept; + MariaConnection& operator=(MariaConnection&&) noexcept; + + MariaConnection(const MariaConnection&) = delete; + MariaConnection& operator=(const MariaConnection&) = delete; + + static util::Result open(const MariaUri& uri); + + void disconnect() noexcept; + bool valid() const noexcept; + + util::Result> + open_table(const std::string& table_name); + + util::Result goto_top(MariaTable* tbl); + util::Result goto_bottom(MariaTable* tbl); + util::Result skip(MariaTable* tbl, std::int32_t step); + + util::Result at_eof(MariaTable* tbl) const; + util::Result at_bof(MariaTable* tbl) const; + util::Result record_count(MariaTable* tbl); + + util::Result> + describe_table(MariaTable* tbl); + + util::Result read_field(MariaTable* tbl, + const std::string& field_name, + std::string& buf, + bool& is_null) const; + + util::Result seek_index(MariaTable* tbl, + const std::string& column, + const std::string& key, + bool soft, + bool last_key); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_index.h b/src/sql_backend/maria_index.h new file mode 100644 index 00000000..65377f18 --- /dev/null +++ b/src/sql_backend/maria_index.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +struct MariaTable; + +struct MariaIndex { + MariaTable* parent = nullptr; + std::string column; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_table.h b/src/sql_backend/maria_table.h new file mode 100644 index 00000000..4327cc8d --- /dev/null +++ b/src/sql_backend/maria_table.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +namespace openads::sql_backend { + +class MariaConnection; + +struct MariaTable { + MariaConnection* 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; + }; + std::vector pk_snapshot; + std::size_t pos = 0; + bool positioned = false; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_uri.cpp b/src/sql_backend/maria_uri.cpp new file mode 100644 index 00000000..c6db9066 --- /dev/null +++ b/src/sql_backend/maria_uri.cpp @@ -0,0 +1,94 @@ +#include "sql_backend/maria_uri.h" + +#include + +namespace openads::sql_backend { + +namespace { + +bool parse_authority(const std::string& auth, MariaUri& out) { + std::string userinfo; + std::string hostport; + const auto at = auth.find('@'); + if (at == std::string::npos) { + hostport = auth; + } else { + userinfo = auth.substr(0, at); + hostport = auth.substr(at + 1); + } + if (!userinfo.empty()) { + const auto colon = userinfo.find(':'); + if (colon == std::string::npos) { + out.user = userinfo; + } else { + out.user = userinfo.substr(0, colon); + out.password = userinfo.substr(colon + 1); + } + } + if (hostport.empty()) return false; + if (hostport.front() == '[') { + const auto close = hostport.find(']'); + if (close == std::string::npos) return false; + out.host = hostport.substr(1, close - 1); + if (close + 1 < hostport.size() && hostport[close + 1] == ':') { + out.port = static_cast( + std::strtoul(hostport.c_str() + close + 2, nullptr, 10)); + } + } else { + const auto colon = hostport.rfind(':'); + if (colon != std::string::npos) { + const std::string port_str = hostport.substr(colon + 1); + bool all_digits = !port_str.empty(); + for (char c : port_str) { + if (c < '0' || c > '9') { + all_digits = false; + break; + } + } + if (all_digits) { + out.host = hostport.substr(0, colon); + out.port = static_cast( + std::strtoul(port_str.c_str(), nullptr, 10)); + } else { + out.host = hostport; + } + } else { + out.host = hostport; + } + } + return !out.host.empty(); +} + +} // namespace + +bool parse_maria_uri(const std::string& uri, MariaUri& out) { + static constexpr const char* kMaria = "mariadb://"; + static constexpr const char* kMysql = "mysql://"; + const auto mlen = std::char_traits::length(kMaria); + const auto mylen = std::char_traits::length(kMysql); + + std::string rest; + if (uri.size() >= mlen && uri.compare(0, mlen, kMaria) == 0) { + rest = uri.substr(mlen); + } else if (uri.size() >= mylen && uri.compare(0, mylen, kMysql) == 0) { + rest = uri.substr(mylen); + } else { + return false; + } + + out = MariaUri{}; + const auto slash = rest.find('/'); + std::string authority = rest; + if (slash != std::string::npos) { + authority = rest.substr(0, slash); + out.database = rest.substr(slash + 1); + const auto q = out.database.find('?'); + if (q != std::string::npos) { + out.database = out.database.substr(0, q); + } + } + if (!parse_authority(authority, out)) return false; + return true; +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/maria_uri.h b/src/sql_backend/maria_uri.h new file mode 100644 index 00000000..37549dd1 --- /dev/null +++ b/src/sql_backend/maria_uri.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace openads::sql_backend { + +struct MariaUri { + std::string host; + std::string user; + std::string password; + std::string database; + std::uint16_t port = 3306; +}; + +bool parse_maria_uri(const std::string& uri, MariaUri& out); + +} // namespace openads::sql_backend \ No newline at end of file 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..e2c2fb10 --- /dev/null +++ b/src/sql_backend/odbc_connection.cpp @@ -0,0 +1,736 @@ +#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 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 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..42de3f2f --- /dev/null +++ b/src/sql_backend/odbc_table.h @@ -0,0 +1,56 @@ +#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; // 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; +}; + +} // 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/postgres_backend.cpp b/src/sql_backend/postgres_backend.cpp new file mode 100644 index 00000000..505603d1 --- /dev/null +++ b/src/sql_backend/postgres_backend.cpp @@ -0,0 +1,119 @@ +#include "sql_backend/postgres_backend.h" + +#include "openads/ace.h" + +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +namespace openads::sql_backend { + +PostgresTable::FieldDesc map_pg_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale) { + PostgresTable::FieldDesc fd; + fd.name = name ? name : ""; + fd.nullable = nullable; + + std::string dt = data_type ? data_type : ""; + for (auto& c : dt) { + c = static_cast(std::tolower(static_cast(c))); + } + + if (dt.find("int") != std::string::npos || + dt == "smallint" || dt == "bigint" || dt == "serial" || + dt == "bigserial") { + fd.type = ADS_INTEGER; + fd.length = 4; + fd.decimals = 0; + } else if (dt.find("double") != std::string::npos || + dt.find("real") != std::string::npos || + dt.find("numeric") != std::string::npos || + dt.find("decimal") != std::string::npos || + dt.find("money") != std::string::npos) { + fd.type = ADS_DOUBLE; + fd.length = 8; + fd.decimals = numeric_scale > 0 + ? static_cast(numeric_scale) + : 6; + (void)numeric_precision; + } else if (dt.find("bytea") != std::string::npos) { + fd.type = ADS_BINARY; + fd.length = 10; + fd.decimals = 0; + } else { + fd.type = ADS_STRING; + fd.length = char_max_len > 0 + ? static_cast(char_max_len) + : 64; + fd.decimals = 0; + } + return fd; +} + +#if defined(OPENADS_WITH_POSTGRESQL) + +std::string format_pg_value(pg_result* res, int row, int col, bool& is_null) { + is_null = false; + if (PQgetisnull(res, row, col)) { + is_null = true; + return {}; + } + const char* val = PQgetvalue(res, row, col); + if (val == nullptr) { + is_null = true; + return {}; + } + return val; +} + +util::Error postgres_error(const char* context, const char* msg) { + return util::Error{5001, 0, + std::string(context) + ": " + (msg ? msg : ""), + ""}; +} + +#else + +std::string format_pg_value(pg_result*, int, int, bool& is_null) { + is_null = true; + return {}; +} + +util::Error postgres_error(const char* context, const char* msg) { + (void)context; + (void)msg; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +} + +#endif + +std::size_t field_index_ci(const PostgresTable& 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) { + const std::string& have = tbl.fields[i].name; + if (have.size() != want.size()) continue; + bool eq = true; + for (std::size_t k = 0; k < have.size(); ++k) { + if (static_cast( + std::toupper(static_cast(have[k]))) != + want[k]) { + eq = false; + break; + } + } + if (eq) return i; + } + return static_cast(-1); +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_backend.h b/src/sql_backend/postgres_backend.h new file mode 100644 index 00000000..9278c5a8 --- /dev/null +++ b/src/sql_backend/postgres_backend.h @@ -0,0 +1,23 @@ +#pragma once + +#include "sql_backend/postgres_table.h" +#include "util/result.h" + +struct pg_result; + +namespace openads::sql_backend { + +PostgresTable::FieldDesc map_pg_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale); + +std::string format_pg_value(pg_result* res, int row, int col, bool& is_null); + +util::Error postgres_error(const char* context, const char* msg); + +std::size_t field_index_ci(const PostgresTable& tbl, const std::string& name); + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_connection.cpp b/src/sql_backend/postgres_connection.cpp new file mode 100644 index 00000000..02be3fad --- /dev/null +++ b/src/sql_backend/postgres_connection.cpp @@ -0,0 +1,626 @@ +#include "sql_backend/postgres_connection.h" + +#include "sql_backend/postgres_backend.h" +#include "sql_backend/sql_common.h" + +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +namespace openads::sql_backend { + +namespace { + +#if defined(OPENADS_WITH_POSTGRESQL) + +std::string quote_ident(const std::string& name) { + std::string out = "\""; + for (char c : name) { + if (c == '"') out += "\"\""; // double any embedded quote + else out += c; + } + out += '"'; + return out; +} + +std::string pk_select_list(const PostgresTable& tbl) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(tbl.pk_columns[i]); + } + return out; +} + +// primary-key columns with an explicit direction ("pk1" ASC, "pk2" ASC) — a +// deterministic tie-breaker so a seek never returns an arbitrary row on dup keys. +std::string pk_order_by(const PostgresTable& tbl, const char* dir) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(tbl.pk_columns[i]) + ' ' + dir; + } + return out; +} + +// "col1" = $1 AND "col2" = $2 ... (placeholders bound positionally) +std::string pk_where_clause(const PostgresTable& tbl) { + std::string sql; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) sql += " AND "; + sql += quote_ident(tbl.pk_columns[i]) + " = $" + + std::to_string(i + 1); + } + return sql; +} + +util::Result load_current_row(PGconn* conn, PostgresTable* tbl); + +util::Result> +discover_pk_columns(PGconn* conn, const std::string& table_name) { + const char* params[1] = {table_name.c_str()}; + PGresult* res = PQexecParams( + conn, + "SELECT kcu.column_name " + "FROM information_schema.table_constraints tc " + "JOIN information_schema.key_column_usage kcu " + " ON kcu.constraint_schema = tc.constraint_schema " + " AND kcu.constraint_name = tc.constraint_name " + "WHERE tc.constraint_type = 'PRIMARY KEY' " + " AND tc.table_schema = ANY (current_schemas(true)) " + " AND tc.table_name = $1 " + "ORDER BY kcu.ordinal_position", + 1, nullptr, params, nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("pk discovery", msg); + } + const int rows = PQntuples(res); + std::vector cols; + cols.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + cols.emplace_back(PQgetvalue(res, r, 0)); + } + PQclear(res); + if (cols.empty()) { + return util::Error{5001, 0, "table has no primary key", table_name}; + } + return cols; +} + +util::Result> +describe_table_impl(PGconn* conn, PostgresTable* tbl) { + if (!is_safe_identifier(tbl->name)) { + return util::Error{5001, 0, "invalid table name", tbl->name}; + } + const char* params[1] = {tbl->name.c_str()}; + PGresult* res = PQexecParams( + conn, + "SELECT column_name, data_type, is_nullable, " + "character_maximum_length, numeric_precision, numeric_scale " + "FROM information_schema.columns " + "WHERE table_schema = ANY (current_schemas(true)) " + "AND table_name = $1 " + "ORDER BY ordinal_position", + 1, nullptr, params, nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("describe_table", msg); + } + const int rows = PQntuples(res); + if (rows <= 0) { + PQclear(res); + return util::Error{5001, 0, "table not found or has no columns", + tbl->name}; + } + std::vector out; + out.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + const char* nullable = PQgetvalue(res, r, 2); + out.push_back(map_pg_column( + PQgetvalue(res, r, 0), + PQgetvalue(res, r, 1), + nullable && nullable[0] == 'Y', + std::atoi(PQgetvalue(res, r, 3)), + std::atoi(PQgetvalue(res, r, 4)), + std::atoi(PQgetvalue(res, r, 5)))); + } + PQclear(res); + tbl->fields = out; + tbl->fields_cached = true; + return out; +} + +util::Result position_at_pk(PGconn* conn, PostgresTable* tbl, + const PostgresTable::PkRow& pk) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid postgres table state", ""}; + } + auto it = std::find_if( + tbl->pk_snapshot.begin(), tbl->pk_snapshot.end(), + [&](const PostgresTable::PkRow& row) { + return row.values == pk.values; + }); + if (it == tbl->pk_snapshot.end()) { + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; + } + tbl->pos = static_cast( + std::distance(tbl->pk_snapshot.begin(), it)); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(conn, tbl); +} + +util::Result load_current_row(PGconn* conn, PostgresTable* tbl) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid postgres table state", ""}; + } + if (!tbl->positioned) { + tbl->row_valid = false; + tbl->current_row.clear(); + tbl->current_nulls.clear(); + return util::Result{}; + } + if (!tbl->fields_cached) { + auto d = describe_table_impl(conn, tbl); + if (!d) return d.error(); + } + + // Explicit column list in tbl->fields order (NOT "SELECT *"): keeps + // current_row[i] aligned with fields[i] regardless of physical column order. + std::string collist; + for (std::size_t i = 0; i < tbl->fields.size(); ++i) { + if (i > 0) collist += ", "; + collist += quote_ident(tbl->fields[i].name); + } + const std::string sql = + "SELECT " + collist + " FROM " + quote_ident(tbl->name) + " WHERE " + + pk_where_clause(*tbl); + const PostgresTable::PkRow& pk = tbl->pk_snapshot[tbl->pos]; + std::vector params; + params.reserve(pk.values.size()); + for (const std::string& v : pk.values) params.push_back(v.c_str()); + + PGresult* res = PQexecParams( + conn, sql.c_str(), static_cast(params.size()), nullptr, + params.data(), nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("load row", msg); + } + + tbl->current_row.clear(); + tbl->current_nulls.clear(); + tbl->row_valid = false; + + if (PQntuples(res) == 1) { + const int cols = PQnfields(res); + tbl->current_row.resize(static_cast(cols)); + tbl->current_nulls.resize(static_cast(cols)); + for (int c = 0; c < cols; ++c) { + bool is_null = false; + tbl->current_row[c] = format_pg_value(res, 0, c, is_null); + tbl->current_nulls[c] = is_null; + } + tbl->row_valid = true; + } else { + tbl->positioned = false; + } + PQclear(res); + return util::Result{}; +} + +#endif + +} // namespace + +struct PostgresConnection::Impl { +#if defined(OPENADS_WITH_POSTGRESQL) + PGconn* conn = nullptr; +#endif +}; + +PostgresConnection::PostgresConnection() = default; +PostgresConnection::~PostgresConnection() { disconnect(); } + +PostgresConnection::PostgresConnection(PostgresConnection&& other) noexcept + : impl_(std::move(other.impl_)), conninfo_(std::move(other.conninfo_)) {} + +PostgresConnection& PostgresConnection::operator=( + PostgresConnection&& other) noexcept { + if (this != &other) { + disconnect(); + impl_ = std::move(other.impl_); + conninfo_ = std::move(other.conninfo_); + } + return *this; +} + +util::Result PostgresConnection::open( + const PostgresUri& uri) { +#if defined(OPENADS_WITH_POSTGRESQL) + PostgresConnection conn; + conn.conninfo_ = uri.conninfo; + conn.impl_ = std::make_unique(); + + PGconn* raw = PQconnectdb(uri.conninfo.c_str()); + if (raw == nullptr) { + return util::Error{5001, 0, "PQconnectdb failed", ""}; + } + if (PQstatus(raw) != CONNECTION_OK) { + util::Error e = postgres_error("connect", PQerrorMessage(raw)); + PQfinish(raw); + return e; + } + conn.impl_->conn = raw; + return std::move(conn); +#else + (void)uri; + return util::Error{5004, 0, + "postgresql backend requires " + "OPENADS_WITH_POSTGRESQL=ON", + ""}; +#endif +} + +void PostgresConnection::disconnect() noexcept { +#if defined(OPENADS_WITH_POSTGRESQL) + if (impl_ && impl_->conn) { + PQfinish(impl_->conn); + impl_->conn = nullptr; + } +#endif + impl_.reset(); +} + +bool PostgresConnection::valid() const noexcept { +#if defined(OPENADS_WITH_POSTGRESQL) + return impl_ && impl_->conn != nullptr && + PQstatus(impl_->conn) == CONNECTION_OK; +#else + return false; +#endif +} + +util::Result> +PostgresConnection::open_table(const std::string& table_name) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid()) { + return util::Error{5001, 0, "postgres 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_columns(impl_->conn, table_name); + if (!pk) return pk.error(); + tbl->pk_columns = std::move(pk).value(); + + const std::string sel = pk_select_list(*tbl); + const std::string sql = + "SELECT " + sel + " FROM " + quote_ident(table_name) + + " ORDER BY " + sel; + PGresult* res = PQexec(impl_->conn, sql.c_str()); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(impl_->conn); + PQclear(res); + return postgres_error("pk snapshot", msg); + } + const int rows = PQntuples(res); + const int pk_cols = PQnfields(res); + tbl->pk_snapshot.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + PostgresTable::PkRow pk_row; + pk_row.values.resize(static_cast(pk_cols)); + for (int c = 0; c < pk_cols; ++c) { + pk_row.values[static_cast(c)] = + PQgetisnull(res, r, c) ? std::string{} + : PQgetvalue(res, r, c); + } + tbl->pk_snapshot.push_back(std::move(pk_row)); + } + PQclear(res); + + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); + tbl->rec_count_cached = true; + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->current_deleted = false; + tbl->pos = 0; + + if (auto d = describe_table_impl(impl_->conn, tbl.get()); !d) { + return d.error(); + } + return tbl; +#else + (void)table_name; + return util::Error{5004, 0, + "postgresql backend requires " + "OPENADS_WITH_POSTGRESQL=ON", + ""}; +#endif +} + +util::Result PostgresConnection::goto_top(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres goto_top", ""}; + } + if (tbl->pk_snapshot.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = 0; + tbl->positioned = true; + tbl->current_recno = 1; + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::goto_bottom(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres goto_bottom", ""}; + } + if (tbl->pk_snapshot.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = tbl->pk_snapshot.size() - 1; + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::skip(PostgresTable* tbl, + std::int32_t step) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres skip", ""}; + } + if (step == 0) return util::Result{}; + if (tbl->pk_snapshot.empty()) { + 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->pk_snapshot.size()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = tbl->pk_snapshot.size(); + return util::Result{}; + } + + tbl->pos = static_cast(next); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + (void)step; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::at_eof(PostgresTable* tbl) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres at_eof", ""}; + } + if (tbl->pk_snapshot.empty()) return true; + if (!tbl->positioned && tbl->pos >= tbl->pk_snapshot.size()) return true; + return false; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::at_bof(PostgresTable* tbl) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres at_bof", ""}; + } + if (tbl->pk_snapshot.empty()) return true; + return !tbl->positioned && tbl->pos == 0; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::record_count(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres 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; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result> +PostgresConnection::describe_table(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres describe_table", ""}; + } + if (tbl->fields_cached) return tbl->fields; + return describe_table_impl(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::read_field( + PostgresTable* tbl, const std::string& field_name, + std::string& buf, bool& is_null) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres 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{}; +#else + (void)tbl; + (void)field_name; + (void)buf; + (void)is_null; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::seek_index( + PostgresTable* tbl, const std::string& column, const std::string& key, + bool soft, bool last_key) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres seek", ""}; + } + if (!is_safe_identifier(column)) { + return util::Error{5001, 0, "invalid seek column", column}; + } + if (!tbl->fields_cached) { + if (auto d = describe_table_impl(impl_->conn, 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 sel = pk_select_list(*tbl); + const std::string qcol = quote_ident(column); + const std::string from = " FROM " + quote_ident(tbl->name) + " WHERE "; + + const std::string pk_asc = pk_order_by(*tbl, "ASC"); + const std::string pk_desc = pk_order_by(*tbl, "DESC"); + + std::string sql; + if (last_key) { + sql = soft + ? "SELECT " + sel + from + qcol + " <= $1 ORDER BY " + qcol + + " DESC, " + pk_desc + " LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + pk_desc + + " LIMIT 1"; + } else { + sql = soft + ? "SELECT " + sel + from + qcol + " >= $1 ORDER BY " + qcol + + " ASC, " + pk_asc + " LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + pk_asc + + " LIMIT 1"; + } + + const char* params[1] = {key.c_str()}; + PGresult* res = PQexecParams(impl_->conn, sql.c_str(), 1, nullptr, params, + nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(impl_->conn); + PQclear(res); + return postgres_error("seek", msg); + } + + bool found = false; + if (PQntuples(res) == 1) { + const int pk_cols = PQnfields(res); + PostgresTable::PkRow pk; + pk.values.resize(static_cast(pk_cols)); + for (int c = 0; c < pk_cols; ++c) { + pk.values[static_cast(c)] = + PQgetisnull(res, 0, c) ? std::string{} + : PQgetvalue(res, 0, c); + } + PQclear(res); + if (auto p = position_at_pk(impl_->conn, tbl, pk); !p) { + return p.error(); + } + found = tbl->positioned && tbl->row_valid; + tbl->last_seek_found = found; + } else { + PQclear(res); + tbl->positioned = false; + tbl->row_valid = false; + found = false; + tbl->last_seek_found = false; + } + return found; +#else + (void)tbl; + (void)column; + (void)key; + (void)soft; + (void)last_key; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +} // namespace openads::sql_backend diff --git a/src/sql_backend/postgres_connection.h b/src/sql_backend/postgres_connection.h new file mode 100644 index 00000000..b42b9d64 --- /dev/null +++ b/src/sql_backend/postgres_connection.h @@ -0,0 +1,62 @@ +#pragma once + +#include "sql_backend/postgres_table.h" +#include "sql_backend/postgres_uri.h" +#include "util/result.h" + +#include +#include +#include + +namespace openads::sql_backend { + +class PostgresConnection { +public: + PostgresConnection(); + ~PostgresConnection(); + + PostgresConnection(PostgresConnection&&) noexcept; + PostgresConnection& operator=(PostgresConnection&&) noexcept; + + PostgresConnection(const PostgresConnection&) = delete; + PostgresConnection& operator=(const PostgresConnection&) = delete; + + static util::Result open(const PostgresUri& uri); + + void disconnect() noexcept; + bool valid() const noexcept; + + util::Result> + open_table(const std::string& table_name); + + util::Result goto_top(PostgresTable* tbl); + util::Result goto_bottom(PostgresTable* tbl); + util::Result skip(PostgresTable* tbl, std::int32_t step); + + util::Result at_eof(PostgresTable* tbl) const; + util::Result at_bof(PostgresTable* tbl) const; + util::Result record_count(PostgresTable* tbl); + + util::Result> + describe_table(PostgresTable* tbl); + + util::Result read_field(PostgresTable* tbl, + const std::string& field_name, + std::string& buf, + bool& is_null) const; + + util::Result seek_index(PostgresTable* tbl, + const std::string& column, + const std::string& key, + bool soft, + bool last_key); + + const std::string& conninfo() const noexcept { return conninfo_; } + +private: + struct Impl; + std::unique_ptr impl_; + std::string conninfo_; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_index.h b/src/sql_backend/postgres_index.h new file mode 100644 index 00000000..47360482 --- /dev/null +++ b/src/sql_backend/postgres_index.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +struct PostgresTable; + +struct PostgresIndex { + PostgresTable* parent = nullptr; + std::string column; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_table.h b/src/sql_backend/postgres_table.h new file mode 100644 index 00000000..18d6bfd0 --- /dev/null +++ b/src/sql_backend/postgres_table.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +namespace openads::sql_backend { + +class PostgresConnection; + +struct PostgresTable { + PostgresConnection* 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; + }; + std::vector pk_snapshot; + std::size_t pos = 0; + bool positioned = false; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_uri.cpp b/src/sql_backend/postgres_uri.cpp new file mode 100644 index 00000000..ccf0b4a4 --- /dev/null +++ b/src/sql_backend/postgres_uri.cpp @@ -0,0 +1,28 @@ +#include "sql_backend/postgres_uri.h" + +namespace openads::sql_backend { + +bool parse_postgres_uri(const std::string& uri, PostgresUri& out) { + static constexpr const char* kPg = "postgresql://"; + static constexpr const char* kPg2 = "postgres://"; + static constexpr const char* kPg3 = "pgsql://"; + const auto plen = std::char_traits::length(kPg); + const auto plen2 = std::char_traits::length(kPg2); + const auto plen3 = std::char_traits::length(kPg3); + + if (uri.size() >= plen && uri.compare(0, plen, kPg) == 0) { + out.conninfo = uri; + return true; + } + if (uri.size() >= plen2 && uri.compare(0, plen2, kPg2) == 0) { + out.conninfo = "postgresql://" + uri.substr(plen2); + return true; + } + if (uri.size() >= plen3 && uri.compare(0, plen3, kPg3) == 0) { + out.conninfo = "postgresql://" + uri.substr(plen3); + return true; + } + return false; +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_uri.h b/src/sql_backend/postgres_uri.h new file mode 100644 index 00000000..c58502ce --- /dev/null +++ b/src/sql_backend/postgres_uri.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +// Full libpq connection URI after `postgresql://`, `postgres://`, or `pgsql://`. +struct PostgresUri { + std::string conninfo; +}; + +bool parse_postgres_uri(const std::string& uri, PostgresUri& out); + +} // namespace openads::sql_backend \ No newline at end of file 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/src/sql_backend/sqlite_backend.cpp b/src/sql_backend/sqlite_backend.cpp index 7c7dfb71..7afa1197 100644 --- a/src/sql_backend/sqlite_backend.cpp +++ b/src/sql_backend/sqlite_backend.cpp @@ -9,20 +9,9 @@ namespace openads::sql_backend { -bool is_safe_identifier(const std::string& name) { - if (name.empty()) return false; - // Locale-independent ASCII check: std::isalnum depends on the active - // C locale, which could admit unexpected bytes into an identifier we - // splice into SQL. Keep it strict regardless of locale. - 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; -} +// is_safe_identifier lives in sql_common.cpp (shared by every SQL backend) so +// that an integration build with SQLite + another SQL backend does not get a +// duplicate-symbol clash. Declared in sqlite_backend.h / sql_common.h. SqliteTable::FieldDesc map_sqlite_column(const char* name, const char* declared_type, @@ -101,6 +90,7 @@ util::Error sqlite_error(sqlite3* db, const char* context) { ""}; } +#if defined(OPENADS_WITH_SQLCIPHER) namespace { [[maybe_unused]] util::Result exec_pragma(sqlite3* db, const std::string& sql) { @@ -125,6 +115,7 @@ namespace { } } // namespace +#endif // OPENADS_WITH_SQLCIPHER util::Result apply_cipher_key(sqlite3* db, const std::string& key) { if (key.empty()) return util::Result{}; diff --git a/src/sql_backend/sqlite_connection.cpp b/src/sql_backend/sqlite_connection.cpp index 5955c67f..a0b169ee 100644 --- a/src/sql_backend/sqlite_connection.cpp +++ b/src/sql_backend/sqlite_connection.cpp @@ -81,6 +81,19 @@ util::Result load_current_row(sqlite3* db, SqliteTable* tbl) { tbl->current_nulls.clear(); return util::Result{}; } + if (tbl->is_result) { + // Materialized result cursor: serve the row from memory, no query. + if (tbl->pos < tbl->result_rows.size()) { + tbl->current_row = tbl->result_rows[tbl->pos]; + tbl->current_nulls = tbl->result_nulls[tbl->pos]; + tbl->row_valid = true; + } else { + tbl->current_row.clear(); + tbl->current_nulls.clear(); + tbl->row_valid = false; + } + return util::Result{}; + } if (!tbl->fields_cached) { auto d = describe_table_impl(db, tbl); if (!d) return d.error(); @@ -155,12 +168,12 @@ util::Result SqliteConnection::open(const SqliteUri& uri) { conn.impl_ = std::make_unique(); sqlite3* raw = nullptr; - // Phase-1 driver is read-only: open an existing database, never - // create. A mistyped path must fail loudly, not silently spawn an - // empty database file. + // SQL passthrough (AdsExecuteSQLDirect) adds DDL/DML, so the backend opens + // read-write and creates the file on first use — a Harbour application can + // CREATE its own database via SQL. const int rc = sqlite3_open_v2( uri.path.c_str(), &raw, - SQLITE_OPEN_READONLY, nullptr); + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr); if (rc != SQLITE_OK) { util::Error e = sqlite_error(raw, "sqlite3_open"); if (raw) sqlite3_close(raw); @@ -524,4 +537,71 @@ util::Result SqliteConnection::seek_index(SqliteTable* tbl, #endif } +util::Result> +SqliteConnection::run_sql(const std::string& sql) { +#if defined(OPENADS_WITH_SQLITE) + if (!valid()) return util::Error{5001, 0, "sqlite connection not open", ""}; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(impl_->db, sql.c_str(), + static_cast(sql.size()), + &stmt, nullptr) != SQLITE_OK) { + return sqlite_error(impl_->db, "prepare sql"); + } + + const int cols = sqlite3_column_count(stmt); + if (cols == 0) { + // Non-result statement (INSERT/UPDATE/DELETE/DDL): run to completion + // and report no cursor — SQLite already told us there are no columns. + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { /* no rows */ } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return sqlite_error(impl_->db, "exec sql"); + return std::unique_ptr{}; + } + + // Result-producing statement: materialize into a navigable cursor. + auto tbl = std::make_unique(); + tbl->conn = this; + tbl->name = "(result)"; + tbl->is_result = true; + tbl->fields.reserve(static_cast(cols)); + for (int c = 0; c < cols; ++c) { + const char* cn = sqlite3_column_name(stmt, c); + const char* dt = sqlite3_column_decltype(stmt, c); + tbl->fields.push_back(map_sqlite_column(cn ? cn : "", dt, false)); + } + tbl->fields_cached = true; + + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + std::vector row(static_cast(cols)); + std::vector nul(static_cast(cols)); + for (int c = 0; c < cols; ++c) { + bool is_null = false; + row[static_cast(c)] = + format_sqlite_value(stmt, c, is_null); + nul[static_cast(c)] = is_null; + } + tbl->result_rows.push_back(std::move(row)); + tbl->result_nulls.push_back(std::move(nul)); + } + if (rc != SQLITE_DONE) { + sqlite3_finalize(stmt); + return sqlite_error(impl_->db, "step query"); + } + sqlite3_finalize(stmt); + + tbl->cached_rec_count = static_cast(tbl->result_rows.size()); + tbl->rec_count_cached = true; + // Synthetic rowid slots so record_count + nav bounds reuse the shared path. + tbl->rowids.resize(tbl->result_rows.size()); + tbl->positioned = false; + tbl->pos = 0; + return tbl; +#else + (void)sql; + return util::Error{5004, 0, "sqlite backend disabled", ""}; +#endif +} + } // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/sqlite_connection.h b/src/sql_backend/sqlite_connection.h index cf003397..ff3271da 100644 --- a/src/sql_backend/sqlite_connection.h +++ b/src/sql_backend/sqlite_connection.h @@ -55,6 +55,13 @@ class SqliteConnection { std::string& buf, bool& is_null) const; + // AdsExecuteSQLDirect passthrough: prepare and run any statement. Returns a + // materialized, navigable result cursor when the statement produces rows + // (column count > 0), or a null pointer for a non-result statement + // (INSERT/UPDATE/DELETE/DDL), which is executed to completion. SQLite itself + // classifies the statement, so no SQL keyword parsing is needed. + util::Result> run_sql(const std::string& sql); + const std::string& db_path() const noexcept { return db_path_; } private: diff --git a/src/sql_backend/sqlite_table.h b/src/sql_backend/sqlite_table.h index 432a3cf4..fa29e117 100644 --- a/src/sql_backend/sqlite_table.h +++ b/src/sql_backend/sqlite_table.h @@ -41,6 +41,13 @@ struct SqliteTable { std::vector current_nulls; bool last_seek_found = false; + + // Result-set cursor mode (AdsExecuteSQLDirect SELECT passthrough): rows are + // materialized in memory instead of fetched per-rowid from a base table, so + // navigation serves `current_row` straight from `result_rows[pos]`. + bool is_result = false; + std::vector> result_rows; + std::vector> result_nulls; }; } // namespace openads::sql_backend \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ab67391a..805b48dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -37,6 +37,8 @@ add_executable(openads_unit_tests unit/engine_table_write_test.cpp unit/engine_cursor_test.cpp unit/session_handle_registry_test.cpp + unit/handle_registry_kind_of_test.cpp + unit/backend_registry_test.cpp unit/session_connection_test.cpp unit/abi_smoke_test.cpp unit/abi_write_smoke_test.cpp @@ -64,6 +66,7 @@ add_executable(openads_unit_tests unit/abi_sql_smoke_test.cpp unit/abi_plus_sqlite_read_test.cpp unit/abi_plus_sqlite_seek_test.cpp + unit/abi_plus_sqlite_passthrough_test.cpp unit/lsn_map_test.cpp unit/index_expr_test.cpp unit/abi_m92_real_test.cpp @@ -157,6 +160,18 @@ add_executable(openads_unit_tests unit/abi_no_current_record_test.cpp ) +if(OPENADS_WITH_POSTGRESQL) + target_sources(openads_unit_tests PRIVATE + unit/abi_plus_postgres_read_test.cpp + unit/abi_plus_mariadb_read_test.cpp + unit/abi_plus_odbc_read_test.cpp + unit/abi_plus_odbc_seek_test.cpp + unit/odbc_uri_test.cpp + unit/abi_plus_postgres_seek_test.cpp + unit/abi_plus_mariadb_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_adi_smoke_test.cpp b/tests/unit/abi_adi_smoke_test.cpp index 536178fd..d1383d13 100644 --- a/tests/unit/abi_adi_smoke_test.cpp +++ b/tests/unit/abi_adi_smoke_test.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace fs = std::filesystem; diff --git a/tests/unit/abi_misc_impls_test.cpp b/tests/unit/abi_misc_impls_test.cpp index 9ed54ba4..905ce2d8 100644 --- a/tests/unit/abi_misc_impls_test.cpp +++ b/tests/unit/abi_misc_impls_test.cpp @@ -2,6 +2,7 @@ #include "openads/ace.h" #include "openads/error.h" +#include #include #include #include diff --git a/tests/unit/abi_plus_mariadb_read_test.cpp b/tests/unit/abi_plus_mariadb_read_test.cpp new file mode 100644 index 00000000..59a2ede9 --- /dev/null +++ b/tests/unit/abi_plus_mariadb_read_test.cpp @@ -0,0 +1,135 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_MARIADB) +#include "sql_backend/maria_uri.h" +#include +#endif + +#if defined(OPENADS_WITH_MARIADB) + +namespace { + +constexpr const char* kDefaultMariaUri = + "mariadb://root@127.0.0.1:3306/test"; + +const char* test_maria_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_MARIADB_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultMariaUri; +} + +void seed_fixture(MYSQL* conn) { + auto exec = [&](const char* sql) { + if (mysql_query(conn, sql) != 0) { + const char* msg = mysql_error(conn); + std::string detail = "seed failed"; + if (msg != nullptr && msg[0] != '\0') { + detail += ": "; + detail += msg; + } + FAIL(detail); + } + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (" + "id INT PRIMARY KEY, nome VARCHAR(64), saldo DOUBLE)"); + exec("INSERT INTO clientes (id, nome, saldo) VALUES " + "(1, 'Ana', 10.5), (2, 'Bob', NULL), (3, 'Cid', 0.0)"); +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI: mariadb read-only AdsOpenTable navigation") { + const char* uri_cstr = test_maria_uri(); + + MYSQL* seed = mysql_init(nullptr); + REQUIRE(seed != nullptr); + openads::sql_backend::MariaUri muri; + REQUIRE(openads::sql_backend::parse_maria_uri(uri_cstr, muri)); + const char* host = muri.host.empty() ? "127.0.0.1" : muri.host.c_str(); + const char* user = muri.user.empty() ? nullptr : muri.user.c_str(); + const char* pass = muri.password.empty() ? nullptr : muri.password.c_str(); + const char* db = muri.database.empty() ? nullptr : muri.database.c_str(); + REQUIRE(mysql_real_connect(seed, host, user, pass, db, muri.port, + nullptr, 0) != nullptr); + seed_fixture(seed); + mysql_close(seed); + + const std::string uri = uri_cstr; + 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); + + 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); + + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Ana")); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "saldo").empty()); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Cid")); + + UNSIGNED16 eof = 0; + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 0); + + REQUIRE(AdsSkip(hTable, 1) == 0); + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 1); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#else + +TEST_CASE("ABI: mariadb backend disabled at compile time") { + UNSIGNED8 uri[] = "mariadb://127.0.0.1/none"; + ADSHANDLE hConn = 0; + const UNSIGNED32 rc = AdsConnect60(uri, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn); + CHECK(rc == openads::AE_FUNCTION_NOT_AVAILABLE); +} + +#endif \ No newline at end of file diff --git a/tests/unit/abi_plus_mariadb_seek_test.cpp b/tests/unit/abi_plus_mariadb_seek_test.cpp new file mode 100644 index 00000000..c8fd11be --- /dev/null +++ b/tests/unit/abi_plus_mariadb_seek_test.cpp @@ -0,0 +1,114 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_MARIADB) +#include "sql_backend/maria_uri.h" +#include +#endif + +#if defined(OPENADS_WITH_MARIADB) + +namespace { + +constexpr const char* kDefaultMariaUri = + "mariadb://root@127.0.0.1:3306/test"; + +const char* test_maria_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_MARIADB_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultMariaUri; +} + +void seed_fixture(MYSQL* conn) { + auto exec = [&](const char* sql) { + if (mysql_query(conn, sql) != 0) { + FAIL("seed failed"); + } + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (id INT PRIMARY KEY, nome VARCHAR(64))"); + exec("INSERT INTO clientes (id, nome) VALUES (1,'Ana'),(2,'Bob'),(3,'Cid')"); +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI: mariadb AdsSeek on column index") { + const char* uri_cstr = test_maria_uri(); + + MYSQL* seed = mysql_init(nullptr); + REQUIRE(seed != nullptr); + openads::sql_backend::MariaUri muri; + REQUIRE(openads::sql_backend::parse_maria_uri(uri_cstr, muri)); + const char* host = muri.host.empty() ? "127.0.0.1" : muri.host.c_str(); + const char* user = muri.user.empty() ? nullptr : muri.user.c_str(); + const char* pass = muri.password.empty() ? nullptr : muri.password.c_str(); + const char* db = muri.database.empty() ? nullptr : muri.database.c_str(); + REQUIRE(mysql_real_connect(seed, host, user, pass, db, muri.port, + nullptr, 0) != nullptr); + seed_fixture(seed); + mysql_close(seed); + + const std::string uri = uri_cstr; + 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[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl, tbl, ADS_DEFAULT, 0, 0, 0, + ADS_READONLY, &hTable) == 0); + + UNSIGNED8 tag[16] = "id"; + ADSHANDLE hIndex = 0; + UNSIGNED16 nIdx = 1; + REQUIRE(AdsOpenIndex(hTable, tag, &hIndex, &nIdx) == 0); + CHECK(nIdx == 1); + + const char key[] = "2"; + UNSIGNED16 found = 0; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(key)), + static_cast(std::strlen(key)), + ADS_STRINGKEY, 0, &found) == 0); + CHECK(found == 1); + + UNSIGNED16 is_found = 0; + REQUIRE(AdsIsFound(hTable, &is_found) == 0); + CHECK(is_found == 1); + + CHECK(field_str(hTable, "nome").find("Bob") != std::string::npos); + + const char miss[] = "9"; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(miss)), + static_cast(std::strlen(miss)), + ADS_STRINGKEY, 0, &found) == 0); + CHECK(found == 0); + + REQUIRE(AdsCloseIndex(hIndex) == 0); + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif \ No newline at end of file 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_postgres_read_test.cpp b/tests/unit/abi_plus_postgres_read_test.cpp new file mode 100644 index 00000000..5c7a48da --- /dev/null +++ b/tests/unit/abi_plus_postgres_read_test.cpp @@ -0,0 +1,129 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) + +namespace { + +constexpr const char* kDefaultPgUri = + "postgresql://postgres@127.0.0.1:5433/postgres"; + +const char* test_pg_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultPgUri; +} + +void seed_fixture(PGconn* conn) { + auto exec = [&](const char* sql) { + PGresult* res = PQexec(conn, sql); + if (PQresultStatus(res) != PGRES_COMMAND_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + std::string detail = "seed failed"; + if (msg != nullptr && msg[0] != '\0') { + detail += ": "; + detail += msg; + } + FAIL(detail); + } + PQclear(res); + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (" + "id INTEGER PRIMARY KEY, nome TEXT, saldo DOUBLE PRECISION)"); + exec("INSERT INTO clientes (id, nome, saldo) VALUES " + "(1, 'Ana', 10.5), (2, 'Bob', NULL), (3, 'Cid', 0.0)"); +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI: postgresql read-only AdsOpenTable navigation") { + const char* uri_cstr = test_pg_uri(); + + PGconn* seed = PQconnectdb(uri_cstr); + REQUIRE(PQstatus(seed) == CONNECTION_OK); + seed_fixture(seed); + PQfinish(seed); + + const std::string uri = uri_cstr; + 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); + + 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); + + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Ana")); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "saldo").empty()); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Cid")); + + UNSIGNED16 eof = 0; + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 0); + + REQUIRE(AdsSkip(hTable, 1) == 0); + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 1); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#else + +TEST_CASE("ABI: postgresql backend disabled at compile time") { + UNSIGNED8 uri[] = "postgresql://127.0.0.1/none"; + ADSHANDLE hConn = 0; + const UNSIGNED32 rc = AdsConnect60(uri, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn); + CHECK(rc == openads::AE_FUNCTION_NOT_AVAILABLE); +} + +#endif \ No newline at end of file diff --git a/tests/unit/abi_plus_postgres_seek_test.cpp b/tests/unit/abi_plus_postgres_seek_test.cpp new file mode 100644 index 00000000..015ecbdc --- /dev/null +++ b/tests/unit/abi_plus_postgres_seek_test.cpp @@ -0,0 +1,108 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) + +namespace { + +constexpr const char* kDefaultPgUri = + "postgresql://postgres@127.0.0.1:5433/postgres"; + +const char* test_pg_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultPgUri; +} + +void seed_fixture(PGconn* conn) { + auto exec = [&](const char* sql) { + PGresult* res = PQexec(conn, sql); + if (PQresultStatus(res) != PGRES_COMMAND_OK) { + PQclear(res); + FAIL("seed failed"); + } + PQclear(res); + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nome TEXT)"); + exec("INSERT INTO clientes (id, nome) VALUES (1,'Ana'),(2,'Bob'),(3,'Cid')"); +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI: postgresql AdsSeek on column index") { + const char* uri_cstr = test_pg_uri(); + + PGconn* seed = PQconnectdb(uri_cstr); + REQUIRE(PQstatus(seed) == CONNECTION_OK); + seed_fixture(seed); + PQfinish(seed); + + const std::string uri = uri_cstr; + 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[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl, tbl, ADS_DEFAULT, 0, 0, 0, + ADS_READONLY, &hTable) == 0); + + UNSIGNED8 tag[16] = "id"; + ADSHANDLE hIndex = 0; + UNSIGNED16 nIdx = 1; + REQUIRE(AdsOpenIndex(hTable, tag, &hIndex, &nIdx) == 0); + CHECK(nIdx == 1); + + const char key[] = "2"; + UNSIGNED16 found = 0; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(key)), + static_cast(std::strlen(key)), + ADS_STRINGKEY, 0, &found) == 0); + CHECK(found == 1); + + UNSIGNED16 is_found = 0; + REQUIRE(AdsIsFound(hTable, &is_found) == 0); + CHECK(is_found == 1); + + CHECK(field_str(hTable, "nome").find("Bob") != std::string::npos); + + const char miss[] = "9"; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(miss)), + static_cast(std::strlen(miss)), + ADS_STRINGKEY, 0, &found) == 0); + CHECK(found == 0); + + REQUIRE(AdsCloseIndex(hIndex) == 0); + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif \ No newline at end of file diff --git a/tests/unit/abi_plus_sqlite_passthrough_test.cpp b/tests/unit/abi_plus_sqlite_passthrough_test.cpp new file mode 100644 index 00000000..efd75d66 --- /dev/null +++ b/tests/unit/abi_plus_sqlite_passthrough_test.cpp @@ -0,0 +1,98 @@ +#include "doctest.h" +#include "openads/ace.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_SQLITE) + +namespace fs = std::filesystem; + +namespace { + +// Run one statement through AdsExecuteSQLDirect; returns the cursor handle +// (0 for DDL/DML, non-zero for a result-producing statement). +ADSHANDLE exec_direct(ADSHANDLE hConn, const char* sql) { + ADSHANDLE hStmt = 0; + REQUIRE(AdsCreateSQLStatement(hConn, &hStmt) == 0); + std::vector buf(std::strlen(sql) + 1); + std::memcpy(buf.data(), sql, std::strlen(sql) + 1); + ADSHANDLE hCursor = 0; + REQUIRE(AdsExecuteSQLDirect(hStmt, buf.data(), &hCursor) == 0); + AdsCloseSQLStatement(hStmt); + return hCursor; +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI Plus: sqlite AdsExecuteSQLDirect passthrough (DDL + DML + SELECT)") { + const auto dir = fs::temp_directory_path() / "openads_plus_sqlpass"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + const auto db_path = dir / "pass.db"; + + const std::string uri = "sqlite://" + db_path.string(); + 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); + + // DDL + DML straight through the ACE ABI — no cursor returned. + CHECK(exec_direct(hConn, "CREATE TABLE t(a INTEGER, b TEXT)") == 0); + CHECK(exec_direct(hConn, + "INSERT INTO t(a,b) VALUES (1,'x'),(2,'y'),(3,'z')") == 0); + + // SELECT through the ABI returns a navigable, materialized result cursor. + ADSHANDLE hCur = exec_direct(hConn, + "SELECT a, b FROM t WHERE a >= 2 ORDER BY a"); + REQUIRE(hCur != 0); + + UNSIGNED32 cnt = 0; + REQUIRE(AdsGetRecordCount(hCur, 0, &cnt) == 0); + CHECK(cnt == 2); + + REQUIRE(AdsGotoTop(hCur) == 0); + CHECK(field_str(hCur, "a").substr(0, 1) == "2"); + CHECK(field_str(hCur, "b").substr(0, 1) == "y"); + + REQUIRE(AdsSkip(hCur, 1) == 0); + CHECK(field_str(hCur, "a").substr(0, 1) == "3"); + CHECK(field_str(hCur, "b").substr(0, 1) == "z"); + + REQUIRE(AdsSkip(hCur, 1) == 0); + UNSIGNED16 eof = 0; + REQUIRE(AdsAtEOF(hCur, &eof) == 0); + CHECK(eof == 1); + + REQUIRE(AdsCloseTable(hCur) == 0); + + // The CREATE/INSERT really persisted: reopen the base table and count. + UNSIGNED8 tn[32] = "t"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hConn, tn, tn, ADS_DEFAULT, 0, 0, 0, + ADS_READONLY, &hT) == 0); + UNSIGNED32 total = 0; + REQUIRE(AdsGetRecordCount(hT, 0, &total) == 0); + CHECK(total == 3); + REQUIRE(AdsCloseTable(hT) == 0); + + REQUIRE(AdsDisconnect(hConn) == 0); + fs::remove_all(dir, ec); +} + +#endif // OPENADS_WITH_SQLITE diff --git a/tests/unit/abi_refresh_extract_test.cpp b/tests/unit/abi_refresh_extract_test.cpp index 1fa5029c..a613107b 100644 --- a/tests/unit/abi_refresh_extract_test.cpp +++ b/tests/unit/abi_refresh_extract_test.cpp @@ -3,6 +3,7 @@ #include "drivers/cdx/cdx_index.h" +#include #include #include #include diff --git a/tests/unit/abi_sql_system_tables_test.cpp b/tests/unit/abi_sql_system_tables_test.cpp index 01da8390..b92ca1cc 100644 --- a/tests/unit/abi_sql_system_tables_test.cpp +++ b/tests/unit/abi_sql_system_tables_test.cpp @@ -2,6 +2,7 @@ #include "openads/ace.h" #include "openads/error.h" +#include #include #include #include diff --git a/tests/unit/backend_registry_test.cpp b/tests/unit/backend_registry_test.cpp new file mode 100644 index 00000000..1838e1bc --- /dev/null +++ b/tests/unit/backend_registry_test.cpp @@ -0,0 +1,26 @@ +#include "doctest.h" +#include "abi/backend_table_ops.h" +#include "abi/backend_registry.h" +#include "session/handle_registry.h" + +using namespace openads::abi; +using openads::session::HandleKind; + +static UNSIGNED32 fake_goto_top(ADSHANDLE) { return 4242; } + +TEST_CASE("ops_for_kind returns registered ops, null for unregistered kinds") { + static const BackendTableOps ops = [] { + BackendTableOps o{}; + o.goto_top = &fake_goto_top; + return o; + }(); + // Snapshot the real registration so this test does not leave the fake ops + // in the process-global table for later tests in the same binary. + const BackendTableOps* saved = ops_for_kind(HandleKind::SqliteTable); + register_backend_table_ops(HandleKind::SqliteTable, &ops); + CHECK(ops_for_kind(HandleKind::SqliteTable) == &ops); + CHECK(ops_for_kind(HandleKind::SqliteTable)->goto_top(0) == 4242u); + CHECK(ops_for_kind(HandleKind::Table) == nullptr); // native: never registered + CHECK(ops_for_kind(HandleKind::None) == nullptr); + register_backend_table_ops(HandleKind::SqliteTable, saved); // restore +} diff --git a/tests/unit/handle_registry_kind_of_test.cpp b/tests/unit/handle_registry_kind_of_test.cpp new file mode 100644 index 00000000..58ae28b5 --- /dev/null +++ b/tests/unit/handle_registry_kind_of_test.cpp @@ -0,0 +1,15 @@ +#include "doctest.h" +#include "session/handle_registry.h" + +using openads::session::HandleRegistry; +using openads::session::HandleKind; + +TEST_CASE("kind_of returns the registered kind, None for unknown") { + HandleRegistry reg; + int dummy = 0; + auto h = reg.register_object(HandleKind::SqliteTable, &dummy); + CHECK(reg.kind_of(h) == HandleKind::SqliteTable); + CHECK(reg.kind_of(h + 999) == HandleKind::None); + reg.release(h); + CHECK(reg.kind_of(h) == HandleKind::None); +} diff --git a/tests/unit/index_expr_utf8_test.cpp b/tests/unit/index_expr_utf8_test.cpp index dcc29d4d..9ff129df 100644 --- a/tests/unit/index_expr_utf8_test.cpp +++ b/tests/unit/index_expr_utf8_test.cpp @@ -2,6 +2,7 @@ #include "engine/index_expr.h" #include "engine/table.h" +#include #include #include #include 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/tests/unit/sql_parser_test.cpp b/tests/unit/sql_parser_test.cpp index 8d5f6b6f..23637290 100644 --- a/tests/unit/sql_parser_test.cpp +++ b/tests/unit/sql_parser_test.cpp @@ -1,6 +1,8 @@ #include "doctest.h" #include "sql/parser.h" +#include + using openads::sql::parse_select; using openads::sql::WhereExpr; using openads::sql::WhereOp; diff --git a/tools/scripts/build_msvc_x64_mariadb.bat b/tools/scripts/build_msvc_x64_mariadb.bat new file mode 100644 index 00000000..fdbab4c9 --- /dev/null +++ b/tools/scripts/build_msvc_x64_mariadb.bat @@ -0,0 +1,27 @@ +@echo off +REM MariaDB backend build with MSVC cl (no winlibs GCC runtime at run time). +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds the + echo msvc, winlibs and mariadb client dependency folders. + exit /b 1 +) +if not defined OPENADS_LIBMARIADB_INCLUDE ( + set "OPENADS_LIBMARIADB_INCLUDE=%OPENADS_TOOLCHAIN_ROOT%\mariadb\include" +) +if not defined OPENADS_LIBMARIADB_LIBRARY ( + set "OPENADS_LIBMARIADB_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\mariadb\lib\libmariadb64.lib" +) +call "%OPENADS_TOOLCHAIN_ROOT%\msvc\setup_x64.bat" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) +cd /d "%~dp0..\.." +cmake -S . -B build\maria-msvc -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ^ + -DOPENADS_WITH_MARIADB=ON -DOPENADS_WITH_HTTP=OFF ^ + -DOPENADS_WARNINGS_AS_ERRORS=OFF ^ + -DOPENADS_LIBMARIADB_INCLUDE=%OPENADS_LIBMARIADB_INCLUDE% ^ + -DOPENADS_LIBMARIADB_LIBRARY=%OPENADS_LIBMARIADB_LIBRARY% +if errorlevel 1 exit /b 1 +cmake --build build\maria-msvc +exit /b %ERRORLEVEL% \ No newline at end of file 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/build_msvc_x64_postgres.bat b/tools/scripts/build_msvc_x64_postgres.bat new file mode 100644 index 00000000..e2b3a543 --- /dev/null +++ b/tools/scripts/build_msvc_x64_postgres.bat @@ -0,0 +1,27 @@ +@echo off +REM PostgreSQL backend build with MSVC cl (no winlibs GCC runtime at run time). +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds the + echo msvc, winlibs and pgsql dependency folders. + exit /b 1 +) +if not defined OPENADS_LIBPQ_INCLUDE ( + set "OPENADS_LIBPQ_INCLUDE=%OPENADS_TOOLCHAIN_ROOT%\pgsql\include" +) +if not defined OPENADS_LIBPQ_LIBRARY ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" +) +call "%OPENADS_TOOLCHAIN_ROOT%\msvc\setup_x64.bat" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) +cd /d "%~dp0..\.." +cmake -S . -B build\pg-msvc -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ^ + -DOPENADS_WITH_POSTGRESQL=ON -DOPENADS_WITH_HTTP=OFF ^ + -DOPENADS_WARNINGS_AS_ERRORS=OFF ^ + -DOPENADS_LIBPQ_INCLUDE=%OPENADS_LIBPQ_INCLUDE% ^ + -DOPENADS_LIBPQ_LIBRARY=%OPENADS_LIBPQ_LIBRARY% +if errorlevel 1 exit /b 1 +cmake --build build\pg-msvc +exit /b %ERRORLEVEL% \ No newline at end of file diff --git a/tools/scripts/build_nmake_postgres.bat b/tools/scripts/build_nmake_postgres.bat new file mode 100644 index 00000000..9a426cd4 --- /dev/null +++ b/tools/scripts/build_nmake_postgres.bat @@ -0,0 +1,29 @@ +@echo off +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to your MSVC toolchain root. + exit /b 1 +) +if not defined OPENADS_LIBPQ_INCLUDE ( + if exist "%OPENADS_TOOLCHAIN_ROOT%\pgsql\include\libpq-fe.h" ( + set "OPENADS_LIBPQ_INCLUDE=%OPENADS_TOOLCHAIN_ROOT%\pgsql\include" + ) +) +if not defined OPENADS_LIBPQ_LIBRARY ( + if exist "%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" + ) else if exist "%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" + ) +) +call "%OPENADS_TOOLCHAIN_ROOT%\msvc\setup_x86.bat" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) +cd /d "%~dp0..\.." +set "EXTRA=-DOPENADS_WITH_POSTGRESQL=ON -DOPENADS_WITH_HTTP=OFF -DOPENADS_WARNINGS_AS_ERRORS=OFF" +if defined OPENADS_LIBPQ_INCLUDE set "EXTRA=%EXTRA% -DOPENADS_LIBPQ_INCLUDE=%OPENADS_LIBPQ_INCLUDE%" +if defined OPENADS_LIBPQ_LIBRARY set "EXTRA=%EXTRA% -DOPENADS_LIBPQ_LIBRARY=%OPENADS_LIBPQ_LIBRARY%" +cmake -S . -B build\pg -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release %EXTRA% +if errorlevel 1 exit /b 1 +cmake --build build\pg +exit /b %ERRORLEVEL% \ No newline at end of file diff --git a/tools/scripts/run_mariadb_tests.bat b/tools/scripts/run_mariadb_tests.bat new file mode 100644 index 00000000..b6eb9b18 --- /dev/null +++ b/tools/scripts/run_mariadb_tests.bat @@ -0,0 +1,30 @@ +@echo off +REM Run MariaDB ABI e2e tests from build\maria-msvc (or pass BUILD_DIR). +setlocal +set "ROOT=%~dp0..\.." +set "BUILD=%ROOT%\build\maria-msvc" +if not "%~1"=="" set "BUILD=%~1" + +if not exist "%BUILD%\tests\openads_unit_tests.exe" ( + echo ERROR: %BUILD%\tests\openads_unit_tests.exe not found. Build first. + exit /b 1 +) + +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds the + echo mariadb client and winlibs (for libmariadb PATH). + exit /b 1 +) + +set "PATH=%OPENADS_TOOLCHAIN_ROOT%\mariadb\bin;%PATH%" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) + +if not defined OPENADS_TEST_MARIADB_URI ( + set "OPENADS_TEST_MARIADB_URI=mariadb://root@127.0.0.1:3306/test" +) + +cd /d "%BUILD%" +tests\openads_unit_tests.exe --test-case=*mariadb* %* +exit /b %ERRORLEVEL% \ No newline at end of file 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_postgres_tests.bat b/tools/scripts/run_postgres_tests.bat new file mode 100644 index 00000000..3af25bf8 --- /dev/null +++ b/tools/scripts/run_postgres_tests.bat @@ -0,0 +1,40 @@ +@echo off +REM Run PostgreSQL ABI e2e tests from build\pg (or pass BUILD_DIR). +REM +REM GCC/Ninja builds link against winlibs runtime DLLs. Before running the +REM exe by hand, prepend winlibs to PATH (libgcc_s_seh-1, libstdc++-6, +REM libwinpthread-1 live there): +REM set PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH% +REM This script does that automatically when OPENADS_TOOLCHAIN_ROOT is set. +REM Also needs pgsql\bin for libpq.dll. +REM +REM Cleaner long-term: build with MSVC cl (see build_msvc_x64_postgres.bat) so +REM no GCC runtime is required at test time. +setlocal +set "ROOT=%~dp0..\.." +set "BUILD=%ROOT%\build\pg" +if not "%~1"=="" set "BUILD=%~1" + +if not exist "%BUILD%\tests\openads_unit_tests.exe" ( + echo ERROR: %BUILD%\tests\openads_unit_tests.exe not found. Build first. + exit /b 1 +) + +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds pgsql + echo and winlibs (for libpq / winlibs PATH). + exit /b 1 +) + +set "PATH=%OPENADS_TOOLCHAIN_ROOT%\pgsql\bin;%PATH%" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) + +if not defined OPENADS_TEST_PG_URI ( + set "OPENADS_TEST_PG_URI=postgresql://postgres@127.0.0.1:5433/postgres" +) + +cd /d "%BUILD%" +tests\openads_unit_tests.exe --test-case=*postgresql* %* +exit /b %ERRORLEVEL% \ No newline at end of file