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